diff --git a/backend/cloudbuild.yml b/backend/cloudbuild.yml index a7881989..68ab3637 100644 --- a/backend/cloudbuild.yml +++ b/backend/cloudbuild.yml @@ -22,4 +22,4 @@ substitutions: _TAG: latest options: - defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET + defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/config/db/ProjectDependencySchemaMigration.java b/backend/src/main/java/net/modtale/config/db/ProjectDependencySchemaMigration.java new file mode 100644 index 00000000..67a59855 --- /dev/null +++ b/backend/src/main/java/net/modtale/config/db/ProjectDependencySchemaMigration.java @@ -0,0 +1,140 @@ +package net.modtale.config.db; + +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.ReplaceOptions; +import java.util.List; +import java.util.UUID; +import net.modtale.model.project.ProjectDependency; +import org.bson.Document; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Component; + +@Component +public class ProjectDependencySchemaMigration { + + private static final Logger logger = LoggerFactory.getLogger(ProjectDependencySchemaMigration.class); + + private final MongoTemplate mongoTemplate; + + public ProjectDependencySchemaMigration(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @EventListener(ApplicationReadyEvent.class) + public void migrateLegacyDependencyDocuments() { + MongoCollection projects = mongoTemplate.getCollection("projects"); + int changedProjects = 0; + int changedDependencies = 0; + + for (Document project : projects.find(Filters.or( + Filters.exists("versions.dependencies.modId"), + Filters.exists("versions.dependencies.id", false), + Filters.exists("modIds") + ))) { + int projectChanges = normalizeProject(project); + if (projectChanges == 0) { + continue; + } + + projects.replaceOne( + Filters.eq("_id", project.get("_id")), + project, + new ReplaceOptions().upsert(false) + ); + changedProjects++; + changedDependencies += projectChanges; + } + + if (changedProjects > 0) { + logger.info("Migrated {} legacy dependency references across {} projects.", changedDependencies, changedProjects); + } + } + + private int normalizeProject(Document project) { + int changes = normalizeProjectDependencyIndex(project); + Object rawVersions = project.get("versions"); + if (!(rawVersions instanceof List versions)) { + return changes; + } + + for (Object versionObj : versions) { + if (!(versionObj instanceof Document version)) { + continue; + } + Object rawDependencies = version.get("dependencies"); + if (!(rawDependencies instanceof List dependencies)) { + continue; + } + for (Object dependencyObj : dependencies) { + if (dependencyObj instanceof Document dependency && normalizeDependency(dependency)) { + changes++; + } + } + } + return changes; + } + + private int normalizeProjectDependencyIndex(Document project) { + if (!project.containsKey("modIds")) { + return 0; + } + Object legacyModIds = project.get("modIds"); + if (!project.containsKey("childProjectIds") && legacyModIds instanceof List) { + project.put("childProjectIds", legacyModIds); + } + project.remove("modIds"); + return 1; + } + + private boolean normalizeDependency(Document dependency) { + if (!dependency.containsKey("modId") + && dependency.containsKey("projectId") + && dependency.containsKey("dependencyType") + && dependency.containsKey("id")) { + return false; + } + + Object projectId = firstPresent(dependency, "projectId", "modId"); + Object projectTitle = firstPresent(dependency, "projectTitle", "modTitle"); + dependency.putIfAbsent("id", UUID.randomUUID().toString()); + if (projectId != null) { + dependency.put("projectId", projectId); + } + if (projectTitle != null) { + dependency.put("projectTitle", projectTitle); + } + dependency.putIfAbsent("source", ProjectDependency.Source.MODTALE.name()); + dependency.putIfAbsent("hytaleProjectConfirmed", false); + dependency.put("dependencyType", inferDependencyType(dependency)); + + dependency.remove("modId"); + dependency.remove("modTitle"); + dependency.remove("isOptional"); + dependency.remove("isEmbedded"); + return true; + } + + private Object firstPresent(Document document, String primary, String fallback) { + Object primaryValue = document.get(primary); + return primaryValue != null ? primaryValue : document.get(fallback); + } + + private String inferDependencyType(Document dependency) { + if (Boolean.TRUE.equals(dependency.getBoolean("isEmbedded"))) { + return ProjectDependency.DependencyType.EMBEDDED.name(); + } + if (Boolean.TRUE.equals(dependency.getBoolean("isOptional"))) { + return ProjectDependency.DependencyType.OPTIONAL.name(); + } + Object existingType = dependency.get("dependencyType"); + if (existingType != null) { + return existingType.toString(); + } + return ProjectDependency.DependencyType.REQUIRED.name(); + } +} diff --git a/backend/src/main/java/net/modtale/config/properties/AppCurseForgeProperties.java b/backend/src/main/java/net/modtale/config/properties/AppCurseForgeProperties.java new file mode 100644 index 00000000..6a8c9233 --- /dev/null +++ b/backend/src/main/java/net/modtale/config/properties/AppCurseForgeProperties.java @@ -0,0 +1,13 @@ +package net.modtale.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.curseforge") +public record AppCurseForgeProperties( + String apiKey, + long hytaleGameId +) { + public boolean isConfigured() { + return apiKey != null && !apiKey.isBlank() && hytaleGameId > 0; + } +} diff --git a/backend/src/main/java/net/modtale/controller/project/ExternalProjectController.java b/backend/src/main/java/net/modtale/controller/project/ExternalProjectController.java new file mode 100644 index 00000000..f71686b1 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/project/ExternalProjectController.java @@ -0,0 +1,36 @@ +package net.modtale.controller.project; + +import net.modtale.model.dto.project.ExternalProjectReferenceDTO; +import net.modtale.model.project.ProjectDependency; +import net.modtale.service.project.version.ExternalProjectReferenceService; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.concurrent.TimeUnit; + +@RestController +@RequestMapping("/api/v1") +public class ExternalProjectController { + + private final ExternalProjectReferenceService externalProjectReferenceService; + + public ExternalProjectController(ExternalProjectReferenceService externalProjectReferenceService) { + this.externalProjectReferenceService = externalProjectReferenceService; + } + + @GetMapping("/projects/external/resolve") + @PreAuthorize("@apiSecurity.hasAnyPerm('PROJECT_READ', authentication)") + public ResponseEntity resolveExternalProject( + @RequestParam String url, + @RequestParam(required = false) ProjectDependency.Source source + ) { + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES).cachePublic()) + .body(externalProjectReferenceService.resolve(url, source)); + } +} diff --git a/backend/src/main/java/net/modtale/controller/project/VersionController.java b/backend/src/main/java/net/modtale/controller/project/VersionController.java index ab903677..128d8d67 100644 --- a/backend/src/main/java/net/modtale/controller/project/VersionController.java +++ b/backend/src/main/java/net/modtale/controller/project/VersionController.java @@ -12,6 +12,7 @@ import net.modtale.model.dto.response.project.DownloadUrlResponse; import net.modtale.model.dto.response.project.VersionDependenciesView; import net.modtale.model.user.User; +import net.modtale.model.project.ModpackTarget; import net.modtale.service.project.version.VersionApplicationService; import net.modtale.service.project.version.VersionDownloadPayload; import net.modtale.service.user.account.AccountService; @@ -136,12 +137,14 @@ public ResponseEntity getDownloadUrl( @PathVariable String id, @PathVariable String version, @RequestParam(value = "gameVersion", required = false) String gameVersion, + @RequestParam(value = "target", defaultValue = "UNIVERSAL") ModpackTarget target, Authentication authentication ) { return ResponseEntity.ok(versionApplicationService.createDownloadUrl( id, version, gameVersion, + target, accountService.getCurrentUser(authentication) )); } diff --git a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java index 1d36d37a..aea10b70 100644 --- a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java +++ b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java @@ -249,7 +249,6 @@ public static ProjectDTO toDTO(Project project, boolean isSummary, String curren if (!isSummary) { dto.setAbout(project.getAbout()); dto.setChildProjectIds(project.getChildProjectIds()); - dto.setModIds(project.getModIds()); dto.setProjectRoles(project.getProjectRoles()); dto.setTeamMembers(project.getTeamMembers()); @@ -426,11 +425,21 @@ private static AdminVerificationQueueScanDTO toVerificationQueueScanDTO(ScanResu public static ProjectDependencyDTO toDependencyDTO(ProjectDependency dependency) { if (dependency == null) return null; return new ProjectDependencyDTO( - dependency.getModId(), - dependency.getModTitle(), + dependency.getId(), + dependency.getProjectId(), + dependency.getProjectTitle(), dependency.getVersionNumber(), + dependency.getDependencyType(), + dependency.getEnvironment(), + dependency.getSource(), + dependency.getExternalId(), + dependency.getExternalUrl(), + dependency.getExternalFileUrl(), + dependency.getExternalFileName(), + dependency.getCachedFileUrl(), + dependency.isHytaleProjectConfirmed(), dependency.getIcon(), - dependency.getTitle() != null ? dependency.getTitle() : dependency.getModTitle(), + dependency.getTitle() != null ? dependency.getTitle() : dependency.getProjectTitle(), dependency.getClassification(), dependency.getSlug(), dependency.isOptional(), diff --git a/backend/src/main/java/net/modtale/model/dto/project/ExternalProjectReferenceDTO.java b/backend/src/main/java/net/modtale/model/dto/project/ExternalProjectReferenceDTO.java new file mode 100644 index 00000000..a0637c91 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/project/ExternalProjectReferenceDTO.java @@ -0,0 +1,35 @@ +package net.modtale.model.dto.project; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import java.util.Map; +import net.modtale.model.project.ProjectDependency; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ExternalProjectReferenceDTO( + ProjectDependency.Source source, + String externalId, + String title, + String versionNumber, + String externalUrl, + String iconUrl, + String summary, + boolean hytaleProjectConfirmed, + Boolean distributionAllowed, + List files +) { + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ExternalFileDTO( + String id, + String displayName, + String fileName, + String versionNumber, + String releaseType, + String downloadUrl, + Long fileSize, + Map hashes, + List gameVersions, + Integer fileStatus, + Boolean available + ) {} +} diff --git a/backend/src/main/java/net/modtale/model/dto/project/ManifestDependencySuggestion.java b/backend/src/main/java/net/modtale/model/dto/project/ManifestDependencySuggestion.java index d49af272..2bd96709 100644 --- a/backend/src/main/java/net/modtale/model/dto/project/ManifestDependencySuggestion.java +++ b/backend/src/main/java/net/modtale/model/dto/project/ManifestDependencySuggestion.java @@ -8,7 +8,6 @@ public class ManifestDependencySuggestion { private String versionNumber; private boolean optional; private int confidence; - private String dependencyEntry; public ManifestDependencySuggestion() {} @@ -20,7 +19,6 @@ public ManifestDependencySuggestion(String manifestKey, String requestedVersion, this.versionNumber = versionNumber; this.optional = optional; this.confidence = confidence; - this.dependencyEntry = projectId + ":" + versionNumber + (optional ? ":optional" : ""); } public String getManifestKey() { return manifestKey; } @@ -43,7 +41,4 @@ public ManifestDependencySuggestion(String manifestKey, String requestedVersion, public int getConfidence() { return confidence; } public void setConfidence(int confidence) { this.confidence = confidence; } - - public String getDependencyEntry() { return dependencyEntry; } - public void setDependencyEntry(String dependencyEntry) { this.dependencyEntry = dependencyEntry; } } diff --git a/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java b/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java index a5e0e063..f4ebdbc4 100644 --- a/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java @@ -35,7 +35,6 @@ public class ProjectDTO { private Map links; private List types; private List childProjectIds; - private List modIds; private boolean allowModpacks; private boolean allowComments; private boolean hmWikiEnabled; @@ -107,8 +106,6 @@ public class ProjectDTO { public void setTypes(List types) { this.types = types; } public List getChildProjectIds() { return childProjectIds; } public void setChildProjectIds(List childProjectIds) { this.childProjectIds = childProjectIds; } - public List getModIds() { return modIds; } - public void setModIds(List modIds) { this.modIds = modIds; } public boolean isAllowModpacks() { return allowModpacks; } public void setAllowModpacks(boolean allowModpacks) { this.allowModpacks = allowModpacks; } public boolean isAllowComments() { return allowComments; } diff --git a/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java b/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java index 22be905a..590bc817 100644 --- a/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java @@ -2,12 +2,23 @@ import com.fasterxml.jackson.annotation.JsonInclude; import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; @JsonInclude(JsonInclude.Include.NON_NULL) public record ProjectDependencyDTO( + String id, String projectId, String projectTitle, String versionNumber, + ProjectDependency.DependencyType dependencyType, + ProjectDependency.Environment environment, + ProjectDependency.Source source, + String externalId, + String externalUrl, + String externalFileUrl, + String externalFileName, + String cachedFileUrl, + boolean hytaleProjectConfirmed, String icon, String title, ProjectClassification classification, diff --git a/backend/src/main/java/net/modtale/model/dto/request/project/CreateVersionRequest.java b/backend/src/main/java/net/modtale/model/dto/request/project/CreateVersionRequest.java index c8b6265d..7eefdcdc 100644 --- a/backend/src/main/java/net/modtale/model/dto/request/project/CreateVersionRequest.java +++ b/backend/src/main/java/net/modtale/model/dto/request/project/CreateVersionRequest.java @@ -13,7 +13,7 @@ public class CreateVersionRequest { private List<@NotBlank(message = "Game version entries cannot be blank.") String> gameVersions; private MultipartFile file; - private List<@NotBlank(message = "Dependency entries cannot be blank.") String> modIds; + private List dependencies; private List<@NotBlank(message = "Incompatible project entries cannot be blank.") String> incompatibleProjectIds; @Size(max = 50000, message = "Version changelogs cannot exceed 50,000 characters.") @@ -46,12 +46,12 @@ public void setFile(MultipartFile file) { this.file = file; } - public List getModIds() { - return modIds; + public List getDependencies() { + return dependencies; } - public void setModIds(List modIds) { - this.modIds = modIds; + public void setDependencies(List dependencies) { + this.dependencies = dependencies; } public List getIncompatibleProjectIds() { diff --git a/backend/src/main/java/net/modtale/model/dto/request/project/DependencyReferenceRequest.java b/backend/src/main/java/net/modtale/model/dto/request/project/DependencyReferenceRequest.java new file mode 100644 index 00000000..5ee1bc02 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/project/DependencyReferenceRequest.java @@ -0,0 +1,98 @@ +package net.modtale.model.dto.request.project; + +import java.util.List; +import java.util.Map; +import net.modtale.model.project.ProjectDependency; + +public class DependencyReferenceRequest { + + private String id; + private String projectId; + private String projectTitle; + private String versionNumber; + private ProjectDependency.DependencyType dependencyType = ProjectDependency.DependencyType.REQUIRED; + private ProjectDependency.Environment environment = ProjectDependency.Environment.COMMON; + private ProjectDependency.Source source = ProjectDependency.Source.MODTALE; + private String externalId; + private String externalUrl; + private String externalFileUrl; + private String externalFileName; + private Long externalFileSize; + private Map externalFileHashes; + private List externalGameVersions; + private Integer externalFileStatus; + private Boolean externalDistributionAllowed; + private String cachedFileUrl; + private boolean hytaleProjectConfirmed; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = projectId; } + + public String getProjectTitle() { return projectTitle; } + public void setProjectTitle(String projectTitle) { this.projectTitle = projectTitle; } + + public String getVersionNumber() { return versionNumber; } + public void setVersionNumber(String versionNumber) { this.versionNumber = versionNumber; } + + public ProjectDependency.DependencyType getDependencyType() { + return dependencyType == null ? ProjectDependency.DependencyType.REQUIRED : dependencyType; + } + + public void setDependencyType(ProjectDependency.DependencyType dependencyType) { + this.dependencyType = dependencyType == null ? ProjectDependency.DependencyType.REQUIRED : dependencyType; + } + + public ProjectDependency.Environment getEnvironment() { + return environment == null ? ProjectDependency.Environment.COMMON : environment; + } + + public void setEnvironment(ProjectDependency.Environment environment) { + this.environment = environment == null ? ProjectDependency.Environment.COMMON : environment; + } + + public ProjectDependency.Source getSource() { + return source == null ? ProjectDependency.Source.MODTALE : source; + } + + public void setSource(ProjectDependency.Source source) { + this.source = source == null ? ProjectDependency.Source.MODTALE : source; + } + + public String getExternalId() { return externalId; } + public void setExternalId(String externalId) { this.externalId = externalId; } + + public String getExternalUrl() { return externalUrl; } + public void setExternalUrl(String externalUrl) { this.externalUrl = externalUrl; } + + public String getExternalFileUrl() { return externalFileUrl; } + public void setExternalFileUrl(String externalFileUrl) { this.externalFileUrl = externalFileUrl; } + + public String getExternalFileName() { return externalFileName; } + public void setExternalFileName(String externalFileName) { this.externalFileName = externalFileName; } + + public Long getExternalFileSize() { return externalFileSize; } + public void setExternalFileSize(Long externalFileSize) { this.externalFileSize = externalFileSize; } + + public Map getExternalFileHashes() { return externalFileHashes; } + public void setExternalFileHashes(Map externalFileHashes) { this.externalFileHashes = externalFileHashes; } + + public List getExternalGameVersions() { return externalGameVersions; } + public void setExternalGameVersions(List externalGameVersions) { this.externalGameVersions = externalGameVersions; } + + public Integer getExternalFileStatus() { return externalFileStatus; } + public void setExternalFileStatus(Integer externalFileStatus) { this.externalFileStatus = externalFileStatus; } + + public Boolean getExternalDistributionAllowed() { return externalDistributionAllowed; } + public void setExternalDistributionAllowed(Boolean externalDistributionAllowed) { this.externalDistributionAllowed = externalDistributionAllowed; } + + public String getCachedFileUrl() { return cachedFileUrl; } + public void setCachedFileUrl(String cachedFileUrl) { this.cachedFileUrl = cachedFileUrl; } + + public boolean isHytaleProjectConfirmed() { return hytaleProjectConfirmed; } + public void setHytaleProjectConfirmed(boolean hytaleProjectConfirmed) { + this.hytaleProjectConfirmed = hytaleProjectConfirmed; + } +} diff --git a/backend/src/main/java/net/modtale/model/dto/request/project/UpdateVersionRequest.java b/backend/src/main/java/net/modtale/model/dto/request/project/UpdateVersionRequest.java index fb568a25..ec6448b1 100644 --- a/backend/src/main/java/net/modtale/model/dto/request/project/UpdateVersionRequest.java +++ b/backend/src/main/java/net/modtale/model/dto/request/project/UpdateVersionRequest.java @@ -6,7 +6,7 @@ import net.modtale.model.project.ProjectVersion; public class UpdateVersionRequest { - private List<@NotBlank(message = "Dependency entries cannot be blank.") String> modIds; + private List dependencies; private List<@NotBlank(message = "Incompatible project entries cannot be blank.") String> incompatibleProjectIds; private List<@NotBlank(message = "Game version entries cannot be blank.") String> gameVersions; @@ -15,12 +15,12 @@ public class UpdateVersionRequest { private ProjectVersion.Channel channel; - public List getModIds() { - return modIds; + public List getDependencies() { + return dependencies; } - public void setModIds(List modIds) { - this.modIds = modIds; + public void setDependencies(List dependencies) { + this.dependencies = dependencies; } public List getIncompatibleProjectIds() { diff --git a/backend/src/main/java/net/modtale/model/project/ModpackTarget.java b/backend/src/main/java/net/modtale/model/project/ModpackTarget.java new file mode 100644 index 00000000..e625979f --- /dev/null +++ b/backend/src/main/java/net/modtale/model/project/ModpackTarget.java @@ -0,0 +1,16 @@ +package net.modtale.model.project; + +public enum ModpackTarget { + UNIVERSAL, + CLIENT, + SERVER; + + public boolean includes(ProjectDependency.Environment environment) { + ProjectDependency.Environment effective = environment == null + ? ProjectDependency.Environment.COMMON + : environment; + return this == UNIVERSAL + || this == CLIENT && effective != ProjectDependency.Environment.SERVER + || this == SERVER && effective != ProjectDependency.Environment.CLIENT; + } +} diff --git a/backend/src/main/java/net/modtale/model/project/Project.java b/backend/src/main/java/net/modtale/model/project/Project.java index 94950fa0..50146881 100644 --- a/backend/src/main/java/net/modtale/model/project/Project.java +++ b/backend/src/main/java/net/modtale/model/project/Project.java @@ -170,8 +170,6 @@ public ProjectMember(String userId, String roleId) { private List types; private List childProjectIds; - - private List modIds; private boolean allowModpacks = true; private boolean allowComments = true; @@ -270,8 +268,6 @@ public Project() {} public void setTypes(List types) { this.types = types; } public List getChildProjectIds() { return childProjectIds; } public void setChildProjectIds(List childProjectIds) { this.childProjectIds = childProjectIds; } - public List getModIds() { return modIds; } - public void setModIds(List modIds) { this.modIds = modIds; } public boolean isAllowModpacks() { return allowModpacks; } public void setAllowModpacks(boolean allowModpacks) { this.allowModpacks = allowModpacks; } public boolean isAllowComments() { return allowComments; } diff --git a/backend/src/main/java/net/modtale/model/project/ProjectDependency.java b/backend/src/main/java/net/modtale/model/project/ProjectDependency.java index 0713082e..77a25485 100644 --- a/backend/src/main/java/net/modtale/model/project/ProjectDependency.java +++ b/backend/src/main/java/net/modtale/model/project/ProjectDependency.java @@ -2,17 +2,37 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import java.util.UUID; import org.springframework.data.annotation.Transient; @JsonInclude(JsonInclude.Include.NON_NULL) public class ProjectDependency { - @JsonProperty("projectId") - private String modId; + public enum Source { + MODTALE, + CURSEFORGE, + GITHUB, + WEBSITE, + OTHER + } + + public enum DependencyType { + REQUIRED, + OPTIONAL, + EMBEDDED + } - @JsonProperty("projectTitle") - private String modTitle; + public enum Environment { + COMMON, + CLIENT, + SERVER + } + private String id = UUID.randomUUID().toString(); + private String projectId; + private String projectTitle; private String versionNumber; @Transient private String icon; @@ -22,38 +42,74 @@ public class ProjectDependency { private ProjectClassification classification; @Transient private String slug; - private boolean isOptional; - private boolean isEmbedded; + private DependencyType dependencyType = DependencyType.REQUIRED; + private Environment environment = Environment.COMMON; + private Source source = Source.MODTALE; + private String externalId; + private String externalUrl; + private String externalFileUrl; + private String externalFileName; + private Long externalFileSize; + private Map externalFileHashes; + private List externalGameVersions; + private Integer externalFileStatus; + private Boolean externalDistributionAllowed; + private String cachedFileUrl; + private boolean hytaleProjectConfirmed; public ProjectDependency() {} - public ProjectDependency(String modId, String modTitle, String versionNumber) { - this(modId, modTitle, versionNumber, false, false); + public ProjectDependency(String projectId, String projectTitle, String versionNumber) { + this(projectId, projectTitle, versionNumber, DependencyType.REQUIRED); } - public ProjectDependency(String modId, String modTitle, String versionNumber, boolean isOptional) { - this(modId, modTitle, versionNumber, isOptional, false); + public ProjectDependency(String projectId, String projectTitle, String versionNumber, DependencyType dependencyType) { + this.projectId = projectId; + this.projectTitle = projectTitle; + this.versionNumber = versionNumber; + setDependencyType(dependencyType); } - public ProjectDependency(String modId, String modTitle, String versionNumber, boolean isOptional, boolean isEmbedded) { - this.modId = modId; - this.modTitle = modTitle; - this.versionNumber = versionNumber; - this.isOptional = isOptional; - this.isEmbedded = isEmbedded; + public static ProjectDependency modtale(String projectId, String projectTitle, String versionNumber, DependencyType dependencyType) { + ProjectDependency dependency = new ProjectDependency(projectId, projectTitle, versionNumber, dependencyType); + dependency.setSource(Source.MODTALE); + return dependency; + } + + public static ProjectDependency curseForge( + String externalId, + String title, + String versionNumber, + String externalUrl, + DependencyType dependencyType + ) { + return external(Source.CURSEFORGE, externalId, title, versionNumber, externalUrl, dependencyType); } - @JsonProperty("projectId") - public String getModId() { return modId; } + public static ProjectDependency external( + Source source, + String externalId, + String title, + String versionNumber, + String externalUrl, + DependencyType dependencyType + ) { + Source externalSource = source == null || source == Source.MODTALE ? Source.OTHER : source; + ProjectDependency dependency = new ProjectDependency(externalSource.name().toLowerCase() + ":" + externalId, title, versionNumber, dependencyType); + dependency.setSource(externalSource); + dependency.setExternalId(externalId); + dependency.setExternalUrl(externalUrl); + return dependency; + } - @JsonProperty("projectId") - public void setModId(String modId) { this.modId = modId; } + public String getId() { return id; } + public void setId(String id) { this.id = id == null || id.isBlank() ? UUID.randomUUID().toString() : id; } - @JsonProperty("projectTitle") - public String getModTitle() { return modTitle; } + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = projectId; } - @JsonProperty("projectTitle") - public void setModTitle(String modTitle) { this.modTitle = modTitle; } + public String getProjectTitle() { return projectTitle; } + public void setProjectTitle(String projectTitle) { this.projectTitle = projectTitle; } public String getVersionNumber() { return versionNumber; } public void setVersionNumber(String versionNumber) { this.versionNumber = versionNumber; } @@ -70,13 +126,61 @@ public ProjectDependency(String modId, String modTitle, String versionNumber, bo public String getSlug() { return slug; } public void setSlug(String slug) { this.slug = slug; } - @JsonProperty("isOptional") - public boolean isOptional() { return isOptional; } + public DependencyType getDependencyType() { return dependencyType == null ? DependencyType.REQUIRED : dependencyType; } + public void setDependencyType(DependencyType dependencyType) { + this.dependencyType = dependencyType == null ? DependencyType.REQUIRED : dependencyType; + } + + public Environment getEnvironment() { return environment == null ? Environment.COMMON : environment; } + public void setEnvironment(Environment environment) { + this.environment = environment == null ? Environment.COMMON : environment; + } + + public Source getSource() { return source == null ? Source.MODTALE : source; } + public void setSource(Source source) { this.source = source == null ? Source.MODTALE : source; } - public void setOptional(boolean optional) { isOptional = optional; } + public String getExternalId() { return externalId; } + public void setExternalId(String externalId) { this.externalId = externalId; } + + public String getExternalUrl() { return externalUrl; } + public void setExternalUrl(String externalUrl) { this.externalUrl = externalUrl; } + + public String getExternalFileUrl() { return externalFileUrl; } + public void setExternalFileUrl(String externalFileUrl) { this.externalFileUrl = externalFileUrl; } + + public String getExternalFileName() { return externalFileName; } + public void setExternalFileName(String externalFileName) { this.externalFileName = externalFileName; } + + public Long getExternalFileSize() { return externalFileSize; } + public void setExternalFileSize(Long externalFileSize) { this.externalFileSize = externalFileSize; } + + public Map getExternalFileHashes() { return externalFileHashes; } + public void setExternalFileHashes(Map externalFileHashes) { this.externalFileHashes = externalFileHashes; } + + public List getExternalGameVersions() { return externalGameVersions; } + public void setExternalGameVersions(List externalGameVersions) { this.externalGameVersions = externalGameVersions; } + + public Integer getExternalFileStatus() { return externalFileStatus; } + public void setExternalFileStatus(Integer externalFileStatus) { this.externalFileStatus = externalFileStatus; } + + public Boolean getExternalDistributionAllowed() { return externalDistributionAllowed; } + public void setExternalDistributionAllowed(Boolean externalDistributionAllowed) { this.externalDistributionAllowed = externalDistributionAllowed; } + + public String getCachedFileUrl() { return cachedFileUrl; } + public void setCachedFileUrl(String cachedFileUrl) { this.cachedFileUrl = cachedFileUrl; } + + public boolean isExternal() { + return getSource() != Source.MODTALE; + } + + public boolean isHytaleProjectConfirmed() { return hytaleProjectConfirmed; } + public void setHytaleProjectConfirmed(boolean hytaleProjectConfirmed) { + this.hytaleProjectConfirmed = hytaleProjectConfirmed; + } @JsonProperty("isEmbedded") - public boolean isEmbedded() { return isEmbedded; } + public boolean isEmbedded() { return getDependencyType() == DependencyType.EMBEDDED; } - public void setEmbedded(boolean embedded) { isEmbedded = embedded; } + @JsonProperty("isOptional") + public boolean isOptional() { return getDependencyType() == DependencyType.OPTIONAL; } } diff --git a/backend/src/main/java/net/modtale/model/project/ProjectVersion.java b/backend/src/main/java/net/modtale/model/project/ProjectVersion.java index 1670a875..8775a215 100644 --- a/backend/src/main/java/net/modtale/model/project/ProjectVersion.java +++ b/backend/src/main/java/net/modtale/model/project/ProjectVersion.java @@ -7,6 +7,7 @@ public class ProjectVersion { private String versionNumber; private List gameVersions; private String fileUrl; + private String overrideFileUrl; private String hash; private int downloadCount; private String releaseDate; @@ -83,6 +84,9 @@ public ApprovedIssueBaseline( public String getFileUrl() { return fileUrl; } public void setFileUrl(String fileUrl) { this.fileUrl = fileUrl; } + public String getOverrideFileUrl() { return overrideFileUrl; } + public void setOverrideFileUrl(String overrideFileUrl) { this.overrideFileUrl = overrideFileUrl; } + public String getHash() { return hash; } public void setHash(String hash) { this.hash = hash; } diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectArtifactDeletionService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectArtifactDeletionService.java index a6b173fb..0b1243da 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectArtifactDeletionService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectArtifactDeletionService.java @@ -17,6 +17,10 @@ public ProjectArtifactDeletionService(StorageService storageService) { public void deleteVersionFile(ProjectVersion version) { if (version != null) { deleteStoredFile(version.getFileUrl()); + if (version.getOverrideFileUrl() != null + && !version.getOverrideFileUrl().equals(version.getFileUrl())) { + deleteStoredFile(version.getOverrideFileUrl()); + } } } diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java index ffcaf48c..23196ffb 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java @@ -77,12 +77,16 @@ public void hardDelete(Project project) { project.getVersions().forEach(version -> { projectArtifactDeletionService.deleteVersionFile(version); if (version.getDependencies() != null) { - version.getDependencies().forEach(dependency -> dependencyIds.add(dependency.getModId())); + version.getDependencies().forEach(dependency -> { + if (!dependency.isExternal()) { + dependencyIds.add(dependency.getProjectId()); + } + }); } }); } - if (project.getModIds() != null) { - dependencyIds.addAll(project.getModIds()); + if (project.getChildProjectIds() != null) { + dependencyIds.addAll(project.getChildProjectIds()); } projectArtifactDeletionService.deleteProjectMedia(project); diff --git a/backend/src/main/java/net/modtale/service/project/query/ProjectViewService.java b/backend/src/main/java/net/modtale/service/project/query/ProjectViewService.java index e08d40c9..7ad585fd 100644 --- a/backend/src/main/java/net/modtale/service/project/query/ProjectViewService.java +++ b/backend/src/main/java/net/modtale/service/project/query/ProjectViewService.java @@ -558,7 +558,7 @@ private void populateDependencyMetadata(Project project, boolean privileged) { Set dependencyIds = project.getVersions().stream() .filter(version -> version.getDependencies() != null) .flatMap(version -> version.getDependencies().stream()) - .map(ProjectDependency::getModId) + .map(ProjectDependency::getProjectId) .filter(id -> id != null && !id.isBlank()) .collect(Collectors.toSet()); @@ -588,16 +588,16 @@ private void populateDependencyMetadata(Project project, boolean privileged) { .filter(version -> version.getDependencies() != null) .flatMap(version -> version.getDependencies().stream()) .forEach(dependency -> { - Project dependencyProject = dependencyProjects.get(dependency.getModId()); + Project dependencyProject = dependencyProjects.get(dependency.getProjectId()); if (dependencyProject == null) { - dependency.setTitle(dependency.getModTitle()); + dependency.setTitle(dependency.getProjectTitle()); return; } - if (dependency.getModTitle() == null || dependency.getModTitle().isBlank()) { - dependency.setModTitle(dependencyProject.getTitle()); + if (dependency.getProjectTitle() == null || dependency.getProjectTitle().isBlank()) { + dependency.setProjectTitle(dependencyProject.getTitle()); } - dependency.setTitle(dependencyProject.getTitle() != null ? dependencyProject.getTitle() : dependency.getModTitle()); + dependency.setTitle(dependencyProject.getTitle() != null ? dependencyProject.getTitle() : dependency.getProjectTitle()); dependency.setIcon(dependencyProject.getImageUrl() != null ? dependencyProject.getImageUrl() : ""); dependency.setClassification(dependencyProject.getClassification()); dependency.setSlug(dependencyProject.getSlug() != null && !dependencyProject.getSlug().isBlank() diff --git a/backend/src/main/java/net/modtale/service/project/version/CurseForgeApiClient.java b/backend/src/main/java/net/modtale/service/project/version/CurseForgeApiClient.java new file mode 100644 index 00000000..9ee584e6 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/project/version/CurseForgeApiClient.java @@ -0,0 +1,260 @@ +package net.modtale.service.project.version; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import net.modtale.config.properties.AppCurseForgeProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.RequestEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +public class CurseForgeApiClient { + + private static final int MAX_FILES = 20; + private static final int MAX_CACHE_ENTRIES = 500; + private static final Duration CACHE_TTL = Duration.ofMinutes(10); + private static final String API_BASE = "https://api.curseforge.com"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final AppCurseForgeProperties properties; + private final RestTemplate restTemplate; + private final ConcurrentMap cache = new ConcurrentHashMap<>(); + + @Autowired + public CurseForgeApiClient(AppCurseForgeProperties properties) { + this(properties, createRestTemplate()); + } + + private static RestTemplate createRestTemplate() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(Duration.ofSeconds(3)); + requestFactory.setReadTimeout(Duration.ofSeconds(5)); + return new RestTemplate(requestFactory); + } + + CurseForgeApiClient(AppCurseForgeProperties properties, RestTemplate restTemplate) { + this.properties = properties; + this.restTemplate = restTemplate; + } + + public boolean isConfigured() { + return properties.isConfigured(); + } + + public Optional resolveProject(String slug, String requestedFileId) { + if (!properties.isConfigured() || slug == null || slug.isBlank() + || (requestedFileId != null && !requestedFileId.matches("[0-9]+"))) { + return Optional.empty(); + } + + String cacheKey = slug.toLowerCase(Locale.ROOT) + ":" + (requestedFileId == null ? "latest" : requestedFileId); + CachedProject cached = cache.get(cacheKey); + if (cached != null && cached.expiresAt().isAfter(Instant.now())) { + return Optional.of(cached.project()); + } + if (cached != null) cache.remove(cacheKey, cached); + + try { + URI searchUri = UriComponentsBuilder.fromUriString(API_BASE) + .path("/v1/mods/search") + .queryParam("gameId", properties.hytaleGameId()) + .queryParam("slug", slug) + .queryParam("pageSize", 1) + .build() + .encode() + .toUri(); + JsonNode projectEnvelope = get(searchUri); + JsonNode projects = projectEnvelope.path("data"); + if (!projects.isArray() || projects.isEmpty()) { + return Optional.empty(); + } + + JsonNode project = projects.get(0); + String resolvedSlug = text(project, "slug"); + long projectId = project.path("id").asLong(0); + if (projectId <= 0 || resolvedSlug == null || !resolvedSlug.equalsIgnoreCase(slug) + || project.path("gameId").asLong(0) != properties.hytaleGameId() + || !project.path("isAvailable").asBoolean(false)) { + return Optional.empty(); + } + + List files = requestedFileId == null + ? getRecentFiles(projectId) + : getExactFile(projectId, requestedFileId); + if (requestedFileId != null && files.isEmpty()) return Optional.empty(); + + CurseForgeProject resolved = new CurseForgeProject( + Long.toString(projectId), + resolvedSlug, + text(project, "name"), + text(project, "summary"), + project.path("logo").path("thumbnailUrl").textValue(), + project.has("allowModDistribution") ? project.path("allowModDistribution").booleanValue() : null, + files + ); + if (cache.size() >= MAX_CACHE_ENTRIES) cache.clear(); + cache.put(cacheKey, new CachedProject(resolved, Instant.now().plus(CACHE_TTL))); + return Optional.of(resolved); + } catch (RestClientException | IllegalArgumentException | java.io.IOException ex) { + return Optional.empty(); + } + } + + private List getRecentFiles(long projectId) throws java.io.IOException { + URI filesUri = UriComponentsBuilder.fromUriString(API_BASE) + .path("/v1/mods/{projectId}/files") + .queryParam("pageSize", 50) + .buildAndExpand(projectId) + .encode() + .toUri(); + return parseFiles(get(filesUri).path("data"), projectId); + } + + private List getExactFile(long projectId, String fileId) throws java.io.IOException { + URI fileUri = UriComponentsBuilder.fromUriString(API_BASE) + .path("/v1/mods/{projectId}/files/{fileId}") + .buildAndExpand(projectId, fileId) + .encode() + .toUri(); + CurseForgeFile file = parseFile(get(fileUri).path("data"), projectId); + return file == null ? List.of() : List.of(file); + } + + private JsonNode get(URI uri) throws java.io.IOException { + RequestEntity request = RequestEntity.get(uri) + .header(HttpHeaders.ACCEPT, "application/json") + .header(HttpHeaders.USER_AGENT, "Modtale/1.0 (+https://modtale.net)") + .header("x-api-key", properties.apiKey().trim()) + .build(); + String body = restTemplate.exchange(request, String.class).getBody(); + if (body == null || body.isBlank()) { + throw new java.io.IOException("CurseForge returned an empty response."); + } + return OBJECT_MAPPER.readTree(body); + } + + private List parseFiles(JsonNode data, long projectId) { + if (!data.isArray()) { + return List.of(); + } + List files = new ArrayList<>(); + for (JsonNode file : data) { + CurseForgeFile parsed = parseFile(file, projectId); + if (parsed != null) files.add(parsed); + } + files.sort(Comparator.comparing(CurseForgeFile::fileDate, Comparator.nullsLast(String::compareTo)).reversed()); + return files.stream().limit(MAX_FILES).toList(); + } + + private CurseForgeFile parseFile(JsonNode file, long projectId) { + long fileId = file.path("id").asLong(0); + if (fileId <= 0 || file.path("modId").asLong(0) != projectId || !file.path("isAvailable").asBoolean(false)) { + return null; + } + return new CurseForgeFile( + Long.toString(fileId), + text(file, "displayName"), + text(file, "fileName"), + inferVersion(file), + releaseType(file.path("releaseType").asInt(0)), + text(file, "fileDate"), + file.path("fileLength").canConvertToLong() && file.path("fileLength").asLong() > 0 + ? file.path("fileLength").asLong() : null, + parseHashes(file.path("hashes")), + parseGameVersions(file.path("gameVersions")), + file.path("fileStatus").canConvertToInt() && file.path("fileStatus").asInt() > 0 + ? file.path("fileStatus").asInt() : null, + true + ); + } + + private Map parseHashes(JsonNode hashes) { + if (!hashes.isArray()) return Map.of(); + Map result = new LinkedHashMap<>(); + for (JsonNode hash : hashes) { + String algorithm = switch (hash.path("algo").asInt(0)) { + case 1 -> "sha1"; + case 2 -> "md5"; + default -> null; + }; + String value = text(hash, "value"); + if (algorithm != null && value != null && value.matches("(?i)[a-f0-9]+")) { + int expectedLength = "sha1".equals(algorithm) ? 40 : 32; + if (value.length() == expectedLength) result.put(algorithm, value.toLowerCase(Locale.ROOT)); + } + } + return Map.copyOf(result); + } + + private List parseGameVersions(JsonNode versions) { + if (!versions.isArray()) return List.of(); + List result = new ArrayList<>(); + for (JsonNode version : versions) { + String value = version.textValue(); + if (value != null && !value.isBlank() && !result.contains(value.trim())) result.add(value.trim()); + } + return List.copyOf(result); + } + + private String inferVersion(JsonNode file) { + String displayName = text(file, "displayName"); + return displayName == null ? text(file, "fileName") : displayName; + } + + private String releaseType(int value) { + return switch (value) { + case 1 -> "RELEASE"; + case 2 -> "BETA"; + case 3 -> "ALPHA"; + default -> null; + }; + } + + private String text(JsonNode node, String field) { + String value = node.path(field).textValue(); + return value == null || value.isBlank() ? null : value.trim(); + } + + public record CurseForgeProject( + String id, + String slug, + String title, + String summary, + String iconUrl, + Boolean distributionAllowed, + List files + ) {} + + public record CurseForgeFile( + String id, + String displayName, + String fileName, + String versionNumber, + String releaseType, + String fileDate, + Long fileSize, + Map hashes, + List gameVersions, + Integer fileStatus, + boolean available + ) {} + + private record CachedProject(CurseForgeProject project, Instant expiresAt) {} +} diff --git a/backend/src/main/java/net/modtale/service/project/version/ExternalDependencyArtifactService.java b/backend/src/main/java/net/modtale/service/project/version/ExternalDependencyArtifactService.java new file mode 100644 index 00000000..c6d9f004 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/project/version/ExternalDependencyArtifactService.java @@ -0,0 +1,465 @@ +package net.modtale.service.project.version; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.IDN; +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.OptionalLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.modtale.exception.InvalidVersionRequestException; +import net.modtale.model.project.ProjectDependency; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class ExternalDependencyArtifactService { + + private static final long MAX_EXTERNAL_FILE_BYTES = 100L * 1024 * 1024; + private static final int MAX_REDIRECTS = 6; + private static final String USER_AGENT = "Modtale/1.0"; + private static final Pattern CURSEFORGE_DOWNLOAD_PATH = + Pattern.compile(".*/api/v1/mods/(\\d+)/files/(\\d+)/download/?", Pattern.CASE_INSENSITIVE); + private static final Pattern CURSEFORGE_FILE_PAGE_PATH = + Pattern.compile(".*/files/(\\d+)/?.*", Pattern.CASE_INSENSITIVE); + private static final Pattern FORGECDN_FILE_PATH = + Pattern.compile(".*/files/(\\d+)/(\\d+)/[^/]+", Pattern.CASE_INSENSITIVE); + + private final HttpClient httpClient; + private final CurseForgeApiClient curseForgeApiClient; + + public ExternalDependencyArtifactService() { + this(null, + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + ); + } + + @Autowired + public ExternalDependencyArtifactService(CurseForgeApiClient curseForgeApiClient) { + this(curseForgeApiClient, + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + ); + } + + ExternalDependencyArtifactService(HttpClient httpClient) { + this(null, httpClient); + } + + ExternalDependencyArtifactService(CurseForgeApiClient curseForgeApiClient, HttpClient httpClient) { + this.curseForgeApiClient = curseForgeApiClient; + this.httpClient = httpClient; + } + + public void prepareExternalArtifacts(List dependencies) { + if (dependencies == null || dependencies.isEmpty()) { + return; + } + + for (ProjectDependency dependency : dependencies) { + if (dependency == null || !dependency.isExternal()) { + continue; + } + prepareExternalArtifact(dependency); + } + } + + private void prepareExternalArtifact(ProjectDependency dependency) { + String fileUrl = trimToNull(dependency.getExternalFileUrl()); + if (fileUrl == null && isDownloadableArchiveUrl(dependency.getExternalUrl())) { + fileUrl = dependency.getExternalUrl().trim(); + dependency.setExternalFileUrl(fileUrl); + } + + if (dependency.getSource() == ProjectDependency.Source.CURSEFORGE) { + prepareCurseForgeReference(dependency, fileUrl); + return; + } + + if (fileUrl != null) { + validateExternalFileLink(dependency.getSource(), fileUrl); + dependency.setExternalFileUrl(fileUrl); + if (trimToNull(dependency.getExternalFileName()) == null) { + dependency.setExternalFileName(fileNameFromUrl(fileUrl, "external-dependency.jar")); + } + } + } + + private void prepareCurseForgeReference(ProjectDependency dependency, String fileUrl) { + CurseForgeFileReference reference = resolveCurseForgeFileReference(dependency, fileUrl); + if (reference == null) { + throw new InvalidVersionRequestException("CurseForge dependencies must link to a specific Hytale file page."); + } + String filename = trimToNull(dependency.getExternalFileName()); + if (filename == null) { + filename = reference.fileName(); + } + dependency.setExternalUrl(reference.filePageUrl()); + dependency.setExternalFileUrl(reference.filePageUrl()); + dependency.setExternalFileName(filename == null ? "curseforge-" + reference.fileId() + ".jar" : sanitizeArchiveFilename(filename)); + dependency.setCachedFileUrl(null); + applyVerifiedCurseForgeMetadata(dependency, reference); + } + + private void applyVerifiedCurseForgeMetadata(ProjectDependency dependency, CurseForgeFileReference reference) { + clearCurseForgeMetadata(dependency); + if (curseForgeApiClient == null || !curseForgeApiClient.isConfigured()) return; + + String slug = curseForgeSlug(reference.filePageUrl()); + CurseForgeApiClient.CurseForgeProject project = slug == null ? null + : curseForgeApiClient.resolveProject(slug, reference.fileId()).orElse(null); + CurseForgeApiClient.CurseForgeFile file = project == null ? null : project.files().stream() + .filter(candidate -> reference.fileId().equals(candidate.id())) + .findFirst() + .orElse(null); + if (project == null || file == null || !file.available()) return; + + dependency.setExternalId(project.id()); + dependency.setVersionNumber(trimToNull(file.versionNumber()) == null ? dependency.getVersionNumber() : file.versionNumber()); + if (trimToNull(file.fileName()) != null) dependency.setExternalFileName(sanitizeArchiveFilename(file.fileName())); + dependency.setExternalFileSize(file.fileSize()); + dependency.setExternalFileHashes(file.hashes().isEmpty() ? null : file.hashes()); + dependency.setExternalGameVersions(file.gameVersions().isEmpty() ? null : file.gameVersions()); + dependency.setExternalFileStatus(file.fileStatus()); + dependency.setExternalDistributionAllowed(project.distributionAllowed()); + } + + private void clearCurseForgeMetadata(ProjectDependency dependency) { + dependency.setExternalFileSize(null); + dependency.setExternalFileHashes(null); + dependency.setExternalGameVersions(null); + dependency.setExternalFileStatus(null); + dependency.setExternalDistributionAllowed(null); + } + + private String curseForgeSlug(String value) { + URI uri = parseUri(value); + if (uri == null || uri.getPath() == null) return null; + String[] segments = uri.getPath().split("/"); + for (int i = 0; i < segments.length - 1; i++) { + if ("mods".equalsIgnoreCase(segments[i]) && segments[i + 1].matches("[A-Za-z0-9][A-Za-z0-9_-]*")) { + return segments[i + 1]; + } + } + return null; + } + + private void validateExternalFileLink(ProjectDependency.Source source, String fileUrl) { + if (!isDownloadableArchiveUrl(fileUrl)) { + throw new InvalidVersionRequestException(sourceLabel(source) + " dependency files must link directly to a .jar or .zip file."); + } + + validateInitialSourceUrl(source, fileUrl); + DownloadedFile sample = downloadFile(fileUrl, false); + validateArchiveSignature(sample.bytes(), sourceLabel(source) + " dependency file"); + } + + private DownloadedFile downloadFile(String rawUrl, boolean fullFile) { + URI uri = requirePublicHttpsUri(rawUrl); + for (int redirect = 0; redirect <= MAX_REDIRECTS; redirect++) { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(uri) + .timeout(Duration.ofSeconds(30)) + .header("User-Agent", USER_AGENT) + .GET(); + if (!fullFile) { + requestBuilder.header("Range", "bytes=0-3"); + } + + try { + HttpResponse response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream body = response.body()) { + int status = response.statusCode(); + if (isRedirect(status)) { + String location = response.headers().firstValue("location") + .orElseThrow(() -> new InvalidVersionRequestException("External dependency file redirect was missing a destination.")); + uri = requirePublicHttpsUri(uri.resolve(location).toString()); + continue; + } + + if (status < 200 || status >= 300) { + throw new InvalidVersionRequestException("External dependency file could not be downloaded for validation."); + } + + OptionalLong declaredLength = declaredContentLength(response); + if (declaredLength.isPresent() && declaredLength.getAsLong() > MAX_EXTERNAL_FILE_BYTES) { + throw new InvalidVersionRequestException("External dependency files must be 100MB or smaller."); + } + + long limit = fullFile ? MAX_EXTERNAL_FILE_BYTES + 1 : 4; + byte[] bytes = readLimited(body, limit); + return new DownloadedFile(bytes, response.headers().firstValue("content-type").orElse("application/octet-stream"), uri); + } + } catch (IOException ex) { + throw new InvalidVersionRequestException("External dependency file could not be downloaded for validation."); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new InvalidVersionRequestException("External dependency file validation was interrupted."); + } + } + + throw new InvalidVersionRequestException("External dependency file followed too many redirects."); + } + + private void validateInitialSourceUrl(ProjectDependency.Source source, String rawUrl) { + URI uri = requirePublicHttpsUri(rawUrl); + String host = uri.getHost(); + boolean valid = switch (source) { + case CURSEFORGE -> isHost(host, "curseforge.com") || isHost(host, "forgecdn.net") || isHost(host, "mediafilez.forgecdn.net"); + case GITHUB -> isHost(host, "github.com") || isHost(host, "raw.githubusercontent.com") || isHost(host, "githubusercontent.com"); + case WEBSITE, OTHER -> true; + case MODTALE -> false; + }; + if (!valid) { + throw new InvalidVersionRequestException(sourceLabel(source) + " dependency files must use a matching external service URL."); + } + } + + private CurseForgeFileReference resolveCurseForgeFileReference(ProjectDependency dependency, String fileUrl) { + String modId = numericOrNull(dependency.getExternalId()); + String fileId = null; + String filename = trimToNull(dependency.getExternalFileName()); + + CurseForgeDownloadPath parsedDownload = parseCurseForgeDownloadPath(fileUrl); + if (parsedDownload != null) { + modId = modId == null ? parsedDownload.modId() : modId; + fileId = parsedDownload.fileId(); + } + + if (fileId == null) { + fileId = extractCurseForgeFileId(fileUrl); + } + if (fileId == null) { + fileId = extractCurseForgeFileId(dependency.getExternalUrl()); + } + if (fileId == null) { + fileId = extractForgeCdnFileId(fileUrl); + } + if (filename == null) { + filename = fileNameFromUrl(fileUrl, null); + } + + if (fileId == null) { + return null; + } + URI projectUri = parseUri(dependency.getExternalUrl()); + if (projectUri == null || !"https".equalsIgnoreCase(projectUri.getScheme()) + || projectUri.getRawUserInfo() != null || (projectUri.getPort() != -1 && projectUri.getPort() != 443) + || !isHost(projectUri.getHost(), "curseforge.com") + || projectUri.getPath() == null || !projectUri.getPath().toLowerCase(Locale.ROOT).startsWith("/hytale/mods/")) { + return null; + } + String filePageUrl = projectUri.resolve(projectUri.getPath().replaceFirst("(?i)/files/.*$", "") + "/files/" + fileId).toString(); + return new CurseForgeFileReference(modId, fileId, filePageUrl, filename); + } + + private CurseForgeDownloadPath parseCurseForgeDownloadPath(String value) { + URI uri = parseUri(value); + if (uri == null || uri.getPath() == null) { + return null; + } + Matcher matcher = CURSEFORGE_DOWNLOAD_PATH.matcher(uri.getPath()); + if (!matcher.matches()) { + return null; + } + return new CurseForgeDownloadPath(matcher.group(1), matcher.group(2)); + } + + private String extractCurseForgeFileId(String value) { + URI uri = parseUri(value); + if (uri == null || uri.getPath() == null) { + return null; + } + Matcher matcher = CURSEFORGE_FILE_PAGE_PATH.matcher(uri.getPath()); + return matcher.matches() ? numericOrNull(matcher.group(1)) : null; + } + + private String extractForgeCdnFileId(String value) { + URI uri = parseUri(value); + if (uri == null || uri.getPath() == null) { + return null; + } + Matcher matcher = FORGECDN_FILE_PATH.matcher(uri.getPath()); + if (!matcher.matches()) { + return null; + } + return numericOrNull(matcher.group(1) + matcher.group(2)); + } + + private URI requirePublicHttpsUri(String value) { + try { + URI uri = new URI(value).normalize(); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getHost().isBlank()) { + throw new InvalidVersionRequestException("External dependency files must use a valid HTTPS URL."); + } + validatePublicHost(uri.getHost()); + return uri; + } catch (URISyntaxException ex) { + throw new InvalidVersionRequestException("External dependency files must use a valid HTTPS URL."); + } + } + + private void validatePublicHost(String rawHost) { + String host = IDN.toASCII(rawHost).toLowerCase(Locale.ROOT); + if (host.equals("localhost") || host.endsWith(".localhost") || host.endsWith(".local")) { + throw new InvalidVersionRequestException("External dependency files must use a public HTTPS host."); + } + + try { + for (InetAddress address : InetAddress.getAllByName(host)) { + if (address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress()) { + throw new InvalidVersionRequestException("External dependency files must use a public HTTPS host."); + } + } + } catch (UnknownHostException ex) { + throw new InvalidVersionRequestException("External dependency file host could not be resolved."); + } + } + + private OptionalLong declaredContentLength(HttpResponse response) { + OptionalLong contentLength = parseLongHeader(response.headers().firstValue("content-length").orElse(null)); + if (contentLength.isPresent()) { + return contentLength; + } + + String contentRange = response.headers().firstValue("content-range").orElse(null); + if (contentRange == null) { + return OptionalLong.empty(); + } + int slash = contentRange.lastIndexOf('/'); + if (slash < 0 || slash == contentRange.length() - 1 || "*".equals(contentRange.substring(slash + 1))) { + return OptionalLong.empty(); + } + return parseLongHeader(contentRange.substring(slash + 1)); + } + + private OptionalLong parseLongHeader(String value) { + if (value == null || value.isBlank()) { + return OptionalLong.empty(); + } + try { + return OptionalLong.of(Long.parseLong(value.trim())); + } catch (NumberFormatException ex) { + return OptionalLong.empty(); + } + } + + private byte[] readLimited(InputStream stream, long limit) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + long total = 0; + int read; + while (total < limit && (read = stream.read(buffer, 0, (int) Math.min(buffer.length, limit - total))) != -1) { + output.write(buffer, 0, read); + total += read; + } + return output.toByteArray(); + } + + private void validateArchiveSignature(byte[] bytes, String label) { + if (bytes == null || bytes.length < 2 || bytes[0] != 'P' || bytes[1] != 'K') { + throw new InvalidVersionRequestException(label + " must be a valid .jar or .zip archive."); + } + } + + private boolean isRedirect(int status) { + return status == 301 || status == 302 || status == 303 || status == 307 || status == 308; + } + + private boolean isDownloadableArchiveUrl(String value) { + String filename = fileNameFromUrl(value, null); + if (filename == null) { + return false; + } + String lower = filename.toLowerCase(Locale.ROOT); + return lower.endsWith(".jar") || lower.endsWith(".zip"); + } + + private String fileNameFromUrl(String value, String fallback) { + URI uri = parseUri(value); + if (uri == null || uri.getPath() == null) { + return fallback; + } + String path = uri.getPath(); + int slash = path.lastIndexOf('/'); + String filename = slash >= 0 ? path.substring(slash + 1) : path; + filename = trimToNull(filename); + return filename == null ? fallback : sanitizeArchiveFilename(filename); + } + + private String sanitizeArchiveFilename(String filename) { + String sanitized = filename.replaceAll("[^A-Za-z0-9._-]+", "-") + .replaceAll("-+", "-") + .replaceAll("(^-|-$)", ""); + if (sanitized.isBlank()) { + return "external-dependency.jar"; + } + String lower = sanitized.toLowerCase(Locale.ROOT); + return lower.endsWith(".jar") || lower.endsWith(".zip") ? sanitized : sanitized + ".jar"; + } + + private URI parseUri(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return new URI(value.trim()); + } catch (URISyntaxException ex) { + return null; + } + } + + private boolean isHost(String host, String expectedHost) { + if (host == null) { + return false; + } + String normalizedHost = host.toLowerCase(Locale.ROOT); + return normalizedHost.equals(expectedHost) || normalizedHost.endsWith("." + expectedHost); + } + + private String numericOrNull(String value) { + if (value == null || !value.matches("\\d+")) { + return null; + } + return value; + } + + private String sourceLabel(ProjectDependency.Source source) { + return switch (source) { + case CURSEFORGE -> "CurseForge"; + case GITHUB -> "GitHub"; + case WEBSITE -> "Website"; + case OTHER -> "External"; + case MODTALE -> "Modtale"; + }; + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private record CurseForgeDownloadPath(String modId, String fileId) {} + private record CurseForgeFileReference(String modId, String fileId, String filePageUrl, String fileName) {} + private record DownloadedFile(byte[] bytes, String contentType, URI finalUri) {} +} diff --git a/backend/src/main/java/net/modtale/service/project/version/ExternalProjectReferenceService.java b/backend/src/main/java/net/modtale/service/project/version/ExternalProjectReferenceService.java new file mode 100644 index 00000000..b64e4f01 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/project/version/ExternalProjectReferenceService.java @@ -0,0 +1,360 @@ +package net.modtale.service.project.version; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import net.modtale.exception.InvalidVersionRequestException; +import net.modtale.model.dto.project.ExternalProjectReferenceDTO; +import net.modtale.model.project.ProjectDependency; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +public class ExternalProjectReferenceService { + + private static final String CURSEFORGE_HOST = "curseforge.com"; + private static final String GITHUB_HOST = "github.com"; + private static final String GITHUB_RAW_HOST = "raw.githubusercontent.com"; + private static final String GITHUB_CONTENT_HOST = "githubusercontent.com"; + + private final CurseForgeApiClient curseForgeApiClient; + + public ExternalProjectReferenceService(CurseForgeApiClient curseForgeApiClient) { + this.curseForgeApiClient = curseForgeApiClient; + } + + public ExternalProjectReferenceDTO resolve(String rawUrl, ProjectDependency.Source requestedSource) { + String externalUrl = trimToNull(rawUrl); + if (externalUrl == null) { + throw new InvalidVersionRequestException("External URL is required."); + } + + ProjectDependency.Source source = requestedSource == null || requestedSource == ProjectDependency.Source.MODTALE + ? detectSource(externalUrl) + : requestedSource; + + return switch (source) { + case CURSEFORGE -> resolveCurseForge(externalUrl); + case GITHUB -> resolveSimple(externalUrl, ProjectDependency.Source.GITHUB, extractGitHubSlug(externalUrl), false); + case WEBSITE, OTHER -> resolveSimple(externalUrl, source, extractWebsiteSlug(externalUrl), false); + case MODTALE -> throw new InvalidVersionRequestException("Use Modtale project search for Modtale dependencies."); + }; + } + + private ExternalProjectReferenceDTO resolveCurseForge(String externalUrl) { + CurseForgePath path = parseCurseForgePath(externalUrl); + if (path == null) { + throw new InvalidVersionRequestException("CurseForge dependencies must link to a Hytale project or file page."); + } + + ExternalProjectReferenceDTO fallback = new ExternalProjectReferenceDTO( + ProjectDependency.Source.CURSEFORGE, + path.projectId() == null ? path.slug() : path.projectId(), + titleFromSlug(path.slug()), + path.fileId() == null ? "latest" : path.fileId(), + path.fileId() == null ? path.projectUrl() : path.fileUrl(), + null, + null, + true, + null, + path.fileId() == null ? List.of() : List.of(new ExternalProjectReferenceDTO.ExternalFileDTO( + path.fileId(), + path.fileId(), + null, + path.fileId(), + null, + null, + null, + null, + List.of(), + null, + null + )) + ); + return curseForgeApiClient.resolveProject(path.slug(), path.fileId()) + .map(project -> new ExternalProjectReferenceDTO( + ProjectDependency.Source.CURSEFORGE, + project.id(), + project.title() == null ? fallback.title() : project.title(), + selectedVersion(project.files(), path.fileId(), fallback.versionNumber()), + fallback.externalUrl(), + project.iconUrl(), + project.summary(), + true, + project.distributionAllowed(), + project.files().stream().map(file -> new ExternalProjectReferenceDTO.ExternalFileDTO( + file.id(), file.displayName(), file.fileName(), file.versionNumber(), file.releaseType(), null, + file.fileSize(), file.hashes(), file.gameVersions(), file.fileStatus(), file.available() + )).toList() + )) + .orElse(fallback); + } + + private String selectedVersion(List files, String fileId, String fallback) { + if (fileId == null) { + return files.isEmpty() ? fallback : files.getFirst().versionNumber(); + } + return files.stream() + .filter(file -> fileId.equals(file.id())) + .map(CurseForgeApiClient.CurseForgeFile::versionNumber) + .filter(value -> value != null && !value.isBlank()) + .findFirst() + .orElse(fallback); + } + + private ExternalProjectReferenceDTO resolveSimple( + String externalUrl, + ProjectDependency.Source source, + String externalId, + boolean hytaleProjectConfirmed + ) { + if (!isSecureUrl(externalUrl) || trimToNull(externalId) == null) { + throw new InvalidVersionRequestException(sourceLabel(source) + " dependencies must use a valid HTTPS URL."); + } + + return new ExternalProjectReferenceDTO( + source, + externalId, + titleFromSlug(externalId.substring(externalId.lastIndexOf('/') + 1)), + "latest", + externalUrl, + null, + null, + hytaleProjectConfirmed, + null, + externalFilesForUrl(externalUrl) + ); + } + + + private ProjectDependency.Source detectSource(String externalUrl) { + try { + URI uri = new URI(externalUrl); + String host = uri.getHost(); + if (host == null) { + return ProjectDependency.Source.WEBSITE; + } + if (isHost(host, CURSEFORGE_HOST)) return ProjectDependency.Source.CURSEFORGE; + if (isGitHubHost(host)) return ProjectDependency.Source.GITHUB; + return ProjectDependency.Source.WEBSITE; + } catch (URISyntaxException ex) { + return ProjectDependency.Source.WEBSITE; + } + } + + private CurseForgePath parseCurseForgePath(String value) { + try { + URI uri = new URI(value); + String host = uri.getHost(); + String[] segments = pathSegments(uri); + if (!isSafeCurseForgeUri(uri) || segments.length < 3) { + return null; + } + if (!"hytale".equalsIgnoreCase(segments[0]) || !"mods".equalsIgnoreCase(segments[1])) { + return null; + } + String slug = segments[2]; + if (!slug.matches("[A-Za-z0-9][A-Za-z0-9_-]*")) { + return null; + } + String fileId = null; + for (int i = 0; i < segments.length - 1; i++) { + if ("files".equalsIgnoreCase(segments[i]) && !segments[i + 1].isBlank()) { + fileId = segments[i + 1]; + break; + } + } + if (fileId != null && !fileId.matches("\\d+")) { + return null; + } + String projectId = trimToNull(uri.getQuery()) == null ? null : extractQueryParam(uri.getQuery(), "projectId"); + String projectUrl = UriComponentsBuilder.fromUri(uri) + .replacePath("/hytale/mods/" + slug) + .replaceQuery(null) + .fragment(null) + .build() + .toUriString(); + String fileUrl = fileId == null ? projectUrl : UriComponentsBuilder.fromUri(uri) + .replacePath("/hytale/mods/" + slug + "/files/" + fileId) + .replaceQuery(null) + .fragment(null) + .build() + .toUriString(); + return new CurseForgePath(slug, projectId, fileId, projectUrl, fileUrl); + } catch (URISyntaxException ex) { + return null; + } + } + + private String extractQueryParam(String query, String name) { + if (query == null) { + return null; + } + for (String part : query.split("&")) { + int equals = part.indexOf('='); + if (equals <= 0) continue; + if (name.equals(part.substring(0, equals))) { + return part.substring(equals + 1); + } + } + return null; + } + + private String extractGitHubSlug(String value) { + try { + URI uri = new URI(value); + String[] segments = pathSegments(uri); + if (segments.length < 2) { + return null; + } + return sanitizeExternalId(segments[0] + "/" + segments[1]); + } catch (URISyntaxException ex) { + return null; + } + } + + private String extractWebsiteSlug(String value) { + try { + URI uri = new URI(value); + String host = uri.getHost(); + if (host == null || host.isBlank()) { + return null; + } + String[] segments = pathSegments(uri); + String candidate = segments.length == 0 ? host : host + "/" + segments[segments.length - 1]; + return sanitizeExternalId(candidate); + } catch (URISyntaxException ex) { + return null; + } + } + + private boolean isSecureUrl(String value) { + try { + URI uri = new URI(value); + return "https".equalsIgnoreCase(uri.getScheme()) && uri.getHost() != null && !uri.getHost().isBlank(); + } catch (URISyntaxException ex) { + return false; + } + } + + private boolean isHost(String host, String expectedHost) { + String normalizedHost = host.toLowerCase(Locale.ROOT); + return normalizedHost.equals(expectedHost) || normalizedHost.endsWith("." + expectedHost); + } + + private boolean isSafeCurseForgeUri(URI uri) { + return "https".equalsIgnoreCase(uri.getScheme()) + && uri.getRawUserInfo() == null + && (uri.getPort() == -1 || uri.getPort() == 443) + && isHost(uri.getHost(), CURSEFORGE_HOST); + } + + private boolean isGitHubHost(String host) { + return isHost(host, GITHUB_HOST) || isHost(host, GITHUB_RAW_HOST) || isHost(host, GITHUB_CONTENT_HOST); + } + + private List externalFilesForUrl(String externalUrl) { + String fileName = fileNameFromUrl(externalUrl); + if (fileName == null) { + return List.of(); + } + + return List.of(new ExternalProjectReferenceDTO.ExternalFileDTO( + "direct", + fileName, + fileName, + "latest", + null, + externalUrl, + null, + null, + List.of(), + null, + null + )); + } + + private String fileNameFromUrl(String value) { + try { + URI uri = new URI(value); + String path = uri.getPath(); + if (path == null || path.isBlank()) { + return null; + } + String filename = path.substring(path.lastIndexOf('/') + 1); + String lower = filename.toLowerCase(Locale.ROOT); + if (!lower.endsWith(".jar") && !lower.endsWith(".zip")) { + return null; + } + return filename; + } catch (URISyntaxException ex) { + return null; + } + } + + private String[] pathSegments(URI uri) { + String path = uri.getPath(); + if (path == null || path.isBlank()) { + return new String[0]; + } + return java.util.Arrays.stream(path.split("/")) + .filter(segment -> !segment.isBlank()) + .toArray(String[]::new); + } + + private String sanitizeExternalId(String value) { + if (value == null) { + return null; + } + String sanitized = value.trim() + .replaceAll("[^A-Za-z0-9._/-]+", "-") + .replaceAll("-+", "-") + .replaceAll("(^-|-$)", ""); + return sanitized.isBlank() ? null : sanitized; + } + + private String titleFromSlug(String value) { + String raw = trimToNull(value); + if (raw == null) { + return "External Project"; + } + String last = raw.substring(raw.lastIndexOf('/') + 1); + String[] words = last.replace('-', ' ').replace('_', ' ').split("\\s+"); + List titled = new ArrayList<>(); + for (String word : words) { + if (word.isBlank()) continue; + titled.add(word.substring(0, 1).toUpperCase(Locale.ROOT) + word.substring(1)); + } + return titled.isEmpty() ? raw : String.join(" ", titled); + } + + private String stringValue(Object value) { + return stringValue(value, null); + } + + private String stringValue(Object value, String fallback) { + String string = value == null ? null : value.toString(); + return trimToNull(string) == null ? fallback : string.trim(); + } + + private String sourceLabel(ProjectDependency.Source source) { + return switch (source) { + case CURSEFORGE -> "CurseForge"; + case GITHUB -> "GitHub"; + case WEBSITE -> "Website"; + case OTHER -> "External"; + case MODTALE -> "Modtale"; + }; + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private record CurseForgePath(String slug, String projectId, String fileId, String projectUrl, String fileUrl) {} +} diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionApplicationService.java b/backend/src/main/java/net/modtale/service/project/version/VersionApplicationService.java index ace817dd..e27d678e 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionApplicationService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionApplicationService.java @@ -15,6 +15,7 @@ import net.modtale.model.dto.response.project.DownloadUrlResponse; import net.modtale.model.dto.response.project.VersionDependenciesView; import net.modtale.model.project.Project; +import net.modtale.model.project.ModpackTarget; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; @@ -93,6 +94,10 @@ public DownloadUrlResponse createDownloadUrl(String projectId, String versionNum return versionDownloadOrchestrationService.createDownloadUrl(projectId, versionNumber, gameVersion, currentUser); } + public DownloadUrlResponse createDownloadUrl(String projectId, String versionNumber, String gameVersion, ModpackTarget target, User currentUser) { + return versionDownloadOrchestrationService.createDownloadUrl(projectId, versionNumber, gameVersion, target, currentUser); + } + public BundleDownloadUrlResponse createBundleDownloadUrl( String projectId, String versionNumber, diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionArtifactService.java b/backend/src/main/java/net/modtale/service/project/version/VersionArtifactService.java index cda1f56d..a6e86f22 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionArtifactService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionArtifactService.java @@ -44,7 +44,7 @@ public PreparedVersionArtifact prepareVersionArtifact(Project project, Multipart boolean isModpack = effectiveClassification == ProjectClassification.MODPACK; storageService.validateUploadSize(file); - if (file != null && !isModpack) { + if (file != null && !file.isEmpty()) { fileValidationService.validateProjectFile(file, effectiveClassification.name()); } @@ -58,7 +58,8 @@ public PreparedVersionArtifact prepareVersionArtifact(Project project, Multipart throw new InvalidVersionRequestException("This file has already been uploaded to Modtale."); } } - filePath = storageService.upload(file, "files/" + effectiveClassification.name().toLowerCase()); + String folder = isModpack ? "modpack-overrides" : "files/" + effectiveClassification.name().toLowerCase(); + filePath = storageService.upload(file, folder); } return new PreparedVersionArtifact(effectiveClassification, filePath, fileHash); diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java b/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java index 94e428f6..d37221af 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java @@ -11,6 +11,7 @@ import net.modtale.config.properties.AppLimitProperties; import net.modtale.exception.InvalidVersionRequestException; import net.modtale.exception.VersionStateConflictException; +import net.modtale.model.dto.request.project.DependencyReferenceRequest; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectVersion; @@ -54,7 +55,7 @@ public void addVersion( List gameVersions, MultipartFile file, String changelog, - List projectIds, + List dependencies, List incompatibleProjectIds, ProjectVersion.Channel channel, boolean replaceExisting, @@ -80,9 +81,9 @@ public void addVersion( ProjectVersion version = buildVersion(project, versionNumber, gameVersions, changelog, channel, preparedArtifact, file, modpack); List simpleProjectIds = new ArrayList<>(); - if (projectIds != null) { + if (dependencies != null) { VersionDependencyService.ResolvedDependencies resolvedDependencies = - versionMutationOrchestrationService.resolveRequestedDependencies(projectIds, modpack, false); + versionMutationOrchestrationService.resolveRequestedDependencies(dependencies, modpack, false); version.setDependencies(new ArrayList<>(resolvedDependencies.dependencies())); simpleProjectIds.addAll(resolvedDependencies.simpleProjectIds()); } @@ -94,7 +95,7 @@ public void addVersion( } if (modpack) { - project.setModIds(simpleProjectIds); + project.setChildProjectIds(simpleProjectIds); } List replacedVersions = replaceExisting @@ -121,7 +122,8 @@ private ProjectVersion buildVersion( version.setId(UUID.randomUUID().toString()); version.setVersionNumber(versionNumber); version.setGameVersions(gameVersions); - version.setFileUrl(preparedArtifact.filePath()); + version.setFileUrl(modpack ? null : preparedArtifact.filePath()); + version.setOverrideFileUrl(modpack ? preparedArtifact.filePath() : null); version.setReleaseDate(LocalDateTime.now().toString()); version.setDownloadCount(0); version.setChangelog(versionMutationOrchestrationService.sanitizeChangelog(changelog)); diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionDependencyService.java b/backend/src/main/java/net/modtale/service/project/version/VersionDependencyService.java index 9eec90d5..6bd5533c 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionDependencyService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionDependencyService.java @@ -1,8 +1,14 @@ package net.modtale.service.project.version; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Locale; +import java.util.Set; import net.modtale.exception.InvalidVersionRequestException; +import net.modtale.model.dto.request.project.DependencyReferenceRequest; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectStatus; @@ -12,6 +18,11 @@ @Service public class VersionDependencyService { + private static final String CURSEFORGE_HOST = "curseforge.com"; + private static final String GITHUB_HOST = "github.com"; + private static final String GITHUB_RAW_HOST = "raw.githubusercontent.com"; + private static final String GITHUB_CONTENT_HOST = "githubusercontent.com"; + private final ProjectService projectService; public VersionDependencyService(ProjectService projectService) { @@ -19,45 +30,40 @@ public VersionDependencyService(ProjectService projectService) { } public ResolvedDependencies resolveRequestedDependencies( - List dependencyEntries, + List dependencyReferences, boolean isModpack, boolean allowDraftDependencies ) { - if (dependencyEntries == null) { + if (dependencyReferences == null) { return new ResolvedDependencies(List.of(), List.of()); } List dependencies = new ArrayList<>(); List simpleProjectIds = new ArrayList<>(); + Set dependencyKeys = new HashSet<>(); - for (String entry : dependencyEntries) { - String[] parts = entry.split(":"); - if (parts.length < 2) { - throw new InvalidVersionRequestException("Dependency entries must use the format projectId:version."); + for (DependencyReferenceRequest reference : dependencyReferences) { + if (reference == null) { + continue; } - String dependencyProjectId = parts[0].trim(); - String dependencyVersion = parts[1].trim(); - Project dependencyProject = projectService.getRawProjectById(dependencyProjectId); - - if (dependencyProject == null - || (!allowDraftDependencies && dependencyProject.getStatus() == ProjectStatus.DRAFT) - || dependencyProject.getVersions() == null - || dependencyProject.getVersions().stream().noneMatch(version -> - version.getVersionNumber() != null && version.getVersionNumber().equalsIgnoreCase(dependencyVersion))) { - throw new InvalidVersionRequestException("One or more selected dependencies could not be found."); + ProjectDependency.Source source = reference.getSource(); + if (isModpack && source == ProjectDependency.Source.CURSEFORGE) { + throw new InvalidVersionRequestException( + "CurseForge projects cannot be added to modpacks until Modtale Launcher support is available." + ); + } + ProjectDependency dependency = source == ProjectDependency.Source.MODTALE + ? resolveModtaleDependency(reference, allowDraftDependencies) + : resolveExternalDependency(reference, source); + String dependencyKey = dependency.getSource().name() + ":" + dependency.getProjectId().toLowerCase(Locale.ROOT); + if (!dependencyKeys.add(dependencyKey)) { + throw new InvalidVersionRequestException("Each dependency can only be included once."); + } + dependencies.add(dependency); + if (!dependency.isExternal()) { + simpleProjectIds.add(dependency.getProjectId()); } - - boolean optional = !isModpack && hasDependencyFlag(parts, "optional"); - boolean embedded = hasDependencyFlag(parts, "embedded"); - dependencies.add(new ProjectDependency( - dependencyProject.getId(), - dependencyProject.getTitle(), - dependencyVersion, - optional, - embedded - )); - simpleProjectIds.add(dependencyProject.getId()); } if (isModpack && dependencies.size() < 2) { @@ -92,13 +98,229 @@ public List resolveRequestedProjectIds( return resolvedProjectIds; } - private boolean hasDependencyFlag(String[] parts, String flag) { - for (int i = 2; i < parts.length; i++) { - if (flag.equalsIgnoreCase(parts[i].trim())) { - return true; + private ProjectDependency resolveModtaleDependency( + DependencyReferenceRequest reference, + boolean allowDraftDependencies + ) { + String projectId = trimToNull(reference.getProjectId()); + String versionNumber = trimToNull(reference.getVersionNumber()); + if (projectId == null || versionNumber == null) { + throw new InvalidVersionRequestException("Modtale dependencies require a project and version."); + } + + Project dependencyProject = projectService.getRawProjectById(projectId); + if (dependencyProject == null + || (!allowDraftDependencies && dependencyProject.getStatus() == ProjectStatus.DRAFT) + || dependencyProject.getVersions() == null + || dependencyProject.getVersions().stream().noneMatch(version -> + version.getVersionNumber() != null && version.getVersionNumber().equalsIgnoreCase(versionNumber))) { + throw new InvalidVersionRequestException("One or more selected dependencies could not be found."); + } + + ProjectDependency.DependencyType dependencyType = reference.getDependencyType(); + ProjectDependency dependency = ProjectDependency.modtale( + dependencyProject.getId(), + dependencyProject.getTitle(), + versionNumber, + dependencyType + ); + dependency.setId(reference.getId()); + dependency.setEnvironment(reference.getEnvironment()); + return dependency; + } + + private ProjectDependency resolveExternalDependency( + DependencyReferenceRequest reference, + ProjectDependency.Source source + ) { + String externalUrl = trimToNull(reference.getExternalUrl()); + String title = trimToNull(reference.getProjectTitle()); + String versionNumber = trimToNull(reference.getVersionNumber()); + if (externalUrl == null || title == null || versionNumber == null) { + throw new InvalidVersionRequestException("External dependencies require a title, version, and URL."); + } + + String externalId = trimToNull(reference.getExternalId()); + if (externalId == null) { + externalId = extractExternalId(source, externalUrl); + } + + if (externalId == null || !isValidExternalUrl(source, externalUrl)) { + throw new InvalidVersionRequestException(sourceLabel(source) + " dependencies must use a valid project URL."); + } + + boolean hytaleProjectConfirmed = isVerifiedHytaleUrl(source, externalUrl) || reference.isHytaleProjectConfirmed(); + if (!hytaleProjectConfirmed) { + throw new InvalidVersionRequestException("External dependencies must be confirmed as Hytale-compatible projects."); + } + + ProjectDependency.DependencyType dependencyType = reference.getDependencyType(); + ProjectDependency dependency = ProjectDependency.external(source, externalId, title, versionNumber, externalUrl, dependencyType); + dependency.setId(reference.getId()); + dependency.setEnvironment(reference.getEnvironment()); + dependency.setExternalFileUrl(trimToNull(reference.getExternalFileUrl())); + dependency.setExternalFileName(trimToNull(reference.getExternalFileName())); + dependency.setExternalFileSize(reference.getExternalFileSize()); + dependency.setExternalFileHashes(reference.getExternalFileHashes()); + dependency.setExternalGameVersions(reference.getExternalGameVersions()); + dependency.setExternalFileStatus(reference.getExternalFileStatus()); + dependency.setExternalDistributionAllowed(reference.getExternalDistributionAllowed()); + dependency.setHytaleProjectConfirmed(hytaleProjectConfirmed); + return dependency; + } + + private boolean isValidExternalUrl(ProjectDependency.Source source, String value) { + return switch (source) { + case CURSEFORGE -> isHytaleCurseForgeModUrl(value); + case GITHUB -> isGitHubProjectUrl(value); + case WEBSITE, OTHER -> isSecureUrl(value); + case MODTALE -> false; + }; + } + + private boolean isVerifiedHytaleUrl(ProjectDependency.Source source, String value) { + return source == ProjectDependency.Source.CURSEFORGE && isHytaleCurseForgeModUrl(value); + } + + private boolean isHytaleCurseForgeModUrl(String value) { + try { + URI uri = new URI(value); + String host = uri.getHost(); + String path = uri.getPath(); + return "https".equalsIgnoreCase(uri.getScheme()) + && uri.getRawUserInfo() == null + && (uri.getPort() == -1 || uri.getPort() == 443) + && host != null + && (host.equalsIgnoreCase(CURSEFORGE_HOST) || host.toLowerCase().endsWith("." + CURSEFORGE_HOST)) + && path != null + && path.toLowerCase().startsWith("/hytale/") + && path.toLowerCase().contains("/mods/") + && extractCurseForgeSlug(value) != null; + } catch (URISyntaxException ex) { + return false; + } + } + + private boolean isGitHubProjectUrl(String value) { + try { + URI uri = new URI(value); + String host = uri.getHost(); + String[] segments = pathSegments(uri); + return host != null && isGitHubHost(host) && segments.length >= 2; + } catch (URISyntaxException ex) { + return false; + } + } + + private boolean isSecureUrl(String value) { + try { + URI uri = new URI(value); + return "https".equalsIgnoreCase(uri.getScheme()) && uri.getHost() != null && !uri.getHost().isBlank(); + } catch (URISyntaxException ex) { + return false; + } + } + + private boolean isHost(String host, String expectedHost) { + String normalizedHost = host.toLowerCase(Locale.ROOT); + return normalizedHost.equals(expectedHost) || normalizedHost.endsWith("." + expectedHost); + } + + private boolean isGitHubHost(String host) { + return isHost(host, GITHUB_HOST) || isHost(host, GITHUB_RAW_HOST) || isHost(host, GITHUB_CONTENT_HOST); + } + + private String extractExternalId(ProjectDependency.Source source, String value) { + return switch (source) { + case CURSEFORGE -> extractCurseForgeSlug(value); + case GITHUB -> extractGitHubSlug(value); + case WEBSITE, OTHER -> extractWebsiteSlug(value); + case MODTALE -> null; + }; + } + + private String extractCurseForgeSlug(String value) { + try { + URI uri = new URI(value); + String path = uri.getPath(); + if (path == null) { + return null; + } + String[] segments = path.split("/"); + for (int i = 0; i < segments.length - 1; i++) { + if ("mods".equalsIgnoreCase(segments[i]) && !segments[i + 1].isBlank()) { + return segments[i + 1].trim(); + } + } + return null; + } catch (URISyntaxException ex) { + return null; + } + } + + private String extractGitHubSlug(String value) { + try { + String[] segments = pathSegments(new URI(value)); + if (segments.length < 2) { + return null; + } + return sanitizeExternalId(segments[0] + "/" + segments[1]); + } catch (URISyntaxException ex) { + return null; + } + } + + private String extractWebsiteSlug(String value) { + try { + URI uri = new URI(value); + String host = uri.getHost(); + if (host == null || host.isBlank()) { + return null; } + String[] segments = pathSegments(uri); + String candidate = segments.length == 0 ? host : host + "/" + segments[segments.length - 1]; + return sanitizeExternalId(candidate); + } catch (URISyntaxException ex) { + return null; + } + } + + private String[] pathSegments(URI uri) { + String path = uri.getPath(); + if (path == null || path.isBlank()) { + return new String[0]; + } + return java.util.Arrays.stream(path.split("/")) + .filter(segment -> !segment.isBlank()) + .toArray(String[]::new); + } + + private String sanitizeExternalId(String value) { + if (value == null) { + return null; + } + String sanitized = value.trim() + .replaceAll("[^A-Za-z0-9._/-]+", "-") + .replaceAll("-+", "-") + .replaceAll("(^-|-$)", ""); + return sanitized.isBlank() ? null : sanitized; + } + + private String sourceLabel(ProjectDependency.Source source) { + return switch (source) { + case CURSEFORGE -> "CurseForge"; + case GITHUB -> "GitHub"; + case WEBSITE -> "Website"; + case OTHER -> "External"; + case MODTALE -> "Modtale"; + }; + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; } - return false; + return value.trim(); } public record ResolvedDependencies(List dependencies, List simpleProjectIds) { diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java b/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java index 0138e0fa..ef4dd8a4 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java @@ -4,6 +4,7 @@ import java.util.List; import net.modtale.config.properties.AppFrontendProperties; import net.modtale.exception.InvalidDownloadTokenException; +import net.modtale.exception.InvalidVersionRequestException; import net.modtale.exception.ResourceNotFoundException; import net.modtale.exception.VersionNotFoundException; import net.modtale.model.dto.response.project.BundleDownloadUrlResponse; @@ -12,6 +13,7 @@ import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; +import net.modtale.model.project.ModpackTarget; import net.modtale.model.user.User; import net.modtale.service.analytics.AnalyticsEligibilityService; import net.modtale.service.analytics.TrackingService; @@ -59,11 +61,19 @@ public VersionDownloadOrchestrationService( } public DownloadUrlResponse createDownloadUrl(String projectId, String versionNumber, String gameVersion, User currentUser) { + return createDownloadUrl(projectId, versionNumber, gameVersion, ModpackTarget.UNIVERSAL, currentUser); + } + + public DownloadUrlResponse createDownloadUrl(String projectId, String versionNumber, String gameVersion, ModpackTarget target, User currentUser) { Project project = getProjectOrThrow(projectId, currentUser, "We couldn't find that project, so no download link could be generated."); - getVersionOrThrow(project, versionNumber, gameVersion, + ProjectVersion version = getVersionOrThrow(project, versionNumber, gameVersion, "We couldn't find the requested version for that project."); - String token = downloadTokenService.generateToken(projectId, versionNumber, gameVersion); + ensureBrowserDownloadable(project, version); + ModpackTarget effectiveTarget = target == null ? ModpackTarget.UNIVERSAL : target; + String token = effectiveTarget == ModpackTarget.UNIVERSAL + ? downloadTokenService.generateToken(projectId, versionNumber, gameVersion) + : downloadTokenService.generateToken(projectId, versionNumber, gameVersion, null, effectiveTarget); return new DownloadUrlResponse("/download/" + token, downloadTokenService.getTokenValiditySeconds()); } @@ -98,15 +108,23 @@ public VersionDownloadPayload downloadVersion( ensureReadable(project, currentUser); ProjectVersion targetVersion = getVersionOrThrow(project, downloadToken.getVersion(), downloadToken.getGameVersion(), "We couldn't find the version requested by this download link."); + ensureBrowserDownloadable(project, targetVersion); trackDownload(project, targetVersion.getId(), context); if (project.getClassification() == ProjectClassification.MODPACK) { + ModpackTarget target = downloadToken.getModpackTarget() == null + ? ModpackTarget.UNIVERSAL + : downloadToken.getModpackTarget(); if (targetVersion.getDependencies() != null) { - targetVersion.getDependencies().forEach(dep -> trackDependencyDownload(dep, context)); + targetVersion.getDependencies().stream() + .filter(dependency -> includedInTarget(dependency, target)) + .forEach(dep -> trackDependencyDownload(dep, context)); } - byte[] zipData = downloadService.generateModpackZip(project, targetVersion, context.currentUser()); - return new VersionDownloadPayload(buildModpackFilename(project, targetVersion), zipData); + byte[] zipData = target == ModpackTarget.UNIVERSAL + ? downloadService.generateModpackZip(project, targetVersion, context.currentUser()) + : downloadService.generateModpackZip(project, targetVersion, context.currentUser(), target); + return new VersionDownloadPayload(buildModpackFilename(project, targetVersion, target), zipData); } byte[] data = storageService.download(targetVersion.getFileUrl()); @@ -135,10 +153,13 @@ public VersionDownloadPayload downloadBundle( List selectedDependencies = downloadToken.getSelectedDependencies(); if (targetVersion.getDependencies() != null) { targetVersion.getDependencies().forEach(dep -> { + if (dep.isExternal()) { + return; + } if (dep.isEmbedded()) { return; } - if (selectedDependencies == null || selectedDependencies.contains(dep.getModId())) { + if (selectedDependencies == null || selectedDependencies.contains(dep.getProjectId())) { trackDependencyDownload(dep, context); } }); @@ -195,6 +216,21 @@ private void ensureReadable(Project project, User currentUser) { } } + private void ensureBrowserDownloadable(Project project, ProjectVersion version) { + if (project.getClassification() != ProjectClassification.MODPACK + || version.getDependencies() == null) { + return; + } + boolean containsCurseForge = version.getDependencies().stream() + .anyMatch(dependency -> dependency != null + && dependency.getSource() == ProjectDependency.Source.CURSEFORGE); + if (containsCurseForge) { + throw new InvalidVersionRequestException( + "This modpack contains CurseForge projects and can only be installed with Modtale Launcher." + ); + } + } + private void trackDownload(Project project, String versionId, DownloadContext context) { if (analyticsEligibilityService.shouldCountProjectEngagement(project, context.currentUser())) { trackingService.logDownload(project.getId(), versionId, project.getAuthor(), context.apiRequest(), context.clientIp()); @@ -202,10 +238,14 @@ private void trackDownload(Project project, String versionId, DownloadContext co } private void trackDependencyDownload(ProjectDependency dependency, DownloadContext context) { - Project dependencyProject = projectService.getRawProjectById(dependency.getModId()); + if (dependency.isExternal()) { + return; + } + + Project dependencyProject = projectService.getRawProjectById(dependency.getProjectId()); if (dependencyProject == null || analyticsEligibilityService.shouldCountProjectEngagement(dependencyProject, context.currentUser())) { trackingService.logDownload( - dependency.getModId(), + dependency.getProjectId(), null, dependencyProject != null ? dependencyProject.getAuthor() : null, context.apiRequest(), @@ -214,8 +254,13 @@ private void trackDependencyDownload(ProjectDependency dependency, DownloadConte } } - private String buildModpackFilename(Project project, ProjectVersion version) { - return sanitizeProjectName(project.getTitle()) + "-" + version.getVersionNumber() + ".zip"; + private boolean includedInTarget(ProjectDependency dependency, ModpackTarget target) { + return target.includes(dependency.getEnvironment()); + } + + private String buildModpackFilename(Project project, ProjectVersion version, ModpackTarget target) { + String suffix = target == ModpackTarget.UNIVERSAL ? "" : "-" + target.name().toLowerCase(); + return sanitizeProjectName(project.getTitle()) + "-" + version.getVersionNumber() + suffix + ".zip"; } private String sanitizeProjectName(String title) { diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionMutationApplicationService.java b/backend/src/main/java/net/modtale/service/project/version/VersionMutationApplicationService.java index d117c1ce..06c9bc8b 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionMutationApplicationService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionMutationApplicationService.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.stream.Collectors; import net.modtale.model.dto.project.ManifestInspectionResult; +import net.modtale.model.dto.request.project.DependencyReferenceRequest; import net.modtale.model.dto.request.project.CreateVersionRequest; import net.modtale.model.dto.request.project.UpdateVersionRequest; import net.modtale.model.user.User; @@ -26,7 +27,7 @@ public void addVersion(String projectId, CreateVersionRequest requestPayload, Us requestPayload.getGameVersions(), requestPayload.getFile(), requestPayload.getChangelog(), - normalizeDependencyEntries(requestPayload.getModIds()), + normalizeDependencies(requestPayload.getDependencies()), normalizeProjectIds(requestPayload.getIncompatibleProjectIds()), requestPayload.getChannel(), requestPayload.isReplaceExisting(), @@ -42,7 +43,7 @@ public void updateVersion(String projectId, String versionId, UpdateVersionReque versionService.updateVersion( projectId, versionId, - normalizeDependencyEntries(requestPayload.getModIds()), + normalizeDependencies(requestPayload.getDependencies()), normalizeProjectIds(requestPayload.getIncompatibleProjectIds()), requestPayload.getGameVersions(), requestPayload.getChangelog(), @@ -55,14 +56,12 @@ public void deleteVersion(String projectId, String versionId, User currentUser) versionService.deleteVersion(projectId, versionId, currentUser); } - private List normalizeDependencyEntries(List rawEntries) { - if (rawEntries == null || rawEntries.isEmpty()) { - return rawEntries; + private List normalizeDependencies(List dependencies) { + if (dependencies == null || dependencies.isEmpty()) { + return dependencies; } - return rawEntries.stream() - .flatMap(entry -> Arrays.stream(entry.split(","))) - .map(String::trim) - .filter(entry -> !entry.isEmpty()) + return dependencies.stream() + .filter(dependency -> dependency != null) .collect(Collectors.toList()); } diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java b/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java index d1a996af..907a1552 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java @@ -1,6 +1,7 @@ package net.modtale.service.project.version; import java.util.List; +import net.modtale.model.dto.request.project.DependencyReferenceRequest; import net.modtale.exception.InvalidVersionRequestException; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectClassification; @@ -12,6 +13,7 @@ import net.modtale.service.project.validation.ValidationService; import net.modtale.service.security.scan.ScanService; import net.modtale.service.security.validation.SanitizationService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -23,14 +25,17 @@ public class VersionMutationOrchestrationService { private final SanitizationService sanitizationService; private final VersionArtifactService versionArtifactService; private final VersionDependencyService versionDependencyService; + private final ExternalDependencyArtifactService externalDependencyArtifactService; private final ProjectDeletionService projectDeletionService; + @Autowired public VersionMutationOrchestrationService( ValidationService validationService, ScanService scanService, SanitizationService sanitizationService, VersionArtifactService versionArtifactService, VersionDependencyService versionDependencyService, + ExternalDependencyArtifactService externalDependencyArtifactService, ProjectDeletionService projectDeletionService ) { this.validationService = validationService; @@ -38,9 +43,29 @@ public VersionMutationOrchestrationService( this.sanitizationService = sanitizationService; this.versionArtifactService = versionArtifactService; this.versionDependencyService = versionDependencyService; + this.externalDependencyArtifactService = externalDependencyArtifactService; this.projectDeletionService = projectDeletionService; } + public VersionMutationOrchestrationService( + ValidationService validationService, + ScanService scanService, + SanitizationService sanitizationService, + VersionArtifactService versionArtifactService, + VersionDependencyService versionDependencyService, + ProjectDeletionService projectDeletionService + ) { + this( + validationService, + scanService, + sanitizationService, + versionArtifactService, + versionDependencyService, + null, + projectDeletionService + ); + } + public void validateVersionNumber(String versionNumber) { validationService.validateVersionNumber(versionNumber); } @@ -67,11 +92,16 @@ public VersionArtifactService.PreparedVersionArtifact prepareVersionArtifact(Pro } public VersionDependencyService.ResolvedDependencies resolveRequestedDependencies( - List projectIds, + List dependencies, boolean modpack, boolean allowVersionlessDependencies ) { - return versionDependencyService.resolveRequestedDependencies(projectIds, modpack, allowVersionlessDependencies); + VersionDependencyService.ResolvedDependencies resolvedDependencies = + versionDependencyService.resolveRequestedDependencies(dependencies, modpack, allowVersionlessDependencies); + if (externalDependencyArtifactService != null) { + externalDependencyArtifactService.prepareExternalArtifacts(resolvedDependencies.dependencies()); + } + return resolvedDependencies; } public List resolveRequestedProjectIds(List projectIds, boolean allowDraftProjects) { diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionService.java b/backend/src/main/java/net/modtale/service/project/version/VersionService.java index 6d39af4c..6c22b6fc 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionService.java @@ -5,6 +5,7 @@ import net.modtale.exception.InvalidVersionRequestException; import net.modtale.exception.VersionNotFoundException; import net.modtale.model.dto.project.ManifestInspectionResult; +import net.modtale.model.dto.request.project.DependencyReferenceRequest; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectStatus; import net.modtale.model.project.ProjectVersion; @@ -83,14 +84,14 @@ public Optional getVersionByHash(String hash) { public void updateVersion( String id, String versionId, - List projectIds, + List dependencies, List incompatibleProjectIds, List gameVersions, String changelog, ProjectVersion.Channel channel, User user ) { - versionUpdateCommandHandler.updateVersion(id, versionId, projectIds, incompatibleProjectIds, gameVersions, changelog, channel, user); + versionUpdateCommandHandler.updateVersion(id, versionId, dependencies, incompatibleProjectIds, gameVersions, changelog, channel, user); } public void addVersion( @@ -99,13 +100,13 @@ public void addVersion( List gameVersions, MultipartFile file, String changelog, - List projectIds, + List dependencies, List incompatibleProjectIds, ProjectVersion.Channel channel, boolean replaceExisting, User user ) { - versionCreationCommandHandler.addVersion(id, versionNumber, gameVersions, file, changelog, projectIds, incompatibleProjectIds, channel, replaceExisting, user); + versionCreationCommandHandler.addVersion(id, versionNumber, gameVersions, file, changelog, dependencies, incompatibleProjectIds, channel, replaceExisting, user); } public ManifestInspectionResult inspectManifest(String id, MultipartFile file, User user) { diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java b/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java index 97ca7e86..fe98b8e5 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java @@ -2,6 +2,7 @@ import java.util.List; import net.modtale.exception.VersionNotFoundException; +import net.modtale.model.dto.request.project.DependencyReferenceRequest; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectDependency; @@ -43,7 +44,7 @@ public VersionUpdateCommandHandler( public void updateVersion( String projectId, String versionId, - List projectIds, + List dependencies, List incompatibleProjectIds, List gameVersions, String changelog, @@ -69,16 +70,16 @@ public void updateVersion( } boolean modpack = project.getClassification() == ProjectClassification.MODPACK; - if (projectIds != null) { + if (dependencies != null) { VersionDependencyService.ResolvedDependencies resolvedDependencies = - versionMutationOrchestrationService.resolveRequestedDependencies(projectIds, modpack, true); - List dependencies = resolvedDependencies.dependencies(); + versionMutationOrchestrationService.resolveRequestedDependencies(dependencies, modpack, true); + List resolvedProjectDependencies = resolvedDependencies.dependencies(); if (modpack) { - versionMutationOrchestrationService.invalidateCachedModpackArtifact(version, dependencies); + versionMutationOrchestrationService.invalidateCachedModpackArtifact(version, resolvedProjectDependencies); } - version.setDependencies(dependencies); + version.setDependencies(resolvedProjectDependencies); if (modpack && project.getVersions().get(0).getId().equals(versionId)) { - project.setModIds(resolvedDependencies.simpleProjectIds()); + project.setChildProjectIds(resolvedDependencies.simpleProjectIds()); } } if (incompatibleProjectIds != null) { diff --git a/backend/src/main/java/net/modtale/service/security/validation/ProjectArchiveValidationService.java b/backend/src/main/java/net/modtale/service/security/validation/ProjectArchiveValidationService.java index 499e1e51..e4c8bd8c 100644 --- a/backend/src/main/java/net/modtale/service/security/validation/ProjectArchiveValidationService.java +++ b/backend/src/main/java/net/modtale/service/security/validation/ProjectArchiveValidationService.java @@ -9,6 +9,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import net.modtale.exception.InvalidProjectRequestException; +import net.modtale.service.storage.ModpackOverrideArchive; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -49,6 +50,15 @@ public FileValidationService.ManifestInspection validateProjectArchive(Multipart validateMagicNumber(file, ZIP_HEADER); + if ("MODPACK".equals(classification)) { + try { + ModpackOverrideArchive.read(file.getInputStream()); + return null; + } catch (IOException ex) { + throw new InvalidProjectRequestException(ex.getMessage()); + } + } + try { return validateZipContents(file, classification); } catch (IOException e) { diff --git a/backend/src/main/java/net/modtale/service/storage/BundlePackagingService.java b/backend/src/main/java/net/modtale/service/storage/BundlePackagingService.java index d55cb1a6..4de4c1ed 100644 --- a/backend/src/main/java/net/modtale/service/storage/BundlePackagingService.java +++ b/backend/src/main/java/net/modtale/service/storage/BundlePackagingService.java @@ -48,10 +48,13 @@ private void writeSelectedDependencies( } for (ProjectDependency dependency : mainVersion.getDependencies()) { + if (dependency.isExternal()) { + continue; + } if (dependency.isEmbedded()) { continue; } - if (selectedDependencies != null && !selectedDependencies.contains(dependency.getModId())) { + if (selectedDependencies != null && !selectedDependencies.contains(dependency.getProjectId())) { continue; } diff --git a/backend/src/main/java/net/modtale/service/storage/DownloadArchiveSupport.java b/backend/src/main/java/net/modtale/service/storage/DownloadArchiveSupport.java index 3bbdc407..1275f700 100644 --- a/backend/src/main/java/net/modtale/service/storage/DownloadArchiveSupport.java +++ b/backend/src/main/java/net/modtale/service/storage/DownloadArchiveSupport.java @@ -19,7 +19,11 @@ final class DownloadArchiveSupport { } ResolvedDependency resolveDependency(ProjectDependency dependency) { - Project project = projectService.getRawProjectById(dependency.getModId()); + if (dependency == null || dependency.isExternal()) { + return null; + } + + Project project = projectService.getRawProjectById(dependency.getProjectId()); if (project == null) { return null; } diff --git a/backend/src/main/java/net/modtale/service/storage/DownloadService.java b/backend/src/main/java/net/modtale/service/storage/DownloadService.java index 32318516..46a2c11d 100644 --- a/backend/src/main/java/net/modtale/service/storage/DownloadService.java +++ b/backend/src/main/java/net/modtale/service/storage/DownloadService.java @@ -4,6 +4,7 @@ import java.util.List; import net.modtale.config.properties.AppLimitProperties; import net.modtale.model.project.Project; +import net.modtale.model.project.ModpackTarget; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; import net.modtale.repository.project.ProjectRepository; @@ -30,8 +31,12 @@ public DownloadService( } public byte[] generateModpackZip(Project pack, ProjectVersion version, User user) throws IOException { + return generateModpackZip(pack, version, user, ModpackTarget.UNIVERSAL); + } + + public byte[] generateModpackZip(Project pack, ProjectVersion version, User user, ModpackTarget target) throws IOException { rateLimitService.consumeModpackGeneration(user); - return modpackArchiveService.generateModpackZip(pack, version); + return modpackArchiveService.generateModpackZip(pack, version, target); } public byte[] generateBundleZip(Project mainProject, ProjectVersion mainVersion, List selectedDependencies, User user) throws IOException { diff --git a/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java b/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java index 62bbaccd..492a132b 100644 --- a/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java +++ b/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import net.modtale.model.project.ModpackTarget; import org.springframework.stereotype.Service; @Service @@ -23,13 +24,19 @@ public static class DownloadToken { private final String gameVersion; private final Instant expiresAt; private final List selectedDependencies; + private final ModpackTarget modpackTarget; private boolean used; public DownloadToken(String projectId, String version, String gameVersion, List selectedDependencies, Instant expiresAt) { + this(projectId, version, gameVersion, selectedDependencies, null, expiresAt); + } + + public DownloadToken(String projectId, String version, String gameVersion, List selectedDependencies, ModpackTarget modpackTarget, Instant expiresAt) { this.projectId = projectId; this.version = version; this.gameVersion = gameVersion; this.selectedDependencies = selectedDependencies; + this.modpackTarget = modpackTarget; this.expiresAt = expiresAt; this.used = false; } @@ -38,6 +45,7 @@ public DownloadToken(String projectId, String version, String gameVersion, List< public String getVersion() { return version; } public String getGameVersion() { return gameVersion; } public List getSelectedDependencies() { return selectedDependencies; } + public ModpackTarget getModpackTarget() { return modpackTarget; } public Instant getExpiresAt() { return expiresAt; } public boolean isUsed() { return used; } public void markAsUsed() { this.used = true; } @@ -48,6 +56,10 @@ public boolean isExpired() { } public String generateToken(String projectId, String version, String gameVersion, List selectedDependencies) { + return generateToken(projectId, version, gameVersion, selectedDependencies, null); + } + + public String generateToken(String projectId, String version, String gameVersion, List selectedDependencies, ModpackTarget modpackTarget) { cleanExpiredTokens(); byte[] randomBytes = new byte[TOKEN_LENGTH]; @@ -55,7 +67,7 @@ public String generateToken(String projectId, String version, String gameVersion String token = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); Instant expiresAt = Instant.now().plusSeconds(TOKEN_VALIDITY_MINUTES * 60); - tokens.put(token, new DownloadToken(projectId, version, gameVersion, selectedDependencies, expiresAt)); + tokens.put(token, new DownloadToken(projectId, version, gameVersion, selectedDependencies, modpackTarget, expiresAt)); return token; } diff --git a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java index 971276cf..2790eabb 100644 --- a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java +++ b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java @@ -1,13 +1,29 @@ package net.modtale.service.storage; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.net.URI; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import net.modtale.exception.StorageDownloadException; import net.modtale.exception.StorageUploadException; import net.modtale.model.project.Project; +import net.modtale.model.project.ModpackTarget; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; import net.modtale.repository.project.ProjectRepository; @@ -17,6 +33,12 @@ final class ModpackArchiveService { private static final Logger logger = LoggerFactory.getLogger(ModpackArchiveService.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT); + private static final long DETERMINISTIC_ZIP_TIME = 0L; + private static final String LEGACY_MANIFEST = "modpack.json"; + private static final String MANIFEST = "manifest.json"; + private static final String LOCKFILE = "modtale.lock.json"; private final ProjectRepository projectRepository; private final DownloadArchiveSupport archiveSupport; @@ -27,13 +49,20 @@ final class ModpackArchiveService { } byte[] generateModpackZip(Project pack, ProjectVersion version) throws IOException { - byte[] cachedArchive = downloadCachedArchive(pack, version); + return generateModpackZip(pack, version, ModpackTarget.UNIVERSAL); + } + + byte[] generateModpackZip(Project pack, ProjectVersion version, ModpackTarget target) throws IOException { + ModpackTarget effectiveTarget = target == null ? ModpackTarget.UNIVERSAL : target; + byte[] cachedArchive = effectiveTarget == ModpackTarget.UNIVERSAL ? downloadCachedArchive(pack, version) : null; if (cachedArchive != null) { return cachedArchive; } - byte[] zipBytes = buildArchive(pack, version); - cacheArchive(pack, version, zipBytes); + byte[] zipBytes = buildArchive(pack, version, effectiveTarget); + if (effectiveTarget == ModpackTarget.UNIVERSAL) { + cacheArchive(pack, version, zipBytes); + } return zipBytes; } @@ -43,7 +72,22 @@ private byte[] downloadCachedArchive(Project pack, ProjectVersion version) { } try { - return archiveSupport.download(version.getFileUrl()); + byte[] cachedArchive = archiveSupport.download(version.getFileUrl()); + if (cachedArchive != null && cachedArchive.length > 0) { + try { + ModpackArchiveValidator.validate(cachedArchive); + return cachedArchive; + } catch (IOException ex) { + logger.warn("Cached modpack archive failed format or integrity validation for project={} version={}. Rebuilding archive.", + pack.getId(), version.getVersionNumber(), ex); + version.setFileUrl(null); + return null; + } + } + logger.warn("Cached modpack archive was empty for project={} version={}. Rebuilding archive.", + pack.getId(), version.getVersionNumber()); + version.setFileUrl(null); + return null; } catch (StorageDownloadException ex) { logger.warn("Cached modpack archive could not be downloaded for project={} version={}. Rebuilding archive.", pack.getId(), version.getVersionNumber(), ex); @@ -52,58 +96,345 @@ private byte[] downloadCachedArchive(Project pack, ProjectVersion version) { } } - private byte[] buildArchive(Project pack, ProjectVersion version) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - writeManifest(zos, pack, version); - writeDependencyFiles(zos, version); + private byte[] buildArchive(Project pack, ProjectVersion version, ModpackTarget target) throws IOException { + List preparedDependencies = prepareDependencies(version, target); + List overrides = prepareOverrides(version, target); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + writeJsonEntry(zip, LEGACY_MANIFEST, legacyManifest(pack, version, target)); + writeJsonEntry(zip, MANIFEST, authorManifest(pack, version, target)); + writeJsonEntry(zip, LOCKFILE, lockfile(pack, version, preparedDependencies, overrides, target)); + for (PreparedDependency prepared : preparedDependencies) { + if (prepared.bytes() != null) { + writeBinaryEntry(zip, prepared.path(), prepared.bytes()); + } + } + for (ModpackOverrideArchive.OverrideFile override : overrides) { + writeBinaryEntry(zip, override.path(), override.bytes()); + } } - return baos.toByteArray(); + byte[] archive = output.toByteArray(); + ModpackArchiveValidator.validate(archive); + return archive; } - private void writeManifest(ZipOutputStream zos, Project pack, ProjectVersion version) throws IOException { - ZipEntry readme = new ZipEntry("modpack.json"); - zos.putNextEntry(readme); + private List prepareOverrides(ProjectVersion version, ModpackTarget target) throws IOException { + String overrideFileUrl = trimToNull(version.getOverrideFileUrl()); + if (overrideFileUrl == null) return List.of(); + byte[] archive; + try { + archive = archiveSupport.download(overrideFileUrl); + } catch (StorageDownloadException ex) { + throw new IOException("Cannot download the modpack override bundle.", ex); + } + return ModpackOverrideArchive.read(new ByteArrayInputStream(archive)).stream() + .filter(file -> target.includes(file.environment())) + .toList(); + } - StringBuilder json = new StringBuilder("{\n \"name\": \"" + pack.getTitle() + "\",\n \"files\": [\n"); - if (version.getDependencies() != null) { - for (int i = 0; i < version.getDependencies().size(); i++) { - ProjectDependency dep = version.getDependencies().get(i); - json.append(" { \"id\": \"").append(dep.getModId()) - .append("\", \"version\": \"").append(dep.getVersionNumber()).append("\" }"); - if (i < version.getDependencies().size() - 1) { - json.append(","); - } - json.append("\n"); + private List prepareDependencies(ProjectVersion version, ModpackTarget target) throws IOException { + if (version.getDependencies() == null) { + return List.of(); + } + + Set archiveKeys = new HashSet<>(); + archiveKeys.add(LEGACY_MANIFEST.toLowerCase(Locale.ROOT)); + archiveKeys.add(MANIFEST.toLowerCase(Locale.ROOT)); + archiveKeys.add(LOCKFILE.toLowerCase(Locale.ROOT)); + List prepared = new ArrayList<>(); + for (ProjectDependency dependency : version.getDependencies()) { + if (!includedInTarget(dependency, target)) { + continue; + } + prepared.add(dependency.isExternal() + ? prepareExternalDependency(dependency, archiveKeys) + : prepareModtaleDependency(dependency, archiveKeys)); + } + return prepared; + } + + private PreparedDependency prepareModtaleDependency( + ProjectDependency dependency, + Set archiveKeys + ) throws IOException { + DownloadArchiveSupport.ResolvedDependency resolved = archiveSupport.resolveDependency(dependency); + if (resolved == null || trimToNull(resolved.version().getFileUrl()) == null) { + throw new IOException("Cannot resolve bundled Modtale dependency " + + dependencyLabel(dependency) + " at version " + dependency.getVersionNumber() + "."); + } + + byte[] bytes; + try { + bytes = archiveSupport.download(resolved.version().getFileUrl()); + } catch (StorageDownloadException ex) { + throw new IOException("Cannot download bundled Modtale dependency " + dependencyLabel(dependency) + ".", ex); + } + if (bytes == null || bytes.length == 0) { + throw new IOException("Bundled Modtale dependency " + dependencyLabel(dependency) + " is empty."); + } + + String filename = archiveSupport.extractOriginalFilename(resolved.version().getFileUrl()); + String path = uniqueArchiveEntryName(archiveKeys, sanitizeArchiveFilename(filename)); + return PreparedDependency.bundled(dependency, path, bytes); + } + + private PreparedDependency prepareExternalDependency( + ProjectDependency dependency, + Set archiveKeys + ) { + if (dependency.getSource() == ProjectDependency.Source.CURSEFORGE) { + return PreparedDependency.reference(dependency); + } + + String cachedFileUrl = trimToNull(dependency.getCachedFileUrl()); + if (cachedFileUrl == null) { + return PreparedDependency.reference(dependency); + } + try { + byte[] bytes = archiveSupport.download(cachedFileUrl); + if (bytes == null || bytes.length == 0) { + return PreparedDependency.reference(dependency); } + String path = uniqueArchiveEntryName(archiveKeys, externalFilename(dependency)); + return PreparedDependency.bundled(dependency, path, bytes); + } catch (StorageDownloadException ex) { + logger.warn("Unable to include cached external dependency {} from {} in generated modpack archive.", + dependency.getProjectTitle(), cachedFileUrl, ex); + return PreparedDependency.reference(dependency); } - json.append(" ]\n}"); + } + + private Map legacyManifest(Project pack, ProjectVersion version, ModpackTarget target) { + Map manifest = packIdentity(pack, version); + manifest.put("name", pack.getTitle()); + manifest.put("formatVersion", 1); + manifest.put("game", "hytale"); + manifest.put("target", target.name()); + manifest.put("files", authorDependencies(version, target)); + return manifest; + } - zos.write(json.toString().getBytes(StandardCharsets.UTF_8)); - zos.closeEntry(); + private Map authorManifest(Project pack, ProjectVersion version, ModpackTarget target) { + Map manifest = new LinkedHashMap<>(); + manifest.put("format", "modtale-pack"); + manifest.put("schemaVersion", 1); + manifest.put("pack", packIdentity(pack, version)); + manifest.put("target", target.name()); + Map game = new LinkedHashMap<>(); + game.put("id", "hytale"); + game.put("versions", version.getGameVersions() == null ? List.of() : version.getGameVersions()); + manifest.put("game", game); + manifest.put("dependencies", authorDependencies(version, target)); + return manifest; } - private void writeDependencyFiles(ZipOutputStream zos, ProjectVersion version) throws IOException { + private List> authorDependencies(ProjectVersion version, ModpackTarget target) { if (version.getDependencies() == null) { - return; + return List.of(); } - + List> dependencies = new ArrayList<>(); for (ProjectDependency dependency : version.getDependencies()) { - DownloadArchiveSupport.ResolvedDependency resolvedDependency = archiveSupport.resolveDependency(dependency); - if (resolvedDependency == null || resolvedDependency.version().getFileUrl() == null) { + if (!includedInTarget(dependency, target)) { continue; } + Map item = baseDependency(dependency); + if (dependency.isExternal()) { + putIfPresent(item, "externalId", dependency.getExternalId()); + putIfPresent(item, "url", dependency.getExternalUrl()); + putIfPresent(item, "externalFileUrl", dependency.getExternalFileUrl()); + putIfPresent(item, "externalFileName", dependency.getExternalFileName()); + item.put("distribution", "REFERENCE_ONLY"); + } + dependencies.add(item); + } + return dependencies; + } - byte[] fileData = archiveSupport.download(resolvedDependency.version().getFileUrl()); - String folder = resolvedDependency.project().getClassification() != null - && "PLUGIN".equals(resolvedDependency.project().getClassification().name()) - ? "plugins/" - : "asset-packs/"; - String originalFilename = archiveSupport.extractOriginalFilename(resolvedDependency.version().getFileUrl()); + private Map lockfile( + Project pack, + ProjectVersion version, + List preparedDependencies, + List overrides, + ModpackTarget target + ) { + Map lock = new LinkedHashMap<>(); + lock.put("format", "modtale-lock"); + lock.put("lockVersion", 1); + lock.put("pack", packIdentity(pack, version)); + lock.put("target", target.name()); + lock.put("gameVersions", version.getGameVersions() == null ? List.of() : version.getGameVersions()); - zos.putNextEntry(new ZipEntry(folder + originalFilename)); - zos.write(fileData); - zos.closeEntry(); + List> entries = new ArrayList<>(); + for (PreparedDependency prepared : preparedDependencies) { + ProjectDependency dependency = prepared.dependency(); + Map item = baseDependency(dependency); + item.put("distribution", prepared.bytes() == null ? "REFERENCE_ONLY" : "BUNDLED"); + if (prepared.bytes() != null) { + item.put("path", prepared.path()); + item.put("size", prepared.bytes().length); + item.put("hashes", Map.of("sha256", sha256(prepared.bytes()))); + } else { + putIfPresent(item, "url", dependency.getExternalUrl()); + String externalFileUrl = trimToNull(dependency.getExternalFileUrl()); + if (externalFileUrl == null && dependency.getSource() == ProjectDependency.Source.CURSEFORGE) { + // Older CurseForge dependencies stored the exact file page only in externalUrl. + // Preserve those records while emitting the stricter v1 lockfile shape. + externalFileUrl = trimToNull(dependency.getExternalUrl()); + } + putIfPresent(item, "fileUrl", externalFileUrl); + if (dependency.getSource() == ProjectDependency.Source.CURSEFORGE) { + Map provider = new LinkedHashMap<>(); + String externalId = trimToNull(dependency.getExternalId()); + if (externalId != null && externalId.matches("\\d+")) { + provider.put("projectId", externalId); + } else { + putIfPresent(provider, "projectSlug", externalId); + } + putIfPresent(provider, "fileId", curseForgeFileId(dependency)); + putIfPresent(provider, "fileName", dependency.getExternalFileName()); + if (dependency.getExternalFileSize() != null && dependency.getExternalFileSize() > 0) { + provider.put("fileSize", dependency.getExternalFileSize()); + } + if (dependency.getExternalFileHashes() != null && !dependency.getExternalFileHashes().isEmpty()) { + provider.put("hashes", new TreeMap<>(dependency.getExternalFileHashes())); + } + if (dependency.getExternalGameVersions() != null && !dependency.getExternalGameVersions().isEmpty()) { + provider.put("gameVersions", dependency.getExternalGameVersions()); + } + if (dependency.getExternalFileStatus() != null) { + provider.put("fileStatus", dependency.getExternalFileStatus()); + } + if (dependency.getExternalDistributionAllowed() != null) { + provider.put("distributionAllowed", dependency.getExternalDistributionAllowed()); + } + item.put("provider", provider); + } + } + entries.add(item); + } + lock.put("entries", entries); + List> overrideEntries = new ArrayList<>(); + for (ModpackOverrideArchive.OverrideFile override : overrides) { + Map item = new LinkedHashMap<>(); + item.put("path", override.path()); + item.put("environment", override.environment().name()); + item.put("size", override.bytes().length); + item.put("hashes", Map.of("sha256", sha256(override.bytes()))); + overrideEntries.add(item); + } + lock.put("overrides", overrideEntries); + return lock; + } + + private boolean includedInTarget(ProjectDependency dependency, ModpackTarget target) { + return target.includes(dependency.getEnvironment()); + } + + private Map packIdentity(Project pack, ProjectVersion version) { + Map identity = new LinkedHashMap<>(); + identity.put("packId", nullToEmpty(pack.getId())); + identity.put("versionId", nullToEmpty(version.getId())); + identity.put("versionNumber", nullToEmpty(version.getVersionNumber())); + identity.put("name", nullToEmpty(pack.getTitle())); + return identity; + } + + private Map baseDependency(ProjectDependency dependency) { + Map item = new LinkedHashMap<>(); + item.put("id", nullToEmpty(dependency.getProjectId())); + item.put("title", nullToEmpty(dependency.getProjectTitle())); + item.put("version", nullToEmpty(dependency.getVersionNumber())); + item.put("source", dependency.getSource().name()); + item.put("dependencyType", dependency.getDependencyType().name()); + item.put("environment", dependency.getEnvironment().name()); + return item; + } + + private void writeJsonEntry(ZipOutputStream zip, String name, Map value) throws IOException { + byte[] bytes = OBJECT_MAPPER.writeValueAsString(value).concat("\n").getBytes(StandardCharsets.UTF_8); + writeBinaryEntry(zip, name, bytes); + } + + private void writeBinaryEntry(ZipOutputStream zip, String name, byte[] bytes) throws IOException { + ZipEntry entry = new ZipEntry(name); + entry.setTime(DETERMINISTIC_ZIP_TIME); + zip.putNextEntry(entry); + zip.write(bytes); + zip.closeEntry(); + } + + private String uniqueArchiveEntryName(Set archiveKeys, String filename) { + String candidate = filename; + int counter = 2; + while (!archiveKeys.add(candidate.toLowerCase(Locale.ROOT))) { + int extensionStart = filename.lastIndexOf('.'); + candidate = extensionStart > 0 + ? filename.substring(0, extensionStart) + "-" + counter + filename.substring(extensionStart) + : filename + "-" + counter; + counter++; + } + return candidate; + } + + private String externalFilename(ProjectDependency dependency) { + String filename = trimToNull(dependency.getExternalFileName()); + if (filename == null && trimToNull(dependency.getCachedFileUrl()) != null) { + filename = archiveSupport.extractOriginalFilename(dependency.getCachedFileUrl()); + } + if (filename != null) { + return sanitizeArchiveFilename(filename); + } + + String title = dependency.getProjectTitle() == null ? dependency.getProjectId() : dependency.getProjectTitle(); + String version = dependency.getVersionNumber() == null ? "latest" : dependency.getVersionNumber(); + String base = (title + "-" + version) + .replaceAll("[^A-Za-z0-9._-]+", "-") + .replaceAll("-+", "-") + .replaceAll("(^-|-$)", ""); + return (base.isBlank() ? "external-dependency" : base) + ".jar"; + } + + private String sanitizeArchiveFilename(String filename) { + String leafName = nullToEmpty(filename).replace('\\', '/'); + leafName = leafName.substring(leafName.lastIndexOf('/') + 1); + String sanitized = leafName + .replaceAll("[^A-Za-z0-9._-]+", "-") + .replaceAll("-+", "-") + .replaceAll("(^-|-$)", ""); + if (sanitized.isBlank() || ".".equals(sanitized) || "..".equals(sanitized)) { + return "dependency.jar"; + } + String lower = sanitized.toLowerCase(Locale.ROOT); + return lower.endsWith(".jar") || lower.endsWith(".zip") ? sanitized : sanitized + ".jar"; + } + + private String curseForgeFileId(ProjectDependency dependency) { + String value = trimToNull(dependency.getExternalFileUrl()); + if (value == null) { + value = trimToNull(dependency.getExternalUrl()); + } + if (value == null) { + return null; + } + try { + String[] segments = URI.create(value).getPath().split("/"); + for (int index = 0; index < segments.length - 1; index++) { + if ("files".equalsIgnoreCase(segments[index]) && segments[index + 1].matches("\\d+")) { + return segments[index + 1]; + } + } + } catch (IllegalArgumentException ignored) { + // URLs are validated before archive generation. Legacy records may be malformed, + // so omit the provider file ID instead of making archive generation fail. + } + return null; + } + + private String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is required by the Java runtime.", ex); } } @@ -125,4 +456,37 @@ private void cacheArchive(Project pack, ProjectVersion version, byte[] zipBytes) pack.getId(), version.getVersionNumber(), ex); } } + + private void putIfPresent(Map target, String name, String value) { + String normalized = trimToNull(value); + if (normalized != null) { + target.put(name, normalized); + } + } + + private String dependencyLabel(ProjectDependency dependency) { + String title = trimToNull(dependency.getProjectTitle()); + return title == null ? nullToEmpty(dependency.getProjectId()) : title; + } + + private String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private record PreparedDependency(ProjectDependency dependency, String path, byte[] bytes) { + private static PreparedDependency bundled(ProjectDependency dependency, String path, byte[] bytes) { + return new PreparedDependency(dependency, path, bytes); + } + + private static PreparedDependency reference(ProjectDependency dependency) { + return new PreparedDependency(dependency, null, null); + } + } } diff --git a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveValidator.java b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveValidator.java new file mode 100644 index 00000000..5e09fe48 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveValidator.java @@ -0,0 +1,306 @@ +package net.modtale.service.storage; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +final class ModpackArchiveValidator { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final int MAX_ENTRIES = 10_000; + private static final long MAX_UNCOMPRESSED_BYTES = 1024L * 1024 * 1024; + private static final int MAX_LOCKFILE_BYTES = 2 * 1024 * 1024; + private static final Set METADATA_FILES = Set.of( + "modpack.json", + "manifest.json", + "modtale.lock.json" + ); + + private ModpackArchiveValidator() {} + + static void validate(byte[] archive) throws IOException { + if (archive == null || archive.length == 0) { + throw new IOException("Modpack archive is empty."); + } + + Map entries = new HashMap<>(); + Set caseFoldedNames = new HashSet<>(); + Map metadata = new HashMap<>(); + long totalBytes = 0; + int entryCount = 0; + + try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(archive))) { + ZipEntry entry; + byte[] buffer = new byte[16 * 1024]; + while ((entry = input.getNextEntry()) != null) { + entryCount++; + if (entryCount > MAX_ENTRIES) { + throw new IOException("Modpack archive contains too many entries."); + } + String name = validatePath(entry.getName()); + if (entry.isDirectory()) { + throw new IOException("Modpack archive must not contain directory entries."); + } + if (!caseFoldedNames.add(name.toLowerCase(Locale.ROOT))) { + throw new IOException("Modpack archive contains duplicate or case-colliding path: " + name); + } + + MessageDigest digest = sha256Digest(); + ByteArrayOutputStream captured = METADATA_FILES.contains(name) + ? new ByteArrayOutputStream() + : null; + long size = 0; + int read; + while ((read = input.read(buffer)) != -1) { + size += read; + totalBytes += read; + if (totalBytes > MAX_UNCOMPRESSED_BYTES) { + throw new IOException("Modpack archive exceeds the uncompressed size limit."); + } + if (captured != null) { + if (size > MAX_LOCKFILE_BYTES) { + throw new IOException("Modpack metadata file exceeds the size limit."); + } + captured.write(buffer, 0, read); + } + digest.update(buffer, 0, read); + } + entries.put(name, new EntryFingerprint(size, HexFormat.of().formatHex(digest.digest()))); + if (captured != null) { + metadata.put(name, captured.toByteArray()); + } + input.closeEntry(); + } + } + + if (!entries.keySet().containsAll(METADATA_FILES)) { + throw new IOException("Modpack archive is missing required metadata files."); + } + validateMetadata(metadata, entries); + } + + private static void validateMetadata( + Map metadata, + Map archiveEntries + ) throws IOException { + JsonNode legacy = readJson(metadata.get("modpack.json"), "Legacy modpack manifest"); + if (!legacy.isObject() || legacy.path("formatVersion").asInt(-1) != 1 + || !"hytale".equals(legacy.path("game").asText()) || !legacy.path("files").isArray()) { + throw new IOException("Legacy modpack manifest has an unsupported format."); + } + JsonNode manifest = readJson(metadata.get("manifest.json"), "Modpack manifest"); + if (!manifest.isObject() || !"modtale-pack".equals(manifest.path("format").asText()) + || manifest.path("schemaVersion").asInt(-1) != 1 || !manifest.path("pack").isObject() + || !manifest.path("game").isObject() || !manifest.path("dependencies").isArray()) { + throw new IOException("Modpack manifest has an unsupported format."); + } + for (JsonNode item : manifest.path("dependencies")) { + validateEnvironment(item); + } + JsonNode lock = readJson(metadata.get("modtale.lock.json"), "Modpack lockfile"); + if (lock == null || !"modtale-lock".equals(lock.path("format").asText()) + || lock.path("lockVersion").asInt(-1) != 1 || !lock.path("pack").isObject() + || !lock.path("gameVersions").isArray() || !lock.path("entries").isArray()) { + throw new IOException("Modpack lockfile has an unsupported format."); + } + validateTargets(legacy, manifest, lock); + + Set expectedFiles = new HashSet<>(METADATA_FILES); + for (JsonNode item : lock.path("entries")) { + String distribution = item.path("distribution").asText(); + String source = item.path("source").asText(); + validateEnvironment(item); + if ("REFERENCE_ONLY".equals(distribution)) { + if (item.has("path") || item.has("size") || item.has("hashes")) { + throw new IOException("Reference-only lock entries must not claim bundled bytes."); + } + if ("CURSEFORGE".equals(source)) { + validateCurseForgeReference(item); + } + continue; + } + if (!"BUNDLED".equals(distribution)) { + throw new IOException("Modpack lock entry has an unknown distribution mode."); + } + if ("CURSEFORGE".equals(source)) { + throw new IOException("CurseForge artifacts must remain reference-only."); + } + + String path = validatePath(item.path("path").asText(null)); + if (!expectedFiles.add(path)) { + throw new IOException("Modpack lockfile contains a duplicate path: " + path); + } + EntryFingerprint actual = archiveEntries.get(path); + if (actual == null) { + throw new IOException("Modpack lockfile references a missing bundled file: " + path); + } + long expectedSize = item.path("size").asLong(-1); + String expectedHash = item.path("hashes").path("sha256").asText(); + if (expectedSize <= 0 || expectedSize != actual.size()) { + throw new IOException("Bundled file size does not match the lockfile: " + path); + } + if (!expectedHash.matches("[a-f0-9]{64}") || !expectedHash.equals(actual.sha256())) { + throw new IOException("Bundled file hash does not match the lockfile: " + path); + } + } + if (lock.has("overrides") && !lock.path("overrides").isArray()) { + throw new IOException("Modpack lockfile overrides must be an array."); + } + for (JsonNode item : lock.path("overrides")) { + validateEnvironment(item); + String path = validatePath(item.path("path").asText(null)); + String expectedPrefix = "overrides/" + item.path("environment").asText().toLowerCase(Locale.ROOT) + "/"; + if (!path.toLowerCase(Locale.ROOT).startsWith(expectedPrefix) || !expectedFiles.add(path)) { + throw new IOException("Modpack override has an invalid or duplicate path: " + path); + } + EntryFingerprint actual = archiveEntries.get(path); + long expectedSize = item.path("size").asLong(-1); + String expectedHash = item.path("hashes").path("sha256").asText(); + if (actual == null || expectedSize < 0 || expectedSize != actual.size() + || !expectedHash.matches("[a-f0-9]{64}") || !expectedHash.equals(actual.sha256())) { + throw new IOException("Override file does not match the lockfile: " + path); + } + } + + if (!archiveEntries.keySet().equals(expectedFiles)) { + throw new IOException("Modpack archive contains files that are not declared in the lockfile."); + } + } + + private static void validateEnvironment(JsonNode item) throws IOException { + if (!Set.of("COMMON", "CLIENT", "SERVER").contains(item.path("environment").asText())) { + throw new IOException("Modpack entry has an unknown environment."); + } + } + + private static void validateTargets(JsonNode legacy, JsonNode manifest, JsonNode lock) throws IOException { + String manifestTarget = manifest.path("target").asText(""); + String lockTarget = lock.path("target").asText(""); + String legacyTarget = legacy.path("target").asText(""); + if (manifestTarget.isBlank() && lockTarget.isBlank() && legacyTarget.isBlank()) { + return; + } + if (!Set.of("UNIVERSAL", "CLIENT", "SERVER").contains(manifestTarget) + || !manifestTarget.equals(lockTarget) + || !manifestTarget.equals(legacyTarget)) { + throw new IOException("Modpack metadata has an unknown or inconsistent target."); + } + } + + private static void validateCurseForgeReference(JsonNode item) throws IOException { + JsonNode provider = item.path("provider"); + String projectId = provider.path("projectId").asText(); + String projectSlug = provider.path("projectSlug").asText(); + String fileId = provider.path("fileId").asText(); + if ((!projectId.matches("[0-9]+") && projectSlug.isBlank()) + || (!projectSlug.isBlank() && !projectSlug.matches("[A-Za-z0-9][A-Za-z0-9_-]*")) + || !fileId.matches("[0-9]+")) { + throw new IOException("CurseForge lock entry is missing a valid project and file identity."); + } + if (provider.has("fileSize") && provider.path("fileSize").asLong(-1) <= 0) { + throw new IOException("CurseForge lock entry has an invalid provider file size."); + } + JsonNode hashes = provider.path("hashes"); + Set providerHashNames = new HashSet<>(); + if (hashes.isObject()) hashes.fieldNames().forEachRemaining(providerHashNames::add); + if (provider.has("hashes") && (!hashes.isObject() + || !Set.of("sha1", "md5").containsAll(providerHashNames) + || (hashes.has("sha1") && !hashes.path("sha1").asText().matches("[a-f0-9]{40}")) + || (hashes.has("md5") && !hashes.path("md5").asText().matches("[a-f0-9]{32}")) + || (!hashes.has("sha1") && !hashes.has("md5")))) { + throw new IOException("CurseForge lock entry has invalid provider hashes."); + } + if (provider.has("gameVersions")) { + JsonNode gameVersions = provider.path("gameVersions"); + Set seenVersions = new HashSet<>(); + if (!gameVersions.isArray() || gameVersions.isEmpty()) { + throw new IOException("CurseForge lock entry has invalid provider game versions."); + } + for (JsonNode version : gameVersions) { + if (!version.isTextual() || version.asText().isBlank() || !seenVersions.add(version.asText())) { + throw new IOException("CurseForge lock entry has invalid provider game versions."); + } + } + } + if (provider.has("fileStatus") && provider.path("fileStatus").asInt(0) <= 0) { + throw new IOException("CurseForge lock entry has an invalid provider file status."); + } + if (provider.has("fileName") && provider.path("fileName").asText().isBlank()) { + throw new IOException("CurseForge lock entry has an invalid provider file name."); + } + if (provider.has("distributionAllowed") && !provider.path("distributionAllowed").isBoolean()) { + throw new IOException("CurseForge lock entry has an invalid provider distribution flag."); + } + validateCurseForgeFileUrl(item.path("url").asText(), fileId); + validateCurseForgeFileUrl(item.path("fileUrl").asText(), fileId); + } + + private static void validateCurseForgeFileUrl(String value, String fileId) throws IOException { + try { + URI uri = URI.create(value); + String host = uri.getHost(); + String path = uri.getPath(); + boolean trustedHost = host != null && (host.equalsIgnoreCase("curseforge.com") + || host.toLowerCase(Locale.ROOT).endsWith(".curseforge.com")); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getRawUserInfo() != null + || (uri.getPort() != -1 && uri.getPort() != 443) || !trustedHost + || path == null || !path.toLowerCase(Locale.ROOT).startsWith("/hytale/mods/") + || !path.matches("(?i).*/files/" + fileId + "/?")) { + throw new IOException("CurseForge lock entry contains an invalid file-page URL."); + } + } catch (IllegalArgumentException ex) { + throw new IOException("CurseForge lock entry contains an invalid file-page URL.", ex); + } + } + + private static JsonNode readJson(byte[] bytes, String label) throws IOException { + if (bytes == null) { + throw new IOException(label + " is missing."); + } + try { + JsonNode value = OBJECT_MAPPER.readTree(bytes); + if (value == null) { + throw new IOException(label + " is empty."); + } + return value; + } catch (RuntimeException ex) { + throw new IOException(label + " is not valid JSON.", ex); + } + } + + private static String validatePath(String name) throws IOException { + if (name == null || name.isBlank() || name.startsWith("/") || name.contains("\\") + || name.indexOf('\0') >= 0 || name.matches("^[A-Za-z]:.*")) { + throw new IOException("Modpack archive contains an unsafe path: " + name); + } + for (String segment : name.split("/", -1)) { + if (segment.isBlank() || ".".equals(segment) || "..".equals(segment)) { + throw new IOException("Modpack archive contains an unsafe path: " + name); + } + } + return name; + } + + private static MessageDigest sha256Digest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is required by the Java runtime.", ex); + } + } + + private record EntryFingerprint(long size, String sha256) {} +} diff --git a/backend/src/main/java/net/modtale/service/storage/ModpackOverrideArchive.java b/backend/src/main/java/net/modtale/service/storage/ModpackOverrideArchive.java new file mode 100644 index 00000000..6cb7c84b --- /dev/null +++ b/backend/src/main/java/net/modtale/service/storage/ModpackOverrideArchive.java @@ -0,0 +1,97 @@ +package net.modtale.service.storage; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import net.modtale.model.project.ProjectDependency; + +public final class ModpackOverrideArchive { + private static final int MAX_FILES = 10_000; + private static final long MAX_FILE_SIZE = 32L * 1024 * 1024; + private static final long MAX_TOTAL_SIZE = 512L * 1024 * 1024; + private static final Set BLOCKED_EXTENSIONS = Set.of( + ".exe", ".dll", ".so", ".dylib", ".sh", ".bat", ".cmd", ".ps1", + ".vbs", ".js", ".jsp", ".php", ".py", ".pl", ".html", ".htm", + ".svg", ".hta", ".jar", ".zip", ".rar", ".7z", ".tar", ".gz" + ); + private static final Set WINDOWS_DEVICE_NAMES = Set.of( + "con", "prn", "aux", "nul", + "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", + "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9" + ); + + private ModpackOverrideArchive() {} + + public static List read(InputStream source) throws IOException { + List files = new ArrayList<>(); + Set paths = new HashSet<>(); + long total = 0; + try (ZipInputStream zip = new ZipInputStream(source)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + if (files.size() >= MAX_FILES) throw new IOException("Override bundle contains too many files."); + ParsedPath parsed = parsePath(entry.getName()); + if (!paths.add(parsed.path().toLowerCase(Locale.ROOT))) { + throw new IOException("Override bundle contains duplicate or case-colliding paths."); + } + byte[] bytes = zip.readNBytes((int) MAX_FILE_SIZE + 1); + if (bytes.length > MAX_FILE_SIZE) throw new IOException("An override file exceeds the 32 MiB limit."); + total += bytes.length; + if (total > MAX_TOTAL_SIZE) throw new IOException("Override bundle exceeds the 512 MiB expanded limit."); + files.add(new OverrideFile(parsed.path(), parsed.environment(), bytes)); + } + } + if (files.isEmpty()) throw new IOException("Override bundle does not contain any files."); + return List.copyOf(files); + } + + private static ParsedPath parsePath(String raw) throws IOException { + String path = raw == null ? "" : raw.replace('\\', '/'); + if (path.isBlank() || path.startsWith("/") || path.matches("^[A-Za-z]:.*") || path.contains("//")) { + throw new IOException("Override bundle contains an unsafe path."); + } + for (String segment : path.split("/")) { + if (segment.isBlank() || ".".equals(segment) || "..".equals(segment)) { + throw new IOException("Override bundle contains an unsafe path."); + } + } + String lower = path.toLowerCase(Locale.ROOT); + ProjectDependency.Environment environment; + String prefix; + if (lower.startsWith("overrides/common/")) { + environment = ProjectDependency.Environment.COMMON; + prefix = "overrides/common/"; + } else if (lower.startsWith("overrides/client/")) { + environment = ProjectDependency.Environment.CLIENT; + prefix = "overrides/client/"; + } else if (lower.startsWith("overrides/server/")) { + environment = ProjectDependency.Environment.SERVER; + prefix = "overrides/server/"; + } + else throw new IOException("Override files must be inside overrides/common, overrides/client, or overrides/server."); + String relative = path.substring(prefix.length()); + for (String segment : relative.split("/")) { + if (segment.matches(".*[<>:\"|?*\\p{Cntrl}].*") || segment.endsWith(".") || segment.endsWith(" ")) { + throw new IOException("Override bundle contains a non-portable path."); + } + String baseName = segment.toLowerCase(Locale.ROOT).split("\\.", 2)[0]; + if (WINDOWS_DEVICE_NAMES.contains(baseName)) { + throw new IOException("Override bundle contains a reserved device name."); + } + } + if (BLOCKED_EXTENSIONS.stream().anyMatch(lower::endsWith)) { + throw new IOException("Override bundle contains a blocked executable, script, or nested archive."); + } + return new ParsedPath(prefix + relative, environment); + } + + private record ParsedPath(String path, ProjectDependency.Environment environment) {} + public record OverrideFile(String path, ProjectDependency.Environment environment, byte[] bytes) {} +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 5ff2cc37..153a7d59 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -76,6 +76,8 @@ spring.security.oauth2.client.provider.hytale.user-name-attribute=sub app.backend.url=${BACKEND_URL:http://localhost:8080} app.frontend.url=${FRONTEND_URL:http://localhost:5173} app.cors.allowed-origins=${FRONTEND_URL:http://localhost:5173} +app.curseforge.api-key=${CURSEFORGE_API_KEY:} +app.curseforge.hytale-game-id=${CURSEFORGE_HYTALE_GAME_ID:0} server.port=8080 server.http2.enabled=true diff --git a/backend/src/test/java/net/modtale/controller/project/VersionControllerTest.java b/backend/src/test/java/net/modtale/controller/project/VersionControllerTest.java index 9f4dd6ae..858fcc5f 100644 --- a/backend/src/test/java/net/modtale/controller/project/VersionControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/project/VersionControllerTest.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.dto.request.project.DependencyReferenceRequest; import net.modtale.model.dto.request.project.CreateVersionRequest; import net.modtale.model.dto.project.ProjectVersionDTO; import net.modtale.model.project.Project; @@ -103,15 +104,18 @@ void tearDown() { } @Test - void addVersionSplitsCommaSeparatedDependencyIdsBeforeDelegating() throws Exception { + void addVersionDelegatesStructuredDependenciesAndSplitsIncompatibleIds() throws Exception { User currentUser = user("user-1"); Authentication authentication = mock(Authentication.class); MockMultipartFile file = new MockMultipartFile("file", "mod.jar", "application/java-archive", new byte[]{1, 2, 3}); + DependencyReferenceRequest dependencyA = dependency("dep-a", "1.0.0"); + DependencyReferenceRequest dependencyB = dependency("dep-b", "2.0.0"); CreateVersionRequest requestPayload = new CreateVersionRequest(); requestPayload.setVersionNumber("1.0.0"); requestPayload.setGameVersions(List.of("1.0.0")); requestPayload.setFile(file); - requestPayload.setModIds(List.of("dep-a, dep-b, , dep-c")); + requestPayload.setDependencies(List.of(dependencyA, dependencyB)); + requestPayload.setIncompatibleProjectIds(List.of("dep-a, dep-b, , dep-c")); requestPayload.setChangelog("Release notes"); requestPayload.setChannel(ProjectVersion.Channel.BETA); requestPayload.setReplaceExisting(true); @@ -127,8 +131,8 @@ void addVersionSplitsCommaSeparatedDependencyIdsBeforeDelegating() throws Except eq(List.of("1.0.0")), eq(file), eq("Release notes"), + eq(List.of(dependencyA, dependencyB)), eq(List.of("dep-a", "dep-b", "dep-c")), - isNull(), eq(ProjectVersion.Channel.BETA), eq(true), eq(currentUser) @@ -246,7 +250,7 @@ void downloadBundleTracksOnlySelectedDependencies() throws Exception { version.setDependencies(List.of( new ProjectDependency("dep-a", "Dependency A", "1.0.0"), new ProjectDependency("dep-b", "Dependency B", "2.0.0"), - new ProjectDependency("dep-c", "Dependency C", "3.0.0", false, true) + new ProjectDependency("dep-c", "Dependency C", "3.0.0", ProjectDependency.DependencyType.EMBEDDED) )); when(downloadTokenService.validateAndConsume("bundle-token")).thenReturn( @@ -327,4 +331,11 @@ private static User user(String id) { user.setId(id); return user; } + + private static DependencyReferenceRequest dependency(String projectId, String versionNumber) { + DependencyReferenceRequest request = new DependencyReferenceRequest(); + request.setProjectId(projectId); + request.setVersionNumber(versionNumber); + return request; + } } diff --git a/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java b/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java index eb1d6beb..6a1ddaa5 100644 --- a/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java +++ b/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java @@ -77,7 +77,6 @@ void toDTOMapsCommentsVersionsAndVotesForFullResponses() { Project project = baseProject(); project.setAbout("Deep project details"); project.setChildProjectIds(List.of("child-1")); - project.setModIds(List.of("mod-1")); project.setGalleryImages(List.of("https://example.com/one.png")); project.setGalleryImageCaptions(Map.of("https://example.com/one.png", "Opening shot")); project.setGalleryCarouselEnabled(true); @@ -91,7 +90,6 @@ void toDTOMapsCommentsVersionsAndVotesForFullResponses() { assertEquals("Deep project details", dto.getAbout()); assertEquals(List.of("child-1"), dto.getChildProjectIds()); - assertEquals(List.of("mod-1"), dto.getModIds()); assertEquals(Map.of("https://example.com/one.png", "Opening shot"), dto.getGalleryImageCaptions()); assertTrue(dto.isGalleryCarouselEnabled()); assertEquals(1, dto.getComments().size()); @@ -128,11 +126,17 @@ void versionAndDependencyMappingsHonorOptionalReviewData() { ProjectVersionSummaryDTO withoutReview = ProjectMapper.toVersionSummaryDTO(version, false); ProjectVersionSummaryDTO withReview = ProjectMapper.toVersionSummaryDTO(version, true); AdminProjectVersionSummaryDTO adminVersion = ProjectMapper.toAdminVersionSummaryDTO(version); - ProjectDependency dependency = new ProjectDependency("modtale:core", "Core", "1.0.0", true, true); + ProjectDependency dependency = new ProjectDependency( + "modtale:core", + "Core", + "1.0.0", + ProjectDependency.DependencyType.EMBEDDED + ); dependency.setIcon("/icons/core.png"); dependency.setTitle("Core Display"); dependency.setClassification(ProjectClassification.PLUGIN); dependency.setSlug("core"); + dependency.setEnvironment(ProjectDependency.Environment.SERVER); ProjectDependencyDTO dependencyDto = ProjectMapper.toDependencyDTO(dependency); assertNull(withoutReview.reviewStatus()); @@ -140,11 +144,14 @@ void versionAndDependencyMappingsHonorOptionalReviewData() { assertEquals("Security review cleared", withReview.rejectionReason()); assertNotNull(adminVersion.scanResult()); assertEquals("modtale:core", dependencyDto.projectId()); + assertEquals(ProjectDependency.DependencyType.EMBEDDED, dependencyDto.dependencyType()); + assertEquals(ProjectDependency.Source.MODTALE, dependencyDto.source()); + assertEquals(ProjectDependency.Environment.SERVER, dependencyDto.environment()); assertEquals("/icons/core.png", dependencyDto.icon()); assertEquals("Core Display", dependencyDto.title()); assertEquals(ProjectClassification.PLUGIN, dependencyDto.classification()); assertEquals("core", dependencyDto.slug()); - assertTrue(dependencyDto.isOptional()); + assertFalse(dependencyDto.isOptional()); assertTrue(dependencyDto.isEmbedded()); assertEquals("Lock in complete", ProjectMapper.toVersionDTO(version).getChangelog()); assertEquals("Core", ProjectMapper.toVersionDTO(version).getDependencies().getFirst().projectTitle()); @@ -243,7 +250,7 @@ private static ProjectVersion version(String id) { version.setDownloadCount(12); version.setReleaseDate("2026-01-01T10:00:00"); version.setChangelog("Lock in complete"); - version.setDependencies(List.of(new ProjectDependency("modtale:core", "Core", "1.0.0", false, false))); + version.setDependencies(List.of(new ProjectDependency("modtale:core", "Core", "1.0.0"))); version.setIncompatibleProjectIds(List.of("modtale:legacy")); version.setChannel(ProjectVersion.Channel.RELEASE); version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); diff --git a/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java index b0c122b6..62428666 100644 --- a/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java @@ -126,10 +126,11 @@ void hardDeleteRemovesArtifactsAnalyticsAndProjectRecordsWhenNoDependentsRemain( project.setImageUrl("https://cdn.modtale.net/icon.png"); project.setBannerUrl("https://cdn.modtale.net/banner.png"); project.setGalleryImages(new ArrayList<>(List.of("https://cdn.modtale.net/one.png"))); - project.setModIds(new ArrayList<>(List.of("dep-2"))); + project.setChildProjectIds(new ArrayList<>(List.of("dep-2"))); ProjectVersion version = version("1.0.0"); version.setFileUrl("files/project-1/main.jar"); + version.setOverrideFileUrl("modpack-overrides/project-1/overrides.zip"); version.setDependencies(List.of(new ProjectDependency("dep-1", "Dependency One", "2.0.0"))); project.setVersions(new ArrayList<>(List.of(version))); @@ -144,6 +145,7 @@ void hardDeleteRemovesArtifactsAnalyticsAndProjectRecordsWhenNoDependentsRemain( verify(trackingService).deleteProjectAnalytics("project-1"); verify(storageService).deleteFile("files/project-1/main.jar"); + verify(storageService).deleteFile("modpack-overrides/project-1/overrides.zip"); verify(storageService).deleteFile("https://cdn.modtale.net/icon.png"); verify(storageService).deleteFile("https://cdn.modtale.net/banner.png"); verify(storageService).deleteFile("https://cdn.modtale.net/one.png"); diff --git a/backend/src/test/java/net/modtale/service/project/version/CurseForgeApiClientTest.java b/backend/src/test/java/net/modtale/service/project/version/CurseForgeApiClientTest.java new file mode 100644 index 00000000..70ff6734 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/project/version/CurseForgeApiClientTest.java @@ -0,0 +1,169 @@ +package net.modtale.service.project.version; + +import java.util.List; +import java.util.Optional; +import net.modtale.config.properties.AppCurseForgeProperties; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withResourceNotFound; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class CurseForgeApiClientTest { + + @Test + void usesTheDocumentedExactFileEndpointPersistsMetadataAndCachesSuccesses() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + CurseForgeApiClient client = new CurseForgeApiClient(properties(), restTemplate); + + server.expect(once(), requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("x-api-key", "approved-test-key")) + .andExpect(header("User-Agent", "Modtale/1.0 (+https://modtale.net)")) + .andRespond(withSuccess(""" + {"data":[{"id":1450386,"gameId":1234,"name":"Simple Compost","slug":"simple-compost","summary":"Compost things","isAvailable":true,"allowModDistribution":false,"logo":{"thumbnailUrl":"https://example.test/icon.png"}}]} + """, MediaType.APPLICATION_JSON)); + server.expect(once(), requestTo("https://api.curseforge.com/v1/mods/1450386/files/8227810")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("x-api-key", "approved-test-key")) + .andRespond(withSuccess(""" + {"data":{"id":8227810,"modId":1450386,"isAvailable":true,"displayName":"1.0.0","fileName":"SimpleCompost-1.0.0.jar","releaseType":1,"fileStatus":4,"fileDate":"2026-08-01T00:00:00Z","fileLength":2048,"gameVersions":["2026.08"],"hashes":[{"algo":1,"value":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"algo":2,"value":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}]}} + """, MediaType.APPLICATION_JSON)); + + CurseForgeApiClient.CurseForgeProject project = client.resolveProject("simple-compost", "8227810").orElseThrow(); + + assertEquals("1450386", project.id()); + assertEquals("Simple Compost", project.title()); + assertEquals(1, project.files().size()); + assertEquals("8227810", project.files().getFirst().id()); + assertEquals("RELEASE", project.files().getFirst().releaseType()); + assertEquals(2048L, project.files().getFirst().fileSize()); + assertEquals("a".repeat(40), project.files().getFirst().hashes().get("sha1")); + assertEquals("b".repeat(32), project.files().getFirst().hashes().get("md5")); + assertEquals(List.of("2026.08"), project.files().getFirst().gameVersions()); + assertEquals(false, project.distributionAllowed()); + + CurseForgeApiClient.CurseForgeProject cached = client.resolveProject("simple-compost", "8227810").orElseThrow(); + assertEquals(project, cached); + server.verify(); + } + + @Test + void listsRecentFilesAndFiltersUnavailableOrMismatchedResults() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + CurseForgeApiClient client = new CurseForgeApiClient(properties(), restTemplate); + server.expect(requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) + .andRespond(withSuccess(""" + {"data":[{"id":1450386,"gameId":1234,"name":"Simple Compost","slug":"simple-compost","isAvailable":true}]} + """, MediaType.APPLICATION_JSON)); + server.expect(requestTo("https://api.curseforge.com/v1/mods/1450386/files?pageSize=50")) + .andRespond(withSuccess(""" + {"data":[ + {"id":3,"modId":1450386,"isAvailable":true,"displayName":"older","fileDate":"2026-08-01T00:00:00Z"}, + {"id":4,"modId":1450386,"isAvailable":true,"displayName":"newer","fileDate":"2026-09-01T00:00:00Z"}, + {"id":5,"modId":1450386,"isAvailable":false,"displayName":"withdrawn"}, + {"id":6,"modId":999,"isAvailable":true,"displayName":"wrong project"} + ]} + """, MediaType.APPLICATION_JSON)); + + CurseForgeApiClient.CurseForgeProject project = client.resolveProject("simple-compost", null).orElseThrow(); + + assertEquals(List.of("4", "3"), project.files().stream().map(CurseForgeApiClient.CurseForgeFile::id).toList()); + server.verify(); + } + + @Test + void rejectsProviderResponsesThatDoNotMatchTheRequestedGameOrSlug() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + CurseForgeApiClient client = new CurseForgeApiClient(properties(), restTemplate); + server.expect(requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) + .andRespond(withSuccess(""" + {"data":[{"id":1450386,"gameId":4321,"name":"Other","slug":"other","isAvailable":true}]} + """, MediaType.APPLICATION_JSON)); + + assertTrue(client.resolveProject("simple-compost", null).isEmpty()); + server.verify(); + } + + @Test + void failsClosedToTheStableReferenceFlowWhenProviderIsUnavailable() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + CurseForgeApiClient client = new CurseForgeApiClient(properties(), restTemplate); + server.expect(requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) + .andRespond(withResourceNotFound()); + + assertTrue(client.resolveProject("simple-compost", null).isEmpty()); + server.verify(); + } + + @Test + void makesNoRequestWithoutBothAnApprovedKeyAndGameId() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + CurseForgeApiClient client = new CurseForgeApiClient( + new AppCurseForgeProperties("", 0), + restTemplate + ); + + Optional result = client.resolveProject("simple-compost", null); + + assertTrue(result.isEmpty()); + server.verify(); + } + + @Test + void rejectsARequestedFileThatIsMissingOrWithdrawn() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + CurseForgeApiClient client = new CurseForgeApiClient(properties(), restTemplate); + server.expect(requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) + .andRespond(withSuccess(""" + {"data":[{"id":1450386,"gameId":1234,"name":"Simple Compost","slug":"simple-compost","isAvailable":true}]} + """, MediaType.APPLICATION_JSON)); + server.expect(requestTo("https://api.curseforge.com/v1/mods/1450386/files/8227810")) + .andRespond(withSuccess("{\"data\":{\"id\":8227810,\"modId\":1450386,\"isAvailable\":false}}", MediaType.APPLICATION_JSON)); + + assertTrue(client.resolveProject("simple-compost", "8227810").isEmpty()); + server.verify(); + } + + @Test + void handlesRateLimitsAndMalformedResponsesWithoutRetryingOrLeakingErrors() { + RestTemplate throttledTemplate = new RestTemplate(); + MockRestServiceServer throttledServer = MockRestServiceServer.bindTo(throttledTemplate).build(); + CurseForgeApiClient throttledClient = new CurseForgeApiClient(properties(), throttledTemplate); + throttledServer.expect(once(), requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) + .andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS).header("Retry-After", "120")); + + assertTrue(throttledClient.resolveProject("simple-compost", null).isEmpty()); + throttledServer.verify(); + + RestTemplate malformedTemplate = new RestTemplate(); + MockRestServiceServer malformedServer = MockRestServiceServer.bindTo(malformedTemplate).build(); + CurseForgeApiClient malformedClient = new CurseForgeApiClient(properties(), malformedTemplate); + malformedServer.expect(once(), requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) + .andRespond(withSuccess("not-json", MediaType.APPLICATION_JSON)); + + assertTrue(malformedClient.resolveProject("simple-compost", null).isEmpty()); + malformedServer.verify(); + } + + private static AppCurseForgeProperties properties() { + return new AppCurseForgeProperties("approved-test-key", 1234); + } +} diff --git a/backend/src/test/java/net/modtale/service/project/version/ExternalDependencyArtifactServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/ExternalDependencyArtifactServiceTest.java new file mode 100644 index 00000000..6bf4fbca --- /dev/null +++ b/backend/src/test/java/net/modtale/service/project/version/ExternalDependencyArtifactServiceTest.java @@ -0,0 +1,145 @@ +package net.modtale.service.project.version; + +import java.net.http.HttpClient; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.modtale.exception.InvalidVersionRequestException; +import net.modtale.model.project.ProjectDependency; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class ExternalDependencyArtifactServiceTest { + + private HttpClient httpClient; + private ExternalDependencyArtifactService service; + + @BeforeEach + void setUp() { + httpClient = mock(HttpClient.class); + service = new ExternalDependencyArtifactService(httpClient); + } + + @Test + void curseForgeDependencyStaysAReferenceAndClearsLegacyCachedArtifact() { + ProjectDependency dependency = curseForgeDependency( + "https://www.curseforge.com/hytale/mods/simple-compost/files/8227810" + ); + dependency.setCachedFileUrl("external-dependencies/curseforge/1450386/8227810/mod.jar"); + + service.prepareExternalArtifacts(List.of(dependency)); + + assertEquals("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810", dependency.getExternalUrl()); + assertEquals("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810", dependency.getExternalFileUrl()); + assertEquals("SimpleCompost-1.0.0.jar", dependency.getExternalFileName()); + assertNull(dependency.getCachedFileUrl()); + verifyNoInteractions(httpClient); + } + + @Test + void acceptsAFilePageSuppliedSeparatelyFromTheProjectPage() { + ProjectDependency dependency = curseForgeDependency( + "https://www.curseforge.com/hytale/mods/simple-compost" + ); + dependency.setExternalFileUrl("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810"); + + service.prepareExternalArtifacts(List.of(dependency)); + + assertEquals("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810", dependency.getExternalFileUrl()); + verifyNoInteractions(httpClient); + } + + @Test + void rejectsCurseForgeProjectReferencesWithoutASpecificFile() { + ProjectDependency dependency = curseForgeDependency( + "https://www.curseforge.com/hytale/mods/simple-compost" + ); + + assertThrows(InvalidVersionRequestException.class, () -> service.prepareExternalArtifacts(List.of(dependency))); + verifyNoInteractions(httpClient); + } + + @Test + void rejectsLookalikeAndNonHytaleFilePages() { + ProjectDependency lookalike = curseForgeDependency( + "https://curseforge.com.evil.example/hytale/mods/simple-compost/files/8227810" + ); + ProjectDependency wrongGame = curseForgeDependency( + "https://www.curseforge.com/minecraft/mc-mods/simple-compost/files/8227810" + ); + + assertThrows(InvalidVersionRequestException.class, () -> service.prepareExternalArtifacts(List.of(lookalike))); + assertThrows(InvalidVersionRequestException.class, () -> service.prepareExternalArtifacts(List.of(wrongGame))); + verifyNoInteractions(httpClient); + } + + @Test + void replacesClientMetadataWithTheDocumentedApiSnapshotWhenConfigured() { + CurseForgeApiClient apiClient = mock(CurseForgeApiClient.class); + when(apiClient.isConfigured()).thenReturn(true); + when(apiClient.resolveProject("simple-compost", "8227810")).thenReturn(Optional.of( + new CurseForgeApiClient.CurseForgeProject( + "1450386", "simple-compost", "Simple Compost", null, null, false, + List.of(new CurseForgeApiClient.CurseForgeFile( + "8227810", "Simple Compost 1.1.0", "SimpleCompost-1.1.0.jar", "1.1.0", + "RELEASE", "2026-09-01T00:00:00Z", 4096L, + Map.of("sha1", "a".repeat(40)), List.of("2026.09"), 4, true + )) + ) + )); + ProjectDependency dependency = curseForgeDependency( + "https://www.curseforge.com/hytale/mods/simple-compost/files/8227810" + ); + dependency.setExternalFileSize(1L); + dependency.setExternalFileHashes(Map.of("sha1", "b".repeat(40))); + + new ExternalDependencyArtifactService(apiClient, httpClient).prepareExternalArtifacts(List.of(dependency)); + + assertEquals("1.1.0", dependency.getVersionNumber()); + assertEquals("SimpleCompost-1.1.0.jar", dependency.getExternalFileName()); + assertEquals(4096L, dependency.getExternalFileSize()); + assertEquals("a".repeat(40), dependency.getExternalFileHashes().get("sha1")); + assertEquals(List.of("2026.09"), dependency.getExternalGameVersions()); + assertEquals(4, dependency.getExternalFileStatus()); + assertEquals(false, dependency.getExternalDistributionAllowed()); + verifyNoInteractions(httpClient); + } + + @Test + void preservesTheCanonicalReferenceButDiscardsClientMetadataWhenTheApiIsUnavailable() { + CurseForgeApiClient apiClient = mock(CurseForgeApiClient.class); + when(apiClient.isConfigured()).thenReturn(true); + when(apiClient.resolveProject("simple-compost", "8227810")).thenReturn(Optional.empty()); + ProjectDependency dependency = curseForgeDependency( + "https://www.curseforge.com/hytale/mods/simple-compost/files/8227810" + ); + dependency.setExternalFileSize(1L); + dependency.setExternalFileHashes(Map.of("sha1", "b".repeat(40))); + + new ExternalDependencyArtifactService(apiClient, httpClient).prepareExternalArtifacts(List.of(dependency)); + + assertEquals("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810", dependency.getExternalFileUrl()); + assertNull(dependency.getExternalFileSize()); + assertNull(dependency.getExternalFileHashes()); + verifyNoInteractions(httpClient); + } + + private static ProjectDependency curseForgeDependency(String url) { + ProjectDependency dependency = ProjectDependency.curseForge( + "1450386", + "Simple Compost", + "1.0.0", + url, + ProjectDependency.DependencyType.REQUIRED + ); + dependency.setExternalFileName("SimpleCompost-1.0.0.jar"); + return dependency; + } +} diff --git a/backend/src/test/java/net/modtale/service/project/version/ExternalProjectReferenceServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/ExternalProjectReferenceServiceTest.java new file mode 100644 index 00000000..5f898bf2 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/project/version/ExternalProjectReferenceServiceTest.java @@ -0,0 +1,127 @@ +package net.modtale.service.project.version; + +import net.modtale.exception.InvalidVersionRequestException; +import net.modtale.model.dto.project.ExternalProjectReferenceDTO; +import net.modtale.model.project.ProjectDependency; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExternalProjectReferenceServiceTest { + + private final CurseForgeApiClient apiClient = mock(CurseForgeApiClient.class); + private final ExternalProjectReferenceService service = new ExternalProjectReferenceService(apiClient); + + @Test + void resolvesSpecificCurseForgeFileWithoutCallingAnUndocumentedApi() { + ExternalProjectReferenceDTO result = service.resolve( + "https://www.curseforge.com/hytale/mods/simple-compost/files/8227810", + ProjectDependency.Source.CURSEFORGE + ); + + assertEquals(ProjectDependency.Source.CURSEFORGE, result.source()); + assertEquals("simple-compost", result.externalId()); + assertEquals("Simple Compost", result.title()); + assertEquals("8227810", result.versionNumber()); + assertEquals("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810", result.externalUrl()); + assertTrue(result.hytaleProjectConfirmed()); + assertEquals(1, result.files().size()); + assertEquals("8227810", result.files().getFirst().id()); + assertNull(result.files().getFirst().downloadUrl()); + } + + @Test + void preservesNumericProjectIdWhenCurseForgeIncludesIt() { + ExternalProjectReferenceDTO result = service.resolve( + "https://www.curseforge.com/hytale/mods/simple-compost/files/8227810?projectId=1450386&utm_source=test", + null + ); + + assertEquals("1450386", result.externalId()); + assertFalse(result.externalUrl().contains("utm_source")); + } + + @Test + void projectPageResolvesAsAReferenceButHasNoInventedFile() { + ExternalProjectReferenceDTO result = service.resolve( + "https://www.curseforge.com/hytale/mods/simple-compost", + ProjectDependency.Source.CURSEFORGE + ); + + assertEquals("latest", result.versionNumber()); + assertTrue(result.files().isEmpty()); + } + + @Test + void rejectsLookalikeHostsAndNonHytaleCurseForgePages() { + assertThrows(InvalidVersionRequestException.class, () -> service.resolve( + "https://curseforge.com.evil.example/hytale/mods/simple-compost/files/1", + ProjectDependency.Source.CURSEFORGE + )); + assertThrows(InvalidVersionRequestException.class, () -> service.resolve( + "https://www.curseforge.com/minecraft/mc-mods/simple-compost/files/1", + ProjectDependency.Source.CURSEFORGE + )); + assertThrows(InvalidVersionRequestException.class, () -> service.resolve( + "http://www.curseforge.com/hytale/mods/simple-compost/files/1", + ProjectDependency.Source.CURSEFORGE + )); + assertThrows(InvalidVersionRequestException.class, () -> service.resolve( + "https://attacker@www.curseforge.com/hytale/mods/simple-compost/files/1", + ProjectDependency.Source.CURSEFORGE + )); + assertThrows(InvalidVersionRequestException.class, () -> service.resolve( + "https://www.curseforge.com:444/hytale/mods/simple-compost/files/1", + ProjectDependency.Source.CURSEFORGE + )); + assertThrows(InvalidVersionRequestException.class, () -> service.resolve( + "https://www.curseforge.com/hytale/mods/simple-compost/files/not-a-number", + ProjectDependency.Source.CURSEFORGE + )); + } + + @Test + void rejectsMalformedUrlsWithoutMakingNetworkRequests() { + assertThrows(InvalidVersionRequestException.class, () -> service.resolve( + "not a URL", + ProjectDependency.Source.CURSEFORGE + )); + } + + @Test + void enrichesAStableReferenceFromTheDocumentedApiWhenConfigured() { + CurseForgeApiClient apiClient = mock(CurseForgeApiClient.class); + when(apiClient.resolveProject("simple-compost", "8227810")).thenReturn(java.util.Optional.of( + new CurseForgeApiClient.CurseForgeProject( + "1450386", "simple-compost", "SimpleCompost", "Compost all the things", "https://example.test/icon.png", true, + List.of(new CurseForgeApiClient.CurseForgeFile( + "8227810", "SimpleCompost 1.0.0", "SimpleCompost-1.0.0.jar", "1.0.0", "RELEASE", "2026-08-01T00:00:00Z", + 2048L, java.util.Map.of("sha1", "a".repeat(40)), List.of("2026.08"), 4, true + )) + ) + )); + + ExternalProjectReferenceDTO result = new ExternalProjectReferenceService(apiClient).resolve( + "https://www.curseforge.com/hytale/mods/simple-compost/files/8227810", + ProjectDependency.Source.CURSEFORGE + ); + + assertEquals("1450386", result.externalId()); + assertEquals("SimpleCompost", result.title()); + assertEquals("1.0.0", result.versionNumber()); + assertEquals("SimpleCompost-1.0.0.jar", result.files().getFirst().fileName()); + assertEquals(2048L, result.files().getFirst().fileSize()); + assertEquals("a".repeat(40), result.files().getFirst().hashes().get("sha1")); + assertEquals(true, result.distributionAllowed()); + assertNull(result.files().getFirst().downloadUrl()); + } +} diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionArtifactServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionArtifactServiceTest.java index 2a9f9dc4..153e1f7e 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionArtifactServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionArtifactServiceTest.java @@ -17,6 +17,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; class VersionArtifactServiceTest { @@ -60,6 +61,23 @@ void prepareVersionArtifactWrapsChecksumReadFailuresInANamedException() throws E ); } + @Test + void prepareVersionArtifactValidatesAndStoresModpackOverridesSeparately() { + StorageService storageService = mock(StorageService.class); + FileValidationService fileValidationService = mock(FileValidationService.class); + service = new VersionArtifactService(storageService, fileValidationService, mongoTemplate); + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(storageService.upload(file, "modpack-overrides")).thenReturn("modpack-overrides/source.zip"); + Project project = new Project(); + project.setClassification(ProjectClassification.MODPACK); + + VersionArtifactService.PreparedVersionArtifact artifact = service.prepareVersionArtifact(project, file); + + assertEquals("modpack-overrides/source.zip", artifact.filePath()); + verify(fileValidationService).validateProjectFile(file, "MODPACK"); + } + private static final class BrokenInputStream extends InputStream { @Override public int read() throws IOException { diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionDependencyServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionDependencyServiceTest.java index 5c8d7fa2..21c11181 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionDependencyServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionDependencyServiceTest.java @@ -2,6 +2,7 @@ import java.util.List; import net.modtale.exception.InvalidVersionRequestException; +import net.modtale.model.dto.request.project.DependencyReferenceRequest; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectStatus; @@ -34,7 +35,10 @@ void resolveRequestedDependenciesBuildsDependencyModelsAndFlags() { when(projectService.getRawProjectById("dep-2")).thenReturn(project("dep-2", "Dependency Two", ProjectStatus.PUBLISHED, "2.0.0")); VersionDependencyService.ResolvedDependencies resolved = service.resolveRequestedDependencies( - List.of("dep-1:1.0.0:optional", "dep-2:2.0.0:embedded"), + List.of( + dependency("dep-1", "1.0.0", ProjectDependency.DependencyType.OPTIONAL), + dependency("dep-2", "2.0.0", ProjectDependency.DependencyType.EMBEDDED) + ), false, false ); @@ -54,19 +58,68 @@ void resolveRequestedDependenciesRequiresAtLeastTwoDependenciesForModpacks() { assertThrows( InvalidVersionRequestException.class, - () -> service.resolveRequestedDependencies(List.of("dep-1:1.0.0"), true, false) + () -> service.resolveRequestedDependencies(List.of(dependency("dep-1", "1.0.0")), true, false) ); } + @Test + void resolveRequestedDependenciesPreservesOptionalAndEnvironmentForModpacks() { + when(projectService.getRawProjectById("client-mod")) + .thenReturn(project("client-mod", "Client Mod", ProjectStatus.PUBLISHED, "1.0.0")); + when(projectService.getRawProjectById("server-mod")) + .thenReturn(project("server-mod", "Server Mod", ProjectStatus.PUBLISHED, "2.0.0")); + DependencyReferenceRequest client = dependency( + "client-mod", + "1.0.0", + ProjectDependency.DependencyType.OPTIONAL + ); + client.setEnvironment(ProjectDependency.Environment.CLIENT); + DependencyReferenceRequest server = dependency( + "server-mod", + "2.0.0", + ProjectDependency.DependencyType.REQUIRED + ); + server.setEnvironment(ProjectDependency.Environment.SERVER); + + VersionDependencyService.ResolvedDependencies resolved = service.resolveRequestedDependencies( + List.of(client, server), + true, + false + ); + + assertEquals(ProjectDependency.DependencyType.OPTIONAL, + resolved.dependencies().getFirst().getDependencyType()); + assertEquals(ProjectDependency.Environment.CLIENT, + resolved.dependencies().getFirst().getEnvironment()); + assertEquals(ProjectDependency.Environment.SERVER, + resolved.dependencies().get(1).getEnvironment()); + } + + @Test + void resolveRequestedDependenciesRejectsDuplicateProjects() { + when(projectService.getRawProjectById("dep-1")).thenReturn(project("dep-1", "Dependency One", ProjectStatus.PUBLISHED, "1.0.0", "2.0.0")); + + InvalidVersionRequestException error = assertThrows( + InvalidVersionRequestException.class, + () -> service.resolveRequestedDependencies( + List.of(dependency("dep-1", "1.0.0"), dependency("dep-1", "2.0.0")), + true, + false + ) + ); + + assertEquals("Each dependency can only be included once.", error.getMessage()); + } + @Test void resolveRequestedDependenciesRejectsMalformedMissingDraftOrUnknownVersions() { when(projectService.getRawProjectById("draft")).thenReturn(project("draft", "Draft", ProjectStatus.DRAFT, "1.0.0")); when(projectService.getRawProjectById("dep-1")).thenReturn(project("dep-1", "Dependency One", ProjectStatus.PUBLISHED, "1.0.0")); - assertThrows(InvalidVersionRequestException.class, () -> service.resolveRequestedDependencies(List.of("bad-entry"), false, false)); - assertThrows(InvalidVersionRequestException.class, () -> service.resolveRequestedDependencies(List.of("missing:1.0.0"), false, false)); - assertThrows(InvalidVersionRequestException.class, () -> service.resolveRequestedDependencies(List.of("draft:1.0.0"), false, false)); - assertThrows(InvalidVersionRequestException.class, () -> service.resolveRequestedDependencies(List.of("dep-1:2.0.0"), false, false)); + assertThrows(InvalidVersionRequestException.class, () -> service.resolveRequestedDependencies(List.of(new DependencyReferenceRequest()), false, false)); + assertThrows(InvalidVersionRequestException.class, () -> service.resolveRequestedDependencies(List.of(dependency("missing", "1.0.0")), false, false)); + assertThrows(InvalidVersionRequestException.class, () -> service.resolveRequestedDependencies(List.of(dependency("draft", "1.0.0")), false, false)); + assertThrows(InvalidVersionRequestException.class, () -> service.resolveRequestedDependencies(List.of(dependency("dep-1", "2.0.0")), false, false)); } @Test @@ -74,9 +127,68 @@ void resolveRequestedDependenciesAllowsDraftsWhenRequested() { when(projectService.getRawProjectById("draft")).thenReturn(project("draft", "Draft", ProjectStatus.DRAFT, "1.0.0")); VersionDependencyService.ResolvedDependencies resolved = - service.resolveRequestedDependencies(List.of("draft:1.0.0"), false, true); + service.resolveRequestedDependencies(List.of(dependency("draft", "1.0.0")), false, true); + + assertEquals("draft", resolved.dependencies().getFirst().getProjectId()); + } + + @Test + void resolveRequestedDependenciesAcceptsConfirmedExternalReferencesWithFileMetadata() { + DependencyReferenceRequest external = new DependencyReferenceRequest(); + external.setSource(ProjectDependency.Source.GITHUB); + external.setProjectTitle("GitHub Mod"); + external.setVersionNumber("latest"); + external.setExternalUrl("https://github.com/modtale/example-mod"); + external.setExternalFileUrl("https://raw.githubusercontent.com/modtale/example-mod/main/build/libs/example-mod.jar"); + external.setExternalFileName("example-mod.jar"); + external.setHytaleProjectConfirmed(true); + + VersionDependencyService.ResolvedDependencies resolved = + service.resolveRequestedDependencies(List.of(external), false, false); - assertEquals("draft", resolved.dependencies().getFirst().getModId()); + ProjectDependency dependency = resolved.dependencies().getFirst(); + assertTrue(dependency.isExternal()); + assertEquals(ProjectDependency.Source.GITHUB, dependency.getSource()); + assertEquals("example-mod.jar", dependency.getExternalFileName()); + assertEquals("https://raw.githubusercontent.com/modtale/example-mod/main/build/libs/example-mod.jar", dependency.getExternalFileUrl()); + } + + @Test + void resolveRequestedDependenciesRequiresSecureCanonicalCurseForgeUrls() { + DependencyReferenceRequest valid = curseForgeDependency( + "https://www.curseforge.com/hytale/mods/simple-compost/files/8227810" + ); + + ProjectDependency resolved = service.resolveRequestedDependencies(List.of(valid), false, false) + .dependencies().getFirst(); + assertTrue(resolved.isHytaleProjectConfirmed()); + + for (String invalidUrl : List.of( + "http://www.curseforge.com/hytale/mods/simple-compost/files/8227810", + "https://attacker@www.curseforge.com/hytale/mods/simple-compost/files/8227810", + "https://curseforge.com.evil.example/hytale/mods/simple-compost/files/8227810", + "https://www.curseforge.com/minecraft/mc-mods/simple-compost/files/8227810" + )) { + assertThrows(InvalidVersionRequestException.class, () -> + service.resolveRequestedDependencies(List.of(curseForgeDependency(invalidUrl)), false, false)); + } + } + + @Test + void rejectsCurseForgeProjectsInModpacks() { + DependencyReferenceRequest curseForge = curseForgeDependency( + "https://www.curseforge.com/hytale/mods/simple-compost/files/8227810" + ); + when(projectService.getRawProjectById("dep-1")) + .thenReturn(project("dep-1", "Dependency One", ProjectStatus.PUBLISHED, "1.0.0")); + + InvalidVersionRequestException error = assertThrows( + InvalidVersionRequestException.class, + () -> service.resolveRequestedDependencies( + List.of(curseForge, dependency("dep-1", "1.0.0")), true, false) + ); + + assertTrue(error.getMessage().contains("CurseForge")); } @Test @@ -104,4 +216,30 @@ private static Project project(String id, String title, ProjectStatus status, St }).toList()); return project; } + + private static DependencyReferenceRequest dependency(String projectId, String versionNumber) { + return dependency(projectId, versionNumber, ProjectDependency.DependencyType.REQUIRED); + } + + private static DependencyReferenceRequest dependency( + String projectId, + String versionNumber, + ProjectDependency.DependencyType dependencyType + ) { + DependencyReferenceRequest request = new DependencyReferenceRequest(); + request.setProjectId(projectId); + request.setVersionNumber(versionNumber); + request.setDependencyType(dependencyType); + return request; + } + + private static DependencyReferenceRequest curseForgeDependency(String url) { + DependencyReferenceRequest request = new DependencyReferenceRequest(); + request.setSource(ProjectDependency.Source.CURSEFORGE); + request.setProjectTitle("Simple Compost"); + request.setVersionNumber("1.0.0"); + request.setExternalUrl(url); + request.setExternalFileUrl(url); + return request; + } } diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java index 75f9ec3b..ff322eaa 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java @@ -4,6 +4,7 @@ import java.util.List; import net.modtale.config.properties.AppFrontendProperties; import net.modtale.exception.InvalidDownloadTokenException; +import net.modtale.exception.InvalidVersionRequestException; import net.modtale.exception.ResourceNotFoundException; import net.modtale.model.dto.response.project.BundleDownloadUrlResponse; import net.modtale.model.dto.response.project.DownloadUrlResponse; @@ -11,6 +12,7 @@ import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; +import net.modtale.model.project.ModpackTarget; import net.modtale.model.user.User; import net.modtale.service.analytics.AnalyticsEligibilityService; import net.modtale.service.analytics.TrackingService; @@ -26,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -151,6 +154,52 @@ void downloadVersionGeneratesModpackZipAndTracksDependencies() throws Exception verify(trackingService).logDownload("dep-1", null, "author-name", true, "198.51.100.9"); } + @Test + void rejectsBrowserDownloadForModpackContainingCurseForgeProjects() { + User user = new User(); + Project pack = project("pack-1", "Sky Pack!", ProjectClassification.MODPACK); + ProjectVersion version = version("version-1", "1.0.0", null); + version.setDependencies(List.of(ProjectDependency.curseForge( + "1450386", "Simple Compost", "1.0.0", + "https://www.curseforge.com/hytale/mods/simple-compost/files/8227810", + ProjectDependency.DependencyType.REQUIRED + ))); + + when(projectService.getProjectById("pack-1", user)).thenReturn(pack); + when(projectVersionAccessService.requireByVersionNumber( + org.mockito.Mockito.eq(pack), org.mockito.Mockito.eq("1.0.0"), + org.mockito.Mockito.isNull(), org.mockito.Mockito.any() + )).thenReturn(version); + + InvalidVersionRequestException error = assertThrows( + InvalidVersionRequestException.class, + () -> service.createDownloadUrl("pack-1", "1.0.0", null, user) + ); + + assertTrue(error.getMessage().contains("Modtale Launcher")); + verify(downloadTokenService, never()).generateToken("pack-1", "1.0.0", null); + } + + @Test + void downloadVersionGeneratesNamedServerVariantFromToken() throws Exception { + User user = new User(); + Project pack = project("pack-1", "Sky Pack!", ProjectClassification.MODPACK); + ProjectVersion version = version("version-1", "1.0.0", "modpacks/pack.zip"); + when(downloadTokenService.validateAndConsume("server-token")).thenReturn(new DownloadTokenService.DownloadToken( + "pack-1", "1.0.0", null, null, ModpackTarget.SERVER, Instant.now().plusSeconds(60) + )); + when(projectService.getRawProjectById("pack-1")).thenReturn(pack); + when(accessControlService.canReadProject(pack, user)).thenReturn(true); + when(projectVersionAccessService.requireByVersionNumber(org.mockito.Mockito.eq(pack), org.mockito.Mockito.eq("1.0.0"), org.mockito.Mockito.isNull(), org.mockito.Mockito.any())) + .thenReturn(version); + when(downloadService.generateModpackZip(pack, version, user, ModpackTarget.SERVER)).thenReturn(new byte[]{7}); + + VersionDownloadPayload payload = service.downloadVersion("server-token", true, null, "198.51.100.9", null, user); + + assertEquals("Sky_Pack_-1.0.0-server.zip", payload.filename()); + assertArrayEquals(new byte[]{7}, payload.bytes()); + } + @Test void downloadBundleTracksOnlySelectedNonEmbeddedDependenciesAndReturnsZipName() throws Exception { User user = new User(); @@ -159,7 +208,7 @@ void downloadBundleTracksOnlySelectedNonEmbeddedDependenciesAndReturnsZipName() version.setDependencies(List.of( new ProjectDependency("dep-1", "Dependency One", "1.0.0"), new ProjectDependency("dep-2", "Dependency Two", "1.0.0"), - new ProjectDependency("embedded", "Embedded", "1.0.0", false, true) + new ProjectDependency("embedded", "Embedded", "1.0.0", ProjectDependency.DependencyType.EMBEDDED) )); Project dependencyProject = project("dep-1", "Dependency One", ProjectClassification.DATA); diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionManifestMatchingServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionManifestMatchingServiceTest.java index 163ec421..70bf9e87 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionManifestMatchingServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionManifestMatchingServiceTest.java @@ -58,7 +58,7 @@ void suggestDependenciesPicksBestCandidateAndExactRequestedVersion() { assertEquals("com.example:sky-library", suggestion.getManifestKey()); assertEquals("project-1", suggestion.getProjectId()); assertEquals("2.0.0", suggestion.getVersionNumber()); - assertEquals("project-1:2.0.0:optional", suggestion.getDependencyEntry()); + assertTrue(suggestion.isOptional()); assertEquals(100, suggestion.getConfidence()); } @@ -75,7 +75,7 @@ void suggestDependenciesUsesLatestReleaseDateWhenRequestedVersionIsRangeOrWildca ); assertEquals("1.1.0", suggestions.getFirst().getVersionNumber()); - assertEquals("project-1:1.1.0", suggestions.getFirst().getDependencyEntry()); + assertEquals("project-1", suggestions.getFirst().getProjectId()); } @Test diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java index f7568d11..82827353 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java @@ -4,6 +4,7 @@ import java.util.List; import net.modtale.config.properties.AppLimitProperties; import net.modtale.exception.InvalidVersionRequestException; +import net.modtale.model.dto.request.project.DependencyReferenceRequest; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectDependency; @@ -128,7 +129,8 @@ void addVersionQueuesInitialScanForNonModpackArtifacts() throws Exception { when(versionArtifactService.prepareVersionArtifact(project, file)) .thenReturn(new VersionArtifactService.PreparedVersionArtifact(ProjectClassification.DATA, "/files/data/bundle.zip", "sha-256")); when(scanService.createQueuedScanResult(1, "Initial scan queued.")).thenReturn(queuedScan); - when(versionDependencyService.resolveRequestedDependencies(List.of("dep-1:2.0.0"), false, false)) + DependencyReferenceRequest dependency = dependency("dep-1", "2.0.0"); + when(versionDependencyService.resolveRequestedDependencies(List.of(dependency), false, false)) .thenReturn(new VersionDependencyService.ResolvedDependencies(List.of(), List.of("dep-1"))); service.addVersion( @@ -137,7 +139,7 @@ void addVersionQueuesInitialScanForNonModpackArtifacts() throws Exception { List.of("1.21.0"), file, "Release notes", - List.of("dep-1:2.0.0"), + List.of(dependency), List.of(), ProjectVersion.Channel.RELEASE, false, @@ -348,7 +350,7 @@ void updateVersionClearsCachedModpackArchivesAndRefreshesLatestDependencyIds() { project.setId("project-1"); project.setStatus(ProjectStatus.PUBLISHED); project.setClassification(ProjectClassification.MODPACK); - project.setModIds(new ArrayList<>(List.of("old-dep"))); + project.setChildProjectIds(new ArrayList<>(List.of("old-dep"))); ProjectVersion version = new ProjectVersion(); version.setId("version-1"); @@ -361,7 +363,8 @@ void updateVersionClearsCachedModpackArchivesAndRefreshesLatestDependencyIds() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "VERSION_EDIT")).thenReturn(true); - when(versionDependencyService.resolveRequestedDependencies(List.of("new-dep:2.0.0"), true, true)) + DependencyReferenceRequest dependency = dependency("new-dep", "2.0.0"); + when(versionDependencyService.resolveRequestedDependencies(List.of(dependency), true, true)) .thenReturn(new VersionDependencyService.ResolvedDependencies( List.of(new ProjectDependency("new-dep", "New Dependency", "2.0.0")), List.of("new-dep") @@ -370,7 +373,7 @@ void updateVersionClearsCachedModpackArchivesAndRefreshesLatestDependencyIds() { service.updateVersion( "project-1", "version-1", - List.of("new-dep:2.0.0"), + List.of(dependency), List.of(), null, null, @@ -379,8 +382,8 @@ void updateVersionClearsCachedModpackArchivesAndRefreshesLatestDependencyIds() { ); assertNull(version.getFileUrl()); - assertEquals(List.of("new-dep"), project.getModIds()); - assertEquals("new-dep", version.getDependencies().getFirst().getModId()); + assertEquals(List.of("new-dep"), project.getChildProjectIds()); + assertEquals("new-dep", version.getDependencies().getFirst().getProjectId()); verify(projectDeletionService).deleteStoredFile("modpacks/sky-pack-1.0.0.zip"); verify(projectRepository).save(project); verify(projectService).evictProjectCache(project); @@ -431,4 +434,11 @@ void deleteVersionStillProtectsTheLastVersionForPublishedProjects() { assertThrows(InvalidVersionRequestException.class, () -> service.deleteVersion("project-1", "version-1", user)); verify(projectDeletionService, never()).deleteVersionFile(version); } + + private static DependencyReferenceRequest dependency(String projectId, String versionNumber) { + DependencyReferenceRequest request = new DependencyReferenceRequest(); + request.setProjectId(projectId); + request.setVersionNumber(versionNumber); + return request; + } } diff --git a/backend/src/test/java/net/modtale/service/storage/BundlePackagingServiceTest.java b/backend/src/test/java/net/modtale/service/storage/BundlePackagingServiceTest.java index 7b8f642e..e9ad1d74 100644 --- a/backend/src/test/java/net/modtale/service/storage/BundlePackagingServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/BundlePackagingServiceTest.java @@ -38,7 +38,7 @@ void generateBundleZipIncludesMainFileAndOnlySelectedNonEmbeddedDependencies() t mainVersion.setDependencies(List.of( new ProjectDependency("dep-1", "Dependency One", "1.0.0"), new ProjectDependency("dep-2", "Dependency Two", "1.0.0"), - new ProjectDependency("embedded", "Embedded", "1.0.0", false, true) + new ProjectDependency("embedded", "Embedded", "1.0.0", ProjectDependency.DependencyType.EMBEDDED) )); ProjectVersion depVersion = new ProjectVersion(); depVersion.setFileUrl("files/dep-one.jar"); diff --git a/backend/src/test/java/net/modtale/service/storage/CurseForgeModpackFlowTest.java b/backend/src/test/java/net/modtale/service/storage/CurseForgeModpackFlowTest.java new file mode 100644 index 00000000..5b058af0 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/storage/CurseForgeModpackFlowTest.java @@ -0,0 +1,147 @@ +package net.modtale.service.storage; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import net.modtale.model.dto.request.project.DependencyReferenceRequest; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; +import net.modtale.model.project.ProjectStatus; +import net.modtale.model.project.ProjectVersion; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.project.version.ExternalDependencyArtifactService; +import net.modtale.service.project.version.VersionDependencyService; +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CurseForgeModpackFlowTest { + + @Test + void legacyCurseForgePackDataCanOnlyBePackagedAsAnAttributableReference() throws Exception { + ProjectService projectService = mock(ProjectService.class); + Project hostedProject = hostedProject(); + when(projectService.getRawProjectById("hosted-mod")).thenReturn(hostedProject); + + VersionDependencyService dependencyService = new VersionDependencyService(projectService); + VersionDependencyService.ResolvedDependencies resolved = dependencyService.resolveRequestedDependencies( + List.of(hostedReference(), curseForgeReference()), + false, + false + ); + new ExternalDependencyArtifactService().prepareExternalArtifacts(resolved.dependencies()); + + ProjectDependency curseForge = resolved.dependencies().get(1); + assertEquals(ProjectDependency.Source.CURSEFORGE, curseForge.getSource()); + assertEquals("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810", curseForge.getExternalFileUrl()); + assertNull(curseForge.getCachedFileUrl()); + curseForge.setExternalFileSize(4096L); + curseForge.setExternalFileHashes(Map.of("sha1", "a".repeat(40), "md5", "b".repeat(32))); + curseForge.setExternalGameVersions(List.of("2026.09")); + curseForge.setExternalFileStatus(4); + curseForge.setExternalDistributionAllowed(false); + + ProjectRepository projectRepository = mock(ProjectRepository.class); + DownloadArchiveSupport archiveSupport = mock(DownloadArchiveSupport.class); + ProjectVersion packVersion = new ProjectVersion(); + packVersion.setId("pack-version-1"); + packVersion.setVersionNumber("2.0.0"); + packVersion.setDependencies(resolved.dependencies()); + Project pack = new Project(); + pack.setId("pack-1"); + pack.setSlug("test-pack"); + pack.setTitle("Test Pack"); + pack.setClassification(ProjectClassification.MODPACK); + + ProjectVersion hostedVersion = hostedProject.getVersions().getFirst(); + when(archiveSupport.resolveDependency(resolved.dependencies().getFirst())) + .thenReturn(new DownloadArchiveSupport.ResolvedDependency(hostedProject, hostedVersion)); + when(archiveSupport.download("files/hosted.jar")).thenReturn("hosted-binary".getBytes(StandardCharsets.UTF_8)); + when(archiveSupport.extractOriginalFilename("files/hosted.jar")).thenReturn("hosted.jar"); + when(archiveSupport.newZipMultipartFile(eq("test-pack-2.0.0.zip"), any())) + .thenAnswer(invocation -> mock(MultipartFile.class)); + when(archiveSupport.upload(any(MultipartFile.class), eq("modpacks"))).thenReturn("modpacks/test-pack.zip"); + + byte[] archive = new ModpackArchiveService(projectRepository, archiveSupport).generateModpackZip(pack, packVersion); + Map entries = unzip(archive); + JsonNode manifest = new ObjectMapper().readTree(entries.get("modpack.json")); + JsonNode lock = new ObjectMapper().readTree(entries.get("modtale.lock.json")); + + assertEquals("hosted-binary", new String(entries.get("hosted.jar"), StandardCharsets.UTF_8)); + assertFalse(entries.containsKey("SimpleCompost-1.0.0.jar")); + JsonNode externalEntry = manifest.get("files").get(1); + assertEquals("CURSEFORGE", externalEntry.get("source").asText()); + assertEquals("REFERENCE_ONLY", externalEntry.get("distribution").asText()); + assertEquals("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810", externalEntry.get("url").asText()); + assertEquals("BUNDLED", lock.at("/entries/0/distribution").asText()); + assertEquals("hosted.jar", lock.at("/entries/0/path").asText()); + assertEquals(64, lock.at("/entries/0/hashes/sha256").asText().length()); + assertEquals("REFERENCE_ONLY", lock.at("/entries/1/distribution").asText()); + assertEquals("1450386", lock.at("/entries/1/provider/projectId").asText()); + assertEquals("8227810", lock.at("/entries/1/provider/fileId").asText()); + assertEquals(4096L, lock.at("/entries/1/provider/fileSize").asLong()); + assertEquals("a".repeat(40), lock.at("/entries/1/provider/hashes/sha1").asText()); + assertEquals("2026.09", lock.at("/entries/1/provider/gameVersions/0").asText()); + assertFalse(lock.at("/entries/1/provider/distributionAllowed").asBoolean()); + } + + private static DependencyReferenceRequest hostedReference() { + DependencyReferenceRequest request = new DependencyReferenceRequest(); + request.setProjectId("hosted-mod"); + request.setVersionNumber("1.0.0"); + request.setSource(ProjectDependency.Source.MODTALE); + return request; + } + + private static DependencyReferenceRequest curseForgeReference() { + DependencyReferenceRequest request = new DependencyReferenceRequest(); + request.setSource(ProjectDependency.Source.CURSEFORGE); + request.setExternalId("1450386"); + request.setProjectTitle("Simple Compost"); + request.setVersionNumber("1.0.0"); + request.setExternalUrl("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810"); + request.setExternalFileUrl("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810"); + request.setExternalFileName("SimpleCompost-1.0.0.jar"); + request.setHytaleProjectConfirmed(true); + return request; + } + + private static Project hostedProject() { + ProjectVersion version = new ProjectVersion(); + version.setVersionNumber("1.0.0"); + version.setFileUrl("files/hosted.jar"); + Project project = new Project(); + project.setId("hosted-mod"); + project.setTitle("Hosted Mod"); + project.setClassification(ProjectClassification.PLUGIN); + project.setStatus(ProjectStatus.PUBLISHED); + project.setVersions(List.of(version)); + return project; + } + + private static Map unzip(byte[] archive) throws Exception { + Map entries = new LinkedHashMap<>(); + try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(archive))) { + ZipEntry entry; + while ((entry = input.getNextEntry()) != null) { + entries.put(entry.getName(), input.readAllBytes()); + } + } + return entries; + } +} diff --git a/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java b/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java index 3897a90f..492c995f 100644 --- a/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java @@ -1,6 +1,9 @@ package net.modtale.service.storage; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; @@ -37,6 +40,8 @@ class DownloadServiceTest { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private DownloadService downloadService; private ProjectRepository projectRepository; private ProjectService projectService; @@ -57,11 +62,12 @@ void generateModpackZipReturnsTheStoredArchiveWhenItAlreadyExists() throws Excep version.setFileUrl("modpacks/already-built.zip"); User user = user("user-1"); - when(storageService.download("modpacks/already-built.zip")).thenReturn(new byte[]{1, 2, 3}); + byte[] cachedArchive = validEmptyArchive(); + when(storageService.download("modpacks/already-built.zip")).thenReturn(cachedArchive); byte[] zipBytes = downloadService.generateModpackZip(pack, version, user); - assertArrayEquals(new byte[]{1, 2, 3}, zipBytes); + assertArrayEquals(cachedArchive, zipBytes); verify(storageService).download("modpacks/already-built.zip"); verifyNoInteractions(projectService, projectRepository); } @@ -94,10 +100,11 @@ void generateModpackZipBuildsAndUploadsANewArchiveWhenTheCachedFileIsMissing() t Map entries = unzip(zipBytes); assertTrue(entries.containsKey("modpack.json")); - assertTrue(entries.get("modpack.json").contains("\"id\": \"plugin-1\"")); - assertTrue(entries.get("modpack.json").contains("\"version\": \"3.0.0\"")); - assertEquals("plugin-binary", entries.get("plugins/plugin.jar")); - assertEquals("asset-binary", entries.get("asset-packs/assets.zip")); + JsonNode legacyManifest = OBJECT_MAPPER.readTree(entries.get("modpack.json")); + assertEquals("plugin-1", legacyManifest.path("files").get(0).path("id").asText()); + assertEquals("3.0.0", legacyManifest.path("files").get(1).path("version").asText()); + assertEquals("plugin-binary", entries.get("plugin.jar")); + assertEquals("asset-binary", entries.get("assets.zip")); assertEquals("modpacks/generated.zip", version.getFileUrl()); ArgumentCaptor uploadCaptor = ArgumentCaptor.forClass(MultipartFile.class); @@ -132,7 +139,7 @@ void generateBundleZipIncludesTheMainFileAndOnlySelectedDependencies() throws Ex mainVersion.setDependencies(List.of( new ProjectDependency("dep-a", "Dependency A", "1.0.0"), new ProjectDependency("dep-b", "Dependency B", "2.0.0"), - new ProjectDependency("dep-c", "Dependency C", "3.0.0", false, true) + new ProjectDependency("dep-c", "Dependency C", "3.0.0", ProjectDependency.DependencyType.EMBEDDED) )); Project dependencyB = dependencyProject("dep-b", ProjectClassification.DATA, "2.0.0", "files/123456789012345678901234567890123456-depb.jar"); @@ -202,4 +209,21 @@ private static Map unzip(byte[] zipBytes) throws IOException { } return entries; } + + private static byte[] validEmptyArchive() throws IOException { + try (ByteArrayOutputStream output = new ByteArrayOutputStream(); + java.util.zip.ZipOutputStream zip = new java.util.zip.ZipOutputStream(output)) { + writeEntry(zip, "modpack.json", "{\"formatVersion\":1,\"game\":\"hytale\",\"files\":[]}"); + writeEntry(zip, "manifest.json", "{\"format\":\"modtale-pack\",\"schemaVersion\":1,\"pack\":{},\"game\":{},\"dependencies\":[]}"); + writeEntry(zip, "modtale.lock.json", "{\"format\":\"modtale-lock\",\"lockVersion\":1,\"pack\":{},\"gameVersions\":[],\"entries\":[]}"); + zip.finish(); + return output.toByteArray(); + } + } + + private static void writeEntry(java.util.zip.ZipOutputStream zip, String name, String value) throws IOException { + zip.putNextEntry(new ZipEntry(name)); + zip.write(value.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } } diff --git a/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java b/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java index 3a67a8de..b752a35e 100644 --- a/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java @@ -3,6 +3,7 @@ import java.time.Instant; import java.util.List; import java.util.Map; +import net.modtale.model.project.ModpackTarget; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -41,6 +42,18 @@ void generateTokenStoresPayloadAndConsumesItOnce() { assertEquals(0, downloadTokenService.getActiveTokenCount()); } + @Test + void generateTokenPreservesModpackTarget() { + String token = downloadTokenService.generateToken( + "pack-1", "1.2.3", "2026.9", null, ModpackTarget.SERVER + ); + + DownloadTokenService.DownloadToken result = downloadTokenService.validateAndConsume(token); + + assertNotNull(result); + assertEquals(ModpackTarget.SERVER, result.getModpackTarget()); + } + @Test void generateTokenOverloadsCreateDistinctTokens() { String first = downloadTokenService.generateToken("project-1", "1.0.0"); diff --git a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java index d2a7e94e..ea5f222b 100644 --- a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java @@ -1,6 +1,9 @@ package net.modtale.service.storage; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; @@ -8,11 +11,13 @@ import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; import net.modtale.exception.StorageDownloadException; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; +import net.modtale.model.project.ModpackTarget; import net.modtale.repository.project.ProjectRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -20,6 +25,9 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -45,19 +53,261 @@ void generateModpackZipReturnsCachedArchiveWhenDownloadSucceeds() throws Excepti Project pack = pack(); ProjectVersion version = version("1.0.0", "modpacks/cached.zip"); - when(archiveSupport.download("modpacks/cached.zip")).thenReturn(new byte[]{1, 2, 3}); + byte[] cached = validEmptyArchive(); + when(archiveSupport.download("modpacks/cached.zip")).thenReturn(cached); - assertArrayEquals(new byte[]{1, 2, 3}, service.generateModpackZip(pack, version)); + assertArrayEquals(cached, service.generateModpackZip(pack, version)); verify(projectRepository, never()).save(pack); } @Test - void generateModpackZipRebuildsCachesAndOrganizesDependencyFilesByClassification() throws Exception { + void generateModpackZipRebuildsLegacyArchivesThatPredateIntegrityLockfiles() throws Exception { + Project pack = pack(); + ProjectVersion version = version("1.0.0", "modpacks/legacy.zip"); + + when(archiveSupport.download("modpacks/legacy.zip")).thenReturn(zip(Map.of( + "modpack.json", "{\"formatVersion\":1,\"game\":\"hytale\",\"files\":[]}" + ))); + when(archiveSupport.newZipMultipartFile(eq("sky-pack-1.0.0.zip"), any())) + .thenAnswer(invocation -> mock(MultipartFile.class)); + when(archiveSupport.upload(any(MultipartFile.class), eq("modpacks"))).thenReturn("modpacks/rebuilt.zip"); + + Map entries = unzip(service.generateModpackZip(pack, version)); + + assertTrue(entries.containsKey("manifest.json")); + assertTrue(entries.containsKey("modtale.lock.json")); + assertEquals("modpacks/rebuilt.zip", version.getFileUrl()); + verify(projectRepository).save(pack); + } + + @Test + void generateModpackZipRebuildsAnEmptyCachedArchive() throws Exception { + Project pack = pack(); + ProjectVersion version = version("1.0.0", "modpacks/empty.zip"); + + when(archiveSupport.download("modpacks/empty.zip")).thenReturn(new byte[0]); + when(archiveSupport.newZipMultipartFile(eq("sky-pack-1.0.0.zip"), any())).thenAnswer(invocation -> mock(MultipartFile.class)); + when(archiveSupport.upload(any(MultipartFile.class), eq("modpacks"))).thenReturn("modpacks/rebuilt.zip"); + + Map entries = unzip(service.generateModpackZip(pack, version)); + + assertEquals(true, entries.containsKey("modpack.json")); + assertEquals("modpacks/rebuilt.zip", version.getFileUrl()); + verify(projectRepository).save(pack); + } + + @Test + void generateModpackZipWritesValidJsonForTitlesWithControlCharacters() throws Exception { + Project pack = pack(); + pack.setTitle("Sky \"Pack\"\nNight\tBuild"); + ProjectVersion version = version("1.0.0", null); + ProjectDependency dependency = new ProjectDependency("plugin", "Plugin\nDeluxe", "2.0.0"); + version.setDependencies(List.of(dependency)); + Project plugin = dependencyProject("plugin", ProjectClassification.PLUGIN); + ProjectVersion pluginVersion = version("2.0.0", "files/plugin.jar"); + + when(archiveSupport.resolveDependency(dependency)) + .thenReturn(new DownloadArchiveSupport.ResolvedDependency(plugin, pluginVersion)); + when(archiveSupport.download("files/plugin.jar")).thenReturn(bytes("plugin-binary")); + when(archiveSupport.extractOriginalFilename("files/plugin.jar")).thenReturn("plugin.jar"); + when(archiveSupport.newZipMultipartFile(eq("sky-pack-1.0.0.zip"), any())).thenAnswer(invocation -> mock(MultipartFile.class)); + when(archiveSupport.upload(any(MultipartFile.class), eq("modpacks"))).thenReturn("modpacks/generated.zip"); + + String manifest = unzip(service.generateModpackZip(pack, version)).get("modpack.json"); + JsonNode parsed = new ObjectMapper().readTree(manifest); + + assertEquals("Sky \"Pack\"\nNight\tBuild", parsed.get("name").asText()); + assertEquals(1, parsed.get("formatVersion").asInt()); + assertEquals("hytale", parsed.get("game").asText()); + assertEquals("pack-1", parsed.get("packId").asText()); + assertEquals("1.0.0", parsed.get("versionNumber").asText()); + assertEquals("Plugin\nDeluxe", parsed.get("files").get(0).get("title").asText()); + } + + @Test + void generateModpackZipWritesExactIntegrityLockfileAndSeparateIntentManifest() throws Exception { + Project pack = pack(); + ProjectVersion version = version("1.0.0", null); + version.setId("version-1"); + version.setGameVersions(List.of("2026.8", "2026.9")); + ProjectDependency hosted = new ProjectDependency("plugin", "Plugin", "2.0.0"); + ProjectDependency curseForge = ProjectDependency.curseForge( + "1450386", + "External Mod", + "1.0.0", + "https://www.curseforge.com/hytale/mods/external-mod/files/8227810", + ProjectDependency.DependencyType.OPTIONAL + ); + curseForge.setExternalFileUrl("https://www.curseforge.com/hytale/mods/external-mod/files/8227810"); + curseForge.setEnvironment(ProjectDependency.Environment.CLIENT); + version.setDependencies(List.of(hosted, curseForge)); + + Project hostedProject = dependencyProject("plugin", ProjectClassification.PLUGIN); + ProjectVersion hostedVersion = version("2.0.0", "files/plugin.jar"); + when(archiveSupport.resolveDependency(hosted)) + .thenReturn(new DownloadArchiveSupport.ResolvedDependency(hostedProject, hostedVersion)); + when(archiveSupport.download("files/plugin.jar")).thenReturn(bytes("plugin-binary")); + when(archiveSupport.extractOriginalFilename("files/plugin.jar")).thenReturn("plugin.jar"); + when(archiveSupport.newZipMultipartFile(eq("sky-pack-1.0.0.zip"), any())) + .thenAnswer(invocation -> mock(MultipartFile.class)); + when(archiveSupport.upload(any(MultipartFile.class), eq("modpacks"))).thenReturn("modpacks/generated.zip"); + + Map entries = unzip(service.generateModpackZip(pack, version)); + JsonNode manifest = new ObjectMapper().readTree(entries.get("manifest.json")); + JsonNode lock = new ObjectMapper().readTree(entries.get("modtale.lock.json")); + + assertEquals("modtale-pack", manifest.get("format").asText()); + assertEquals(List.of("2026.8", "2026.9"), + new ObjectMapper().convertValue(manifest.at("/game/versions"), List.class)); + assertFalse(manifest.toString().contains("cachedFileUrl")); + assertEquals("modtale-lock", lock.get("format").asText()); + assertEquals("BUNDLED", lock.at("/entries/0/distribution").asText()); + assertEquals("plugin.jar", lock.at("/entries/0/path").asText()); + assertEquals(bytes("plugin-binary").length, lock.at("/entries/0/size").asInt()); + assertEquals(64, lock.at("/entries/0/hashes/sha256").asText().length()); + assertEquals("REFERENCE_ONLY", lock.at("/entries/1/distribution").asText()); + assertEquals("1450386", lock.at("/entries/1/provider/projectId").asText()); + assertEquals("8227810", lock.at("/entries/1/provider/fileId").asText()); + assertEquals("OPTIONAL", lock.at("/entries/1/dependencyType").asText()); + assertEquals("CLIENT", lock.at("/entries/1/environment").asText()); + assertFalse(entries.containsKey("External-Mod-1.0.0.jar")); + } + + @Test + void generateModpackZipIsByteForByteDeterministicForTheSameInputs() throws Exception { + Project pack = pack(); + ProjectVersion version = version("1.0.0", null); + version.setId("version-1"); + ProjectDependency dependency = new ProjectDependency("plugin", "Plugin", "2.0.0"); + version.setDependencies(List.of(dependency)); + Project plugin = dependencyProject("plugin", ProjectClassification.PLUGIN); + ProjectVersion pluginVersion = version("2.0.0", "files/plugin.jar"); + + when(archiveSupport.resolveDependency(dependency)) + .thenReturn(new DownloadArchiveSupport.ResolvedDependency(plugin, pluginVersion)); + when(archiveSupport.download("files/plugin.jar")).thenReturn(bytes("plugin-binary")); + when(archiveSupport.extractOriginalFilename("files/plugin.jar")).thenReturn("plugin.jar"); + when(archiveSupport.newZipMultipartFile(eq("sky-pack-1.0.0.zip"), any())) + .thenAnswer(invocation -> mock(MultipartFile.class)); + when(archiveSupport.upload(any(MultipartFile.class), eq("modpacks"))).thenReturn("modpacks/generated.zip"); + + byte[] first = service.generateModpackZip(pack, version); + version.setFileUrl(null); + byte[] second = service.generateModpackZip(pack, version); + + assertArrayEquals(first, second); + } + + @Test + void generateModpackZipFiltersClientAndServerVariantsWithoutCachingThem() throws Exception { + Project pack = pack(); + ProjectVersion version = version("1.0.0", "modpacks/universal.zip"); + ProjectDependency common = new ProjectDependency("common", "Common", "1.0.0"); + ProjectDependency client = new ProjectDependency("client", "Client", "1.0.0"); + client.setEnvironment(ProjectDependency.Environment.CLIENT); + ProjectDependency server = new ProjectDependency("server", "Server", "1.0.0"); + server.setEnvironment(ProjectDependency.Environment.SERVER); + version.setDependencies(List.of(common, client, server)); + version.setOverrideFileUrl("modpack-overrides/source.zip"); + when(archiveSupport.download("modpack-overrides/source.zip")).thenReturn(zip(Map.of( + "overrides/common/config.json", "common", + "overrides/client/ui.toml", "client", + "overrides/server/server.properties", "server" + ))); + + for (ProjectDependency dependency : version.getDependencies()) { + ProjectVersion dependencyVersion = version("1.0.0", "files/" + dependency.getProjectId() + ".jar"); + when(archiveSupport.resolveDependency(dependency)).thenReturn(new DownloadArchiveSupport.ResolvedDependency( + dependencyProject(dependency.getProjectId(), ProjectClassification.PLUGIN), dependencyVersion + )); + when(archiveSupport.download(dependencyVersion.getFileUrl())).thenReturn(bytes(dependency.getProjectId())); + when(archiveSupport.extractOriginalFilename(dependencyVersion.getFileUrl())).thenReturn(dependency.getProjectId() + ".jar"); + } + + Map clientEntries = unzip(service.generateModpackZip(pack, version, ModpackTarget.CLIENT)); + JsonNode clientLock = new ObjectMapper().readTree(clientEntries.get("modtale.lock.json")); + Map serverEntries = unzip(service.generateModpackZip(pack, version, ModpackTarget.SERVER)); + JsonNode serverLock = new ObjectMapper().readTree(serverEntries.get("modtale.lock.json")); + + assertEquals("CLIENT", clientLock.get("target").asText()); + assertTrue(clientEntries.containsKey("common.jar")); + assertTrue(clientEntries.containsKey("client.jar")); + assertFalse(clientEntries.containsKey("server.jar")); + assertTrue(clientEntries.containsKey("overrides/common/config.json")); + assertTrue(clientEntries.containsKey("overrides/client/ui.toml")); + assertFalse(clientEntries.containsKey("overrides/server/server.properties")); + assertEquals(2, clientLock.path("overrides").size()); + assertEquals("SERVER", serverLock.get("target").asText()); + assertTrue(serverEntries.containsKey("common.jar")); + assertFalse(serverEntries.containsKey("client.jar")); + assertTrue(serverEntries.containsKey("server.jar")); + assertTrue(serverEntries.containsKey("overrides/common/config.json")); + assertFalse(serverEntries.containsKey("overrides/client/ui.toml")); + assertTrue(serverEntries.containsKey("overrides/server/server.properties")); + assertEquals("modpacks/universal.zip", version.getFileUrl()); + verify(projectRepository, never()).save(pack); + } + + @Test + void generateModpackZipPreventsCaseInsensitiveAndTraversalFilenameCollisions() throws Exception { + Project pack = pack(); + ProjectVersion version = version("1.0.0", null); + ProjectDependency first = new ProjectDependency("first", "First", "1.0.0"); + ProjectDependency second = new ProjectDependency("second", "Second", "1.0.0"); + version.setDependencies(List.of(first, second)); + Project firstProject = dependencyProject("first", ProjectClassification.PLUGIN); + Project secondProject = dependencyProject("second", ProjectClassification.PLUGIN); + ProjectVersion firstVersion = version("1.0.0", "files/first"); + ProjectVersion secondVersion = version("1.0.0", "files/second"); + + when(archiveSupport.resolveDependency(first)) + .thenReturn(new DownloadArchiveSupport.ResolvedDependency(firstProject, firstVersion)); + when(archiveSupport.resolveDependency(second)) + .thenReturn(new DownloadArchiveSupport.ResolvedDependency(secondProject, secondVersion)); + when(archiveSupport.download("files/first")).thenReturn(bytes("first")); + when(archiveSupport.download("files/second")).thenReturn(bytes("second")); + when(archiveSupport.extractOriginalFilename("files/first")).thenReturn("../../Plugin.jar"); + when(archiveSupport.extractOriginalFilename("files/second")).thenReturn("plugin.JAR"); + when(archiveSupport.newZipMultipartFile(eq("sky-pack-1.0.0.zip"), any())) + .thenAnswer(invocation -> mock(MultipartFile.class)); + + Map entries = unzip(service.generateModpackZip(pack, version)); + + assertTrue(entries.containsKey("Plugin.jar")); + assertTrue(entries.containsKey("plugin-2.JAR")); + assertTrue(entries.keySet().stream().noneMatch(name -> name.contains("/") || name.contains("\\"))); + } + + @Test + void generateModpackZipFailsInsteadOfSilentlyOmittingAnUnresolvedHostedDependency() { + Project pack = pack(); + ProjectVersion version = version("1.0.0", null); + ProjectDependency dependency = new ProjectDependency("missing", "Missing", "1.0.0"); + version.setDependencies(List.of(dependency)); + + IOException error = assertThrows(IOException.class, () -> service.generateModpackZip(pack, version)); + + assertTrue(error.getMessage().contains("Cannot resolve bundled Modtale dependency Missing")); + verify(archiveSupport, never()).upload(any(MultipartFile.class), eq("modpacks")); + } + + @Test + void generateModpackZipRebuildsCachesAndWritesDependencyFilesAtArchiveRoot() throws Exception { Project pack = pack(); ProjectVersion version = version("1.0.0", "modpacks/missing.zip"); + ProjectDependency externalDependency = ProjectDependency.curseForge( + "1450386", + "External Mod", + "1.0.0", + "https://www.curseforge.com/hytale/mods/external-mod/files/8227810", + ProjectDependency.DependencyType.REQUIRED + ); + externalDependency.setExternalFileName("External-Mod-1.0.0.jar"); + externalDependency.setCachedFileUrl("external-dependencies/curseforge/1450386/8227810/External-Mod-1.0.0.jar"); version.setDependencies(List.of( new ProjectDependency("plugin", "Plugin", "2.0.0"), - new ProjectDependency("data", "Data", "3.0.0") + new ProjectDependency("data", "Data", "3.0.0"), + externalDependency )); Project plugin = dependencyProject("plugin", ProjectClassification.PLUGIN); Project data = dependencyProject("data", ProjectClassification.DATA); @@ -72,6 +322,7 @@ void generateModpackZipRebuildsCachesAndOrganizesDependencyFilesByClassification .thenReturn(new DownloadArchiveSupport.ResolvedDependency(data, dataVersion)); when(archiveSupport.download("files/plugin.jar")).thenReturn(bytes("plugin-binary")); when(archiveSupport.download("files/data.zip")).thenReturn(bytes("data-binary")); + when(archiveSupport.download("external-dependencies/curseforge/1450386/8227810/External-Mod-1.0.0.jar")).thenReturn(bytes("external-binary")); when(archiveSupport.extractOriginalFilename("files/plugin.jar")).thenReturn("plugin.jar"); when(archiveSupport.extractOriginalFilename("files/data.zip")).thenReturn("data.zip"); when(archiveSupport.newZipMultipartFile(eq("sky-pack-1.0.0.zip"), any())).thenAnswer(invocation -> mock(MultipartFile.class)); @@ -80,9 +331,15 @@ void generateModpackZipRebuildsCachesAndOrganizesDependencyFilesByClassification Map entries = unzip(service.generateModpackZip(pack, version)); assertEquals("modpacks/generated.zip", version.getFileUrl()); - assertEquals("plugin-binary", entries.get("plugins/plugin.jar")); - assertEquals("data-binary", entries.get("asset-packs/data.zip")); - assertEquals(true, entries.get("modpack.json").contains("\"id\": \"plugin\"")); + assertEquals("plugin-binary", entries.get("plugin.jar")); + assertEquals("data-binary", entries.get("data.zip")); + assertFalse(entries.containsKey("External-Mod-1.0.0.jar")); + assertTrue(entries.containsKey("manifest.json")); + assertTrue(entries.containsKey("modtale.lock.json")); + assertTrue(entries.get("modpack.json").contains("\"id\" : \"plugin\"")); + assertTrue(entries.get("modpack.json").contains("\"externalId\" : \"1450386\"")); + assertTrue(entries.get("modpack.json").contains("\"distribution\" : \"REFERENCE_ONLY\"")); + assertTrue(entries.get("modpack.json").contains("https://www.curseforge.com/hytale/mods/external-mod/files/8227810")); verify(projectRepository).save(pack); } @@ -113,6 +370,28 @@ private static byte[] bytes(String value) { return value.getBytes(StandardCharsets.UTF_8); } + private static byte[] validEmptyArchive() throws IOException { + return zip(Map.of( + "modpack.json", "{\"formatVersion\":1,\"game\":\"hytale\",\"files\":[]}", + "manifest.json", "{\"format\":\"modtale-pack\",\"schemaVersion\":1,\"pack\":{},\"game\":{},\"dependencies\":[]}", + "modtale.lock.json", "{\"format\":\"modtale-lock\",\"lockVersion\":1,\"pack\":{},\"gameVersions\":[],\"entries\":[]}" + )); + } + + private static byte[] zip(Map entries) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + for (Map.Entry value : entries.entrySet()) { + ZipEntry entry = new ZipEntry(value.getKey()); + entry.setTime(0); + zip.putNextEntry(entry); + zip.write(value.getValue().getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + return output.toByteArray(); + } + private static Map unzip(byte[] zipBytes) throws IOException { Map entries = new LinkedHashMap<>(); try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { diff --git a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveValidatorTest.java b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveValidatorTest.java new file mode 100644 index 00000000..fb2250ad --- /dev/null +++ b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveValidatorTest.java @@ -0,0 +1,287 @@ +package net.modtale.service.storage; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ModpackArchiveValidatorTest { + + @Test + void acceptsAnArchiveWhoseBundledBytesMatchTheLockfile() throws Exception { + byte[] content = bytes("trusted"); + byte[] archive = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest()), + entry("modtale.lock.json", lockEntry("MODTALE", "BUNDLED", "plugin.jar", content, false)), + new ArchiveEntry("plugin.jar", content) + )); + + assertDoesNotThrow(() -> ModpackArchiveValidator.validate(archive)); + } + + @Test + void rejectsHashMismatch() throws Exception { + byte[] content = bytes("tampered"); + String lock = lockEntry("MODTALE", "BUNDLED", "plugin.jar", content, false) + .replace(sha256(content), "0".repeat(64)); + byte[] archive = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest()), + entry("modtale.lock.json", lock), + new ArchiveEntry("plugin.jar", content) + )); + + IOException error = assertThrows(IOException.class, () -> ModpackArchiveValidator.validate(archive)); + assertTrue(error.getMessage().contains("hash does not match")); + } + + @Test + void rejectsTraversalAndCaseFoldedDuplicatePaths() throws Exception { + byte[] traversal = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest()), + entry("modtale.lock.json", emptyLock()), + entry("../escape.jar", "bad") + )); + byte[] collision = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest()), + entry("modtale.lock.json", emptyLock()), + entry("Plugin.jar", "one"), + entry("plugin.JAR", "two") + )); + + assertTrue(assertThrows(IOException.class, + () -> ModpackArchiveValidator.validate(traversal)).getMessage().contains("unsafe path")); + assertTrue(assertThrows(IOException.class, + () -> ModpackArchiveValidator.validate(collision)).getMessage().contains("case-colliding")); + } + + @Test + void rejectsUndeclaredFilesAndBundledCurseForgeArtifacts() throws Exception { + byte[] content = bytes("provider-file"); + byte[] undeclared = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest()), + entry("modtale.lock.json", emptyLock()), + new ArchiveEntry("surprise.jar", content) + )); + byte[] curseForge = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest()), + entry("modtale.lock.json", lockEntry("CURSEFORGE", "BUNDLED", "curseforge.jar", content, false)), + new ArchiveEntry("curseforge.jar", content) + )); + + assertTrue(assertThrows(IOException.class, + () -> ModpackArchiveValidator.validate(undeclared)).getMessage().contains("not declared")); + assertTrue(assertThrows(IOException.class, + () -> ModpackArchiveValidator.validate(curseForge)).getMessage().contains("reference-only")); + } + + @Test + void rejectsReferenceEntriesThatClaimBundledIntegrityFields() throws Exception { + byte[] archive = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest()), + entry("modtale.lock.json", lockEntry( + "CURSEFORGE", + "REFERENCE_ONLY", + "should-not-exist.jar", + bytes("not bundled"), + true + )) + )); + + assertTrue(assertThrows(IOException.class, + () -> ModpackArchiveValidator.validate(archive)).getMessage().contains("must not claim bundled bytes")); + } + + @Test + void acceptsCanonicalCurseForgeReferencesAndRejectsLookalikeHosts() throws Exception { + String canonical = curseForgeLock("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810"); + String lookalike = curseForgeLock("https://www.curseforge.com.evil.example/hytale/mods/simple-compost/files/8227810"); + + byte[] valid = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest()), + entry("modtale.lock.json", canonical) + )); + byte[] invalid = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest()), + entry("modtale.lock.json", lookalike) + )); + + assertDoesNotThrow(() -> ModpackArchiveValidator.validate(valid)); + assertTrue(assertThrows(IOException.class, + () -> ModpackArchiveValidator.validate(invalid)).getMessage().contains("invalid file-page URL")); + } + + @Test + void rejectsMalformedCurseForgeProviderIntegrityMetadata() throws Exception { + String invalidLock = curseForgeLock("https://www.curseforge.com/hytale/mods/simple-compost/files/8227810") + .replace("\"a" + "a".repeat(39) + "\"", "\"not-a-sha1\""); + byte[] archive = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest()), + entry("modtale.lock.json", invalidLock) + )); + + assertTrue(assertThrows(IOException.class, + () -> ModpackArchiveValidator.validate(archive)).getMessage().contains("invalid provider hashes")); + } + + @Test + void rejectsUnknownDependencyEnvironments() throws Exception { + byte[] content = bytes("trusted"); + String invalidLock = lockEntry("MODTALE", "BUNDLED", "plugin.jar", content, false) + .replace("\"environment\":\"COMMON\"", "\"environment\":\"BROWSER\""); + byte[] archive = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest()), + entry("modtale.lock.json", invalidLock), + new ArchiveEntry("plugin.jar", content) + )); + + assertTrue(assertThrows(IOException.class, + () -> ModpackArchiveValidator.validate(archive)).getMessage().contains("unknown environment")); + } + + @Test + void rejectsInconsistentArchiveTargets() throws Exception { + byte[] archive = archive(List.of( + entry("modpack.json", legacyManifest().replace("\"files\"", "\"target\":\"CLIENT\",\"files\"")), + entry("manifest.json", manifest().replace("\"pack\"", "\"target\":\"CLIENT\",\"pack\"")), + entry("modtale.lock.json", emptyLock().replace("\"pack\"", "\"target\":\"SERVER\",\"pack\"")) + )); + + assertTrue(assertThrows(IOException.class, + () -> ModpackArchiveValidator.validate(archive)).getMessage().contains("inconsistent target")); + } + + private static String emptyLock() { + return """ + { + "format": "modtale-lock", + "lockVersion": 1, + "pack": {}, + "gameVersions": [], + "entries": [] + } + """; + } + + private static String curseForgeLock(String fileUrl) { + return """ + { + "format": "modtale-lock", + "lockVersion": 1, + "pack": {}, + "gameVersions": [], + "entries": [{ + "id": "", + "title": "Simple Compost", + "version": "1.0.0", + "source": "CURSEFORGE", + "dependencyType": "REQUIRED", + "environment": "COMMON", + "distribution": "REFERENCE_ONLY", + "url": "%s", + "fileUrl": "%s", + "provider": { + "projectId": "1450386", + "fileId": "8227810", + "fileName": "SimpleCompost-1.0.0.jar", + "fileSize": 4096, + "hashes": {"sha1": "%s"}, + "gameVersions": ["2026.09"], + "fileStatus": 4, + "distributionAllowed": false + } + }] + } + """.formatted(fileUrl, fileUrl, "a".repeat(40)); + } + + private static String lockEntry( + String source, + String distribution, + String path, + byte[] content, + boolean includeIntegrityForReference + ) throws Exception { + String integrity = "BUNDLED".equals(distribution) || includeIntegrityForReference + ? """ + ,"path":"%s","size":%d,"hashes":{"sha256":"%s"} + """.formatted(path, content.length, sha256(content)) + : ""; + return """ + { + "format": "modtale-lock", + "lockVersion": 1, + "pack": {}, + "gameVersions": [], + "entries": [ + {"source":"%s","environment":"COMMON","distribution":"%s"%s} + ] + } + """.formatted(source, distribution, integrity); + } + + private static String legacyManifest() { + return """ + {"formatVersion":1,"game":"hytale","files":[]} + """; + } + + private static String manifest() { + return """ + { + "format": "modtale-pack", + "schemaVersion": 1, + "pack": {}, + "game": {}, + "dependencies": [] + } + """; + } + + private static ArchiveEntry entry(String name, String content) { + return new ArchiveEntry(name, bytes(content)); + } + + private static byte[] archive(List entries) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + for (ArchiveEntry value : entries) { + ZipEntry entry = new ZipEntry(value.name()); + entry.setTime(0); + zip.putNextEntry(entry); + zip.write(value.content()); + zip.closeEntry(); + } + } + return output.toByteArray(); + } + + private static String sha256(byte[] content) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private record ArchiveEntry(String name, byte[] content) {} +} diff --git a/backend/src/test/java/net/modtale/service/storage/ModpackOverrideArchiveTest.java b/backend/src/test/java/net/modtale/service/storage/ModpackOverrideArchiveTest.java new file mode 100644 index 00000000..696f6c66 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/storage/ModpackOverrideArchiveTest.java @@ -0,0 +1,57 @@ +package net.modtale.service.storage; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ModpackOverrideArchiveTest { + + @Test + void readsLayeredOverridesAndPreservesPortablePaths() throws Exception { + List files = ModpackOverrideArchive.read(new ByteArrayInputStream(zip(Map.of( + "overrides/common/config/game.json", "{}", + "overrides/client/config/ui.toml", "scale=2", + "overrides/server/config/server.properties", "pvp=true" + )))); + + assertEquals(3, files.size()); + assertTrue(files.stream().anyMatch(file -> file.path().equals("overrides/client/config/ui.toml"))); + } + + @Test + void rejectsTraversalWrongRootsCaseCollisionsScriptsAndNestedArchives() throws Exception { + for (Map entries : List.of( + Map.of("overrides/common/../secret.txt", "bad"), + Map.of("config/game.json", "bad"), + new LinkedHashMap<>(Map.of("overrides/common/A.txt", "one", "overrides/common/a.TXT", "two")), + Map.of("overrides/client/install.ps1", "bad"), + Map.of("overrides/server/mods.zip", "bad"), + Map.of("overrides/common/config/NUL.txt", "bad") + )) { + assertThrows(IOException.class, () -> ModpackOverrideArchive.read(new ByteArrayInputStream(zip(entries)))); + } + } + + private static byte[] zip(Map entries) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + for (Map.Entry entry : entries.entrySet()) { + zip.putNextEntry(new ZipEntry(entry.getKey())); + zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + return output.toByteArray(); + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e167562f..10e40a36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "astro": "^7.2.10", "axios": "^1.20.0", + "i18next": "^25.10.10", "jszip": "^3.10.1", "lucide-react": "^1.39.0", "mermaid": "^11.17.2", @@ -24,6 +25,7 @@ "react-dropzone": "^20.1.1", "react-easy-crop": "^6.2.3", "react-helmet-async": "^3.0.0", + "react-i18next": "^16.6.6", "react-markdown": "^10.1.0", "react-router-dom": "^7.18.3", "react-syntax-highlighter": "^16.1.1", @@ -722,9 +724,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -6166,6 +6168,15 @@ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", + "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -6225,6 +6236,37 @@ "node": ">= 6" } }, + "node_modules/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -8472,6 +8514,33 @@ "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-i18next": { + "version": "16.6.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz", + "integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.10.9", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -9775,6 +9844,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10022,6 +10100,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/volar-service-css": { "version": "0.0.70", "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz", diff --git a/frontend/package.json b/frontend/package.json index 141914d1..601e5abb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "astro": "^7.2.10", "axios": "^1.20.0", + "i18next": "^25.10.10", "jszip": "^3.10.1", "lucide-react": "^1.39.0", "mermaid": "^11.17.2", @@ -32,6 +33,7 @@ "react-dropzone": "^20.1.1", "react-easy-crop": "^6.2.3", "react-helmet-async": "^3.0.0", + "react-i18next": "^16.6.6", "react-markdown": "^10.1.0", "react-router-dom": "^7.18.3", "react-syntax-highlighter": "^16.1.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5213e38e..690b7378 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,8 @@ import { STATUS_PAGE_URL } from '@/utils/status'; import type { Classification } from '@/data/categories'; import { normalizeUser } from '@/utils/users'; import { clearPendingSignInMethod, completeSignInMethod } from '@/modules/auth/api/authClient'; +import { LocalizationProvider } from '@/i18n'; +import { useTranslation } from 'react-i18next'; const StatusModal = lazy(() => import('@/components/ui/StatusModal').then((module) => ({ default: module.StatusModal }))); const Onboarding = lazy(() => import('@/modules/user/components/Onboarding').then((module) => ({ default: module.Onboarding }))); @@ -45,6 +47,8 @@ const SwaggerDocs = lazy(() => import('@/modules/core/views/SwaggerDocs').then(( const RouteLoading = () =>
; const StatusRedirect = () => { + const { t } = useTranslation('status'); + useEffect(() => { if (typeof window !== 'undefined') { window.location.replace(STATUS_PAGE_URL); @@ -54,15 +58,15 @@ const StatusRedirect = () => { return (
-

Opening Modtale Status

+

{t('title')}

- Redirecting to {STATUS_PAGE_URL}. + {t('redirecting', { url: STATUS_PAGE_URL })}

- Open Status + {t('open')}
@@ -368,25 +372,27 @@ const AppContent: React.FC = () => { export const App: React.FC = ({ initialPath, ssrData }) => { return ( - - - - - - {import.meta.env.SSR ? ( - - - - ) : ( - - - - )} - - - - - + + + + + + + {import.meta.env.SSR ? ( + + + + ) : ( + + + + )} + + + + + + ); }; export default App; diff --git a/frontend/src/components/ui/error/NotFound.tsx b/frontend/src/components/ui/error/NotFound.tsx index 6d673c17..8c5cf8ec 100644 --- a/frontend/src/components/ui/error/NotFound.tsx +++ b/frontend/src/components/ui/error/NotFound.tsx @@ -2,12 +2,14 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { Helmet } from 'react-helmet-async'; import { AlertCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; const NotFound: React.FC = () => { + const { t } = useTranslation('errors'); return (
- 404 - Page Not Found | Modtale + {t('notFound.documentTitle')} @@ -16,21 +18,21 @@ const NotFound: React.FC = () => {

- Page Not Found + {t('notFound.title')}

- The mod, modpack, or page you are looking for doesn't exist or has been removed. + {t('notFound.description')}

- Return Home + {t('notFound.returnHome')} ); }; -export default NotFound; \ No newline at end of file +export default NotFound; diff --git a/frontend/src/data/categories.ts b/frontend/src/data/categories.ts index 74b8f2ff..e37115ad 100644 --- a/frontend/src/data/categories.ts +++ b/frontend/src/data/categories.ts @@ -24,11 +24,11 @@ export const GLOBAL_TAGS = [ export const PROJECT_TYPES = [ { id: 'All', label: 'All Projects', icon: Layout }, + { id: 'MODPACK', label: 'Modpacks', icon: Layers }, { id: 'PLUGIN', label: 'Plugins', icon: FileCode }, - { id: 'DATA', label: 'Data Assets', icon: Database }, - { id: 'ART', label: 'Art Assets', icon: Palette }, { id: 'SAVE', label: 'Worlds', icon: Save }, - { id: 'MODPACK', label: 'Modpacks', icon: Layers } + { id: 'ART', label: 'Art Assets', icon: Palette }, + { id: 'DATA', label: 'Data Assets', icon: Database } ]; export const LICENSES = [ diff --git a/frontend/src/i18n/LanguageSelector.tsx b/frontend/src/i18n/LanguageSelector.tsx new file mode 100644 index 00000000..4eeffffb --- /dev/null +++ b/frontend/src/i18n/LanguageSelector.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { localeMetadata, supportedLocales, type SupportedLocale } from './config'; +import { useLocalization } from './LocalizationProvider'; + +export const LanguageSelector: React.FC<{ className?: string }> = ({ className = '' }) => { + const { t } = useTranslation('common'); + const { locale, setLocale } = useLocalization(); + + if (supportedLocales.length < 2) return null; + + return ( + + ); +}; diff --git a/frontend/src/i18n/LocalizationProvider.tsx b/frontend/src/i18n/LocalizationProvider.tsx new file mode 100644 index 00000000..f8102602 --- /dev/null +++ b/frontend/src/i18n/LocalizationProvider.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { I18nextProvider } from 'react-i18next'; +import { + DEFAULT_LOCALE, + LOCALE_STORAGE_KEY, + detectPreferredLocale, + localeMetadata, + type SupportedLocale +} from './config'; +import { createAppI18n, ensureLocaleLoaded } from './i18n'; + +type LocalizationContextValue = { + locale: SupportedLocale; + setLocale: (locale: SupportedLocale) => Promise; + formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string; + formatDate: (value: Date | number | string, options?: Intl.DateTimeFormatOptions) => string; + formatList: (values: string[], options?: Intl.ListFormatOptions) => string; +}; + +const LocalizationContext = createContext(null); + +export const LocalizationProvider: React.FC = ({ children }) => { + const [instance] = useState(() => createAppI18n(DEFAULT_LOCALE)); + const [locale, setLocaleState] = useState(DEFAULT_LOCALE); + + const setLocale = useCallback(async (nextLocale: SupportedLocale) => { + await ensureLocaleLoaded(instance, nextLocale); + setLocaleState(nextLocale); + if (typeof document !== 'undefined') { + document.documentElement.lang = nextLocale; + document.documentElement.dir = localeMetadata[nextLocale].direction; + } + try { + window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale); + } catch { + // A locale still applies for this session when persistence is unavailable. + } + }, [instance]); + + useEffect(() => { + void setLocale(detectPreferredLocale()); + }, [setLocale]); + + const value = useMemo(() => ({ + locale, + setLocale, + formatNumber: (number, options) => new Intl.NumberFormat(locale, options).format(number), + formatDate: (date, options) => new Intl.DateTimeFormat(locale, options).format(new Date(date)), + formatList: (values, options) => new Intl.ListFormat(locale, options).format(values) + }), [locale, setLocale]); + + return ( + + {children} + + ); +}; + +export const useLocalization = () => { + const context = useContext(LocalizationContext); + if (!context) throw new Error('useLocalization must be used within LocalizationProvider'); + return context; +}; diff --git a/frontend/src/i18n/README.md b/frontend/src/i18n/README.md new file mode 100644 index 00000000..9ea188cd --- /dev/null +++ b/frontend/src/i18n/README.md @@ -0,0 +1,13 @@ +# Web localization + +User-facing copy lives in `locales/.ts` and is accessed through `react-i18next`. English is the fallback and currently the only shipped locale. + +To add a language: + +1. Copy `locales/en.ts`, preserving its complete key structure. +2. Add the locale and its native display name/direction to `localeMetadata`. +3. Add its lazy loader in `resources.ts`. + +The language selector appears automatically once a second locale is registered. Locale selection is persisted under `modtale-locale`; otherwise the browser language is negotiated. The root provider updates both `lang` and `dir`, while `useLocalization` supplies locale-aware number, date, and list formatting. + +Keep dynamic values in interpolation variables, use i18next plural suffixes (`_one`, `_other`) for counts, and avoid assembling translated sentences from fragments. diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts new file mode 100644 index 00000000..ff63b06e --- /dev/null +++ b/frontend/src/i18n/config.ts @@ -0,0 +1,43 @@ +export const DEFAULT_LOCALE = 'en' as const; +export const LOCALE_STORAGE_KEY = 'modtale-locale'; + +export const localeMetadata = { + en: { nativeName: 'English', direction: 'ltr' } +} as const; + +export type SupportedLocale = keyof typeof localeMetadata; +export type TextDirection = (typeof localeMetadata)[SupportedLocale]['direction']; + +export const supportedLocales = Object.keys(localeMetadata) as SupportedLocale[]; + +export const normalizeLocale = (candidate?: string | null): SupportedLocale | null => { + if (!candidate) return null; + const normalized = candidate.trim().replace('_', '-').toLowerCase(); + if (!normalized) return null; + const exact = supportedLocales.find(locale => locale.toLowerCase() === normalized); + if (exact) return exact; + const base = normalized.split('-')[0]; + return supportedLocales.find(locale => locale.toLowerCase() === base) ?? null; +}; + +export const detectPreferredLocale = (): SupportedLocale => { + if (typeof window === 'undefined') return DEFAULT_LOCALE; + + try { + const stored = normalizeLocale(window.localStorage.getItem(LOCALE_STORAGE_KEY)); + if (stored) return stored; + } catch { + // Storage can be unavailable in privacy-restricted browsers. + } + + const candidates = [ + ...(window.navigator.languages ?? []), + window.navigator.language, + window.document.documentElement.lang + ]; + for (const candidate of candidates) { + const locale = normalizeLocale(candidate); + if (locale) return locale; + } + return DEFAULT_LOCALE; +}; diff --git a/frontend/src/i18n/i18n.ts b/frontend/src/i18n/i18n.ts new file mode 100644 index 00000000..f242b35f --- /dev/null +++ b/frontend/src/i18n/i18n.ts @@ -0,0 +1,35 @@ +import { createInstance, type i18n } from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { DEFAULT_LOCALE, supportedLocales, type SupportedLocale } from './config'; +import { defaultResources, loadLocale } from './resources'; + +export const createAppI18n = (locale: SupportedLocale = DEFAULT_LOCALE): i18n => { + const instance = createInstance(); + void instance.use(initReactI18next).init({ + lng: locale, + fallbackLng: DEFAULT_LOCALE, + supportedLngs: supportedLocales, + resources: defaultResources, + defaultNS: 'common', + fallbackNS: 'common', + load: 'languageOnly', + cleanCode: true, + nonExplicitSupportedLngs: true, + returnNull: false, + interpolation: { escapeValue: false }, + react: { useSuspense: false }, + showSupportNotice: false, + initImmediate: false + }); + return instance; +}; + +export const ensureLocaleLoaded = async (instance: i18n, locale: SupportedLocale) => { + if (!instance.hasResourceBundle(locale, 'common')) { + const resources = await loadLocale(locale); + Object.entries(resources).forEach(([namespace, messages]) => { + instance.addResourceBundle(locale, namespace, messages, true, true); + }); + } + await instance.changeLanguage(locale); +}; diff --git a/frontend/src/i18n/i18next.d.ts b/frontend/src/i18n/i18next.d.ts new file mode 100644 index 00000000..049b3d2d --- /dev/null +++ b/frontend/src/i18n/i18next.d.ts @@ -0,0 +1,9 @@ +import 'i18next'; +import en from './locales/en'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'common'; + resources: typeof en; + } +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 00000000..b6b0c238 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,3 @@ +export * from './config'; +export * from './LanguageSelector'; +export * from './LocalizationProvider'; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts new file mode 100644 index 00000000..569c3e19 --- /dev/null +++ b/frontend/src/i18n/locales/en.ts @@ -0,0 +1,82 @@ +const en = { + common: { + actions: { + back: 'Back', + close: 'Close', + create: 'Create', + download: 'Download', + save: 'Save', + search: 'Search', + signIn: 'Sign in', + signOut: 'Sign out' + }, + language: { + label: 'Language' + }, + theme: 'Theme' + }, + navigation: { + browse: 'Browse', + mods: 'Mods', + allProjects: 'All Projects', + plugins: 'Plugins', + modpacks: 'Modpacks', + worlds: 'Worlds', + artAssets: 'Art Assets', + dataAssets: 'Data Assets', + api: 'API', + dashboard: 'Dashboard', + createProject: 'Create Project', + yourProfile: 'Your Profile', + profile: 'Profile', + following: 'Following', + userDashboard: 'User Dashboard', + adminPanel: 'Admin Panel', + signedInAs: 'Signed in as', + openMenu: 'Open navigation menu', + closeMenu: 'Close navigation menu' + }, + footer: { + discover: 'Discover', + resources: 'Resources', + community: 'Community', + apiDocs: 'API Docs', + status: 'Status', + terms: 'Terms of Service', + privacy: 'Privacy Policy', + copyright: '© {{year}} Modtale.', + description: { + default: 'The premier community repository for Hytale. Discover, download, and share Hytale mods, server plugins, worlds, art assets, data assets, and modpacks.', + plugins: 'The premier community repository for Hytale plugins. Discover, download, and share server plugins, admin tools, gameplay extensions, and supporting libraries.', + modpacks: 'The premier community repository for Hytale modpacks. Discover, download, and share curated Hytale modpacks, collections, and bundled project setups.', + worlds: 'The premier community repository for Hytale worlds. Discover, download, and share Hytale save files, maps, lobbies, schematics, and spawns.', + art: 'The premier community repository for Hytale art assets. Discover, download, and share Hytale models, textures, animations, and creator resources.', + data: 'The premier community repository for Hytale data assets. Discover, download, and share Hytale configs, loot tables, recipes, and data-driven files.' + } + }, + status: { + title: 'Opening Modtale Status', + redirecting: 'Redirecting to {{url}}.', + open: 'Open Status' + }, + project: { + bannerAlt: 'Project Banner', + iconAlt: 'Icon', + changeBanner: 'Change Banner', + uploadBanner: 'Upload Banner', + changeIcon: 'Change Icon', + recommendedBannerSize: 'Recommended: 1920x640', + shortRecommendedBannerSize: 'Rec: 1920x640', + recommendedIconSize: 'Rec: 512x512' + }, + errors: { + notFound: { + documentTitle: '404 - Page Not Found | Modtale', + title: 'Page Not Found', + description: "The mod, modpack, or page you are looking for doesn't exist or has been removed.", + returnHome: 'Return Home' + } + } +} as const; + +export default en; diff --git a/frontend/src/i18n/resources.ts b/frontend/src/i18n/resources.ts new file mode 100644 index 00000000..c7136db3 --- /dev/null +++ b/frontend/src/i18n/resources.ts @@ -0,0 +1,11 @@ +import type { Resource } from 'i18next'; +import type { SupportedLocale } from './config'; +import en from './locales/en'; + +export const defaultResources: Resource = { en }; + +const localeLoaders: Record Promise<{ default: typeof en }>> = { + en: () => Promise.resolve({ default: en }) +}; + +export const loadLocale = async (locale: SupportedLocale) => (await localeLoaders[locale]()).default; diff --git a/frontend/src/layouts/BaseLayout.astro b/frontend/src/layouts/BaseLayout.astro index eed01d9b..63d1c4d0 100644 --- a/frontend/src/layouts/BaseLayout.astro +++ b/frontend/src/layouts/BaseLayout.astro @@ -2,6 +2,7 @@ import '../index.css'; import { DEFAULT_SEO } from '../data/seo-constants'; import { BACKEND_URL } from '../utils/api'; +import { DEFAULT_LOCALE, localeMetadata, supportedLocales } from '../i18n/config'; interface Props { title?: string; @@ -50,10 +51,15 @@ const initialDataString = initialData ? JSON.stringify(initialData).replace(/ [locale, metadata.direction])) +}); --- - + @@ -108,6 +114,26 @@ const wikiBootstrapString = wikiBootstrap ? JSON.stringify(wikiBootstrap).replac } catch (e) {} })(); +