diff --git a/backend/cloudbuild.yml b/backend/cloudbuild.yml index 814d16b5..df8f8b71 100644 --- a/backend/cloudbuild.yml +++ b/backend/cloudbuild.yml @@ -17,4 +17,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/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/mapper/ProjectMapper.java b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java index 5bd0a314..d8026ca2 100644 --- a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java +++ b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java @@ -242,7 +242,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()); @@ -369,11 +368,20 @@ public static List toAdminVersionSummaryDTOs(List 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.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..cb5111d8 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/project/ExternalProjectReferenceDTO.java @@ -0,0 +1,28 @@ +package net.modtale.model.dto.project; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +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, + List files +) { + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ExternalFileDTO( + String id, + String displayName, + String fileName, + String versionNumber, + String releaseType, + String downloadUrl + ) {} +} 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..31dc0282 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,22 @@ 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.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..1d3d9bab --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/project/DependencyReferenceRequest.java @@ -0,0 +1,67 @@ +package net.modtale.model.dto.request.project; + +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.Source source = ProjectDependency.Source.MODTALE; + private String externalId; + private String externalUrl; + private String externalFileUrl; + private String externalFileName; + 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.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 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/Project.java b/backend/src/main/java/net/modtale/model/project/Project.java index 6a5998db..38b76219 100644 --- a/backend/src/main/java/net/modtale/model/project/Project.java +++ b/backend/src/main/java/net/modtale/model/project/Project.java @@ -169,8 +169,6 @@ public ProjectMember(String userId, String roleId) { private List types; private List childProjectIds; - - private List modIds; private boolean allowModpacks = true; private boolean allowComments = true; @@ -269,8 +267,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..f0860c05 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,29 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +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 + } - @JsonProperty("projectTitle") - private String modTitle; + public enum DependencyType { + REQUIRED, + OPTIONAL, + EMBEDDED + } + private String id = UUID.randomUUID().toString(); + private String projectId; + private String projectTitle; private String versionNumber; @Transient private String icon; @@ -22,38 +34,68 @@ public class ProjectDependency { private ProjectClassification classification; @Transient private String slug; - private boolean isOptional; - private boolean isEmbedded; + private DependencyType dependencyType = DependencyType.REQUIRED; + private Source source = Source.MODTALE; + private String externalId; + private String externalUrl; + private String externalFileUrl; + private String externalFileName; + 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 +112,41 @@ 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 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 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/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/ExternalDependencyArtifactService.java b/backend/src/main/java/net/modtale/service/project/version/ExternalDependencyArtifactService.java new file mode 100644 index 00000000..e3886acc --- /dev/null +++ b/backend/src/main/java/net/modtale/service/project/version/ExternalDependencyArtifactService.java @@ -0,0 +1,439 @@ +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.exception.StorageDownloadException; +import net.modtale.exception.StorageUploadException; +import net.modtale.model.project.ProjectDependency; +import net.modtale.service.storage.StorageService; +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 String CURSEFORGE_DOWNLOAD_BASE = "https://www.curseforge.com/api/v1/mods/%s/files/%s/download"; + 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 StorageService storageService; + private final HttpClient httpClient; + + @Autowired + public ExternalDependencyArtifactService(StorageService storageService) { + this( + storageService, + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + ); + } + + ExternalDependencyArtifactService(StorageService storageService, HttpClient httpClient) { + this.storageService = storageService; + 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) { + cacheCurseForgeDependency(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 cacheCurseForgeDependency(ProjectDependency dependency, String fileUrl) { + CurseForgeFileReference reference = resolveCurseForgeFileReference(dependency, fileUrl); + if (reference == null) { + throw new InvalidVersionRequestException("CurseForge dependencies must include a downloadable file so Modtale can cache it."); + } + + validateInitialSourceUrl(ProjectDependency.Source.CURSEFORGE, reference.downloadUrl()); + String filename = trimToNull(dependency.getExternalFileName()); + if (filename == null) { + filename = reference.fileName(); + } + filename = sanitizeArchiveFilename(filename == null ? "curseforge-" + reference.modId() + "-" + reference.fileId() + ".jar" : filename); + String storageKey = "external-dependencies/curseforge/%s/%s/%s".formatted(reference.modId(), reference.fileId(), filename); + + if (isAlreadyCached(storageKey)) { + dependency.setExternalFileUrl(reference.downloadUrl()); + dependency.setExternalFileName(filename); + dependency.setCachedFileUrl(storageKey); + return; + } + + DownloadedFile downloaded = downloadFile(reference.downloadUrl(), true); + validateArchiveSignature(downloaded.bytes(), "CurseForge dependency"); + if (downloaded.bytes().length > MAX_EXTERNAL_FILE_BYTES) { + throw new InvalidVersionRequestException("CurseForge dependency files must be 100MB or smaller."); + } + + try { + storageService.uploadDirect(storageKey, downloaded.bytes(), contentTypeForFilename(filename)); + dependency.setExternalFileUrl(reference.downloadUrl()); + dependency.setExternalFileName(filename); + dependency.setCachedFileUrl(storageKey); + } catch (StorageUploadException ex) { + throw new InvalidVersionRequestException("Could not cache the CurseForge dependency file. Try again later."); + } + } + + 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 (modId == null || fileId == null) { + return null; + } + return new CurseForgeFileReference(modId, fileId, String.format(CURSEFORGE_DOWNLOAD_BASE, modId, fileId), 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 boolean isAlreadyCached(String storageKey) { + try { + return storageService.exists(storageKey); + } catch (StorageDownloadException ex) { + return false; + } + } + + 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 String contentTypeForFilename(String filename) { + return filename.toLowerCase(Locale.ROOT).endsWith(".zip") ? "application/zip" : "application/java-archive"; + } + + 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 downloadUrl, 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..29ab5c49 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/project/version/ExternalProjectReferenceService.java @@ -0,0 +1,390 @@ +package net.modtale.service.project.version; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import net.modtale.exception.InvalidVersionRequestException; +import net.modtale.model.dto.project.ExternalProjectReferenceDTO; +import net.modtale.model.project.ProjectDependency; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +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 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 static final String CF_WIDGET_BASE = "https://api.cfwidget.com/"; + private static final String CURSEFORGE_DOWNLOAD_BASE = "https://www.curseforge.com/api/v1/mods/%s/files/%s/download"; + + private final RestTemplate restTemplate; + + public ExternalProjectReferenceService() { + this(new RestTemplate()); + } + + ExternalProjectReferenceService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + 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."); + }; + } + + public String curseForgeDownloadUrl(String projectId, String fileId) { + if (trimToNull(projectId) == null || trimToNull(fileId) == null) { + return null; + } + return String.format(CURSEFORGE_DOWNLOAD_BASE, projectId.trim(), fileId.trim()); + } + + 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.slug(), + titleFromSlug(path.slug()), + path.fileId() == null ? "latest" : path.fileId(), + path.projectUrl(), + null, + null, + true, + path.fileId() == null ? List.of() : List.of(new ExternalProjectReferenceDTO.ExternalFileDTO( + path.fileId(), + path.fileId(), + null, + path.fileId(), + null, + curseForgeDownloadUrl(path.projectId(), path.fileId()) + )) + ); + + try { + Map data = getJson(CF_WIDGET_BASE + "hytale/mods/" + path.slug()); + if (data == null || data.isEmpty()) { + return fallback; + } + + String projectId = stringValue(data.get("id")); + List files = parseCurseForgeFiles(projectId, data.get("files")); + ExternalProjectReferenceDTO.ExternalFileDTO selectedFile = files.stream() + .filter(file -> path.fileId() == null || path.fileId().equals(file.id())) + .findFirst() + .orElseGet(() -> parseCurseForgeFile(projectId, data.get("download"))); + String versionNumber = selectedFile != null && trimToNull(selectedFile.versionNumber()) != null + ? selectedFile.versionNumber() + : "latest"; + String externalId = trimToNull(projectId) != null ? projectId : path.slug(); + + return new ExternalProjectReferenceDTO( + ProjectDependency.Source.CURSEFORGE, + externalId, + stringValue(data.get("title"), fallback.title()), + versionNumber, + path.fileId() == null ? path.projectUrl() : path.fileUrl(), + stringValue(data.get("thumbnail")), + stringValue(data.get("summary")), + true, + files + ); + } catch (RestClientException ex) { + return 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, + externalFilesForUrl(externalUrl) + ); + } + + private Map getJson(String url) { + RequestEntity request = RequestEntity + .get(URI.create(url)) + .header(HttpHeaders.USER_AGENT, "Modtale/1.0") + .build(); + return restTemplate.exchange(request, new ParameterizedTypeReference>() {}).getBody(); + } + + private List parseCurseForgeFiles(String projectId, Object rawFiles) { + if (!(rawFiles instanceof List files)) { + return List.of(); + } + List parsed = new ArrayList<>(); + for (Object rawFile : files) { + ExternalProjectReferenceDTO.ExternalFileDTO file = parseCurseForgeFile(projectId, rawFile); + if (file != null) { + parsed.add(file); + } + } + parsed.sort(Comparator.comparing(ExternalProjectReferenceDTO.ExternalFileDTO::id, Comparator.nullsLast(String::compareTo)).reversed()); + return parsed.stream().limit(20).toList(); + } + + @SuppressWarnings("unchecked") + private ExternalProjectReferenceDTO.ExternalFileDTO parseCurseForgeFile(String projectId, Object rawFile) { + if (!(rawFile instanceof Map file)) { + return null; + } + String id = stringValue(file.get("id")); + String fileName = stringValue(file.get("name")); + String displayName = stringValue(file.get("display"), fileName); + String version = stringValue(file.get("version"), "latest"); + String type = stringValue(file.get("type")); + String downloadUrl = trimToNull(projectId) != null && trimToNull(id) != null + ? curseForgeDownloadUrl(projectId, id) + : stringValue(file.get("url")); + return new ExternalProjectReferenceDTO.ExternalFileDTO(id, displayName, fileName, version, type, downloadUrl); + } + + 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 (host == null || !isHost(host, CURSEFORGE_HOST) || segments.length < 3) { + return null; + } + if (!"hytale".equalsIgnoreCase(segments[0]) || !"mods".equalsIgnoreCase(segments[1])) { + return null; + } + String slug = segments[2]; + 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; + } + } + 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(); + return new CurseForgePath(slug, projectId, fileId, projectUrl, value); + } 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 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 + )); + } + + 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/VersionCreationCommandHandler.java b/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java index 94e428f6..479f7ecd 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 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..a6862daf 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,12 @@ 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.request.project.DependencyReferenceRequest; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectStatus; @@ -12,6 +16,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 +28,30 @@ 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<>(); - 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(); + ProjectDependency dependency = source == ProjectDependency.Source.MODTALE + ? resolveModtaleDependency(reference, isModpack, allowDraftDependencies) + : resolveExternalDependency(reference, source, isModpack); + 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 +86,225 @@ 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 isModpack, + 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 = isModpack + ? ProjectDependency.DependencyType.REQUIRED + : reference.getDependencyType(); + ProjectDependency dependency = ProjectDependency.modtale( + dependencyProject.getId(), + dependencyProject.getTitle(), + versionNumber, + dependencyType + ); + dependency.setId(reference.getId()); + return dependency; + } + + private ProjectDependency resolveExternalDependency( + DependencyReferenceRequest reference, + ProjectDependency.Source source, + boolean isModpack + ) { + 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 = isModpack + ? ProjectDependency.DependencyType.REQUIRED + : reference.getDependencyType(); + ProjectDependency dependency = ProjectDependency.external(source, externalId, title, versionNumber, externalUrl, dependencyType); + dependency.setId(reference.getId()); + dependency.setExternalFileUrl(trimToNull(reference.getExternalFileUrl())); + dependency.setExternalFileName(trimToNull(reference.getExternalFileName())); + 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 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..e7681e31 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 @@ -135,10 +135,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); } }); @@ -202,10 +205,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(), 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/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/ModpackArchiveService.java b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java index 971276cf..eb25914a 100644 --- a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java +++ b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java @@ -3,6 +3,9 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import net.modtale.exception.StorageDownloadException; @@ -55,8 +58,10 @@ 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)) { + Set archiveEntries = new HashSet<>(); writeManifest(zos, pack, version); - writeDependencyFiles(zos, version); + archiveEntries.add("modpack.json"); + writeDependencyFiles(zos, version, archiveEntries); } return baos.toByteArray(); } @@ -65,12 +70,30 @@ private void writeManifest(ZipOutputStream zos, Project pack, ProjectVersion ver ZipEntry readme = new ZipEntry("modpack.json"); zos.putNextEntry(readme); - StringBuilder json = new StringBuilder("{\n \"name\": \"" + pack.getTitle() + "\",\n \"files\": [\n"); + StringBuilder json = new StringBuilder("{\n \"name\": \"") + .append(jsonEscape(pack.getTitle())) + .append("\",\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("\" }"); + json.append(" { \"id\": \"").append(jsonEscape(dep.getProjectId())) + .append("\", \"title\": \"").append(jsonEscape(dep.getProjectTitle())) + .append("\", \"version\": \"").append(jsonEscape(dep.getVersionNumber())) + .append("\", \"source\": \"").append(jsonEscape(dep.getSource().name())).append("\""); + if (dep.isExternal()) { + json.append(", \"externalId\": \"").append(jsonEscape(dep.getExternalId())) + .append("\", \"url\": \"").append(jsonEscape(dep.getExternalUrl())).append("\""); + if (dep.getExternalFileUrl() != null) { + json.append(", \"externalFileUrl\": \"").append(jsonEscape(dep.getExternalFileUrl())).append("\""); + } + if (dep.getExternalFileName() != null) { + json.append(", \"externalFileName\": \"").append(jsonEscape(dep.getExternalFileName())).append("\""); + } + if (dep.getCachedFileUrl() != null) { + json.append(", \"cachedFileUrl\": \"").append(jsonEscape(dep.getCachedFileUrl())).append("\""); + } + } + json.append(" }"); if (i < version.getDependencies().size() - 1) { json.append(","); } @@ -83,28 +106,97 @@ private void writeManifest(ZipOutputStream zos, Project pack, ProjectVersion ver zos.closeEntry(); } - private void writeDependencyFiles(ZipOutputStream zos, ProjectVersion version) throws IOException { + private void writeDependencyFiles(ZipOutputStream zos, ProjectVersion version, Set archiveEntries) throws IOException { if (version.getDependencies() == null) { return; } for (ProjectDependency dependency : version.getDependencies()) { + if (dependency.isExternal()) { + writeExternalDependencyFile(zos, dependency, archiveEntries); + continue; + } + DownloadArchiveSupport.ResolvedDependency resolvedDependency = archiveSupport.resolveDependency(dependency); if (resolvedDependency == null || resolvedDependency.version().getFileUrl() == null) { continue; } 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()); - zos.putNextEntry(new ZipEntry(folder + originalFilename)); - zos.write(fileData); - zos.closeEntry(); + writeArchiveEntry(zos, archiveEntries, originalFilename, fileData); + } + } + + private void writeExternalDependencyFile(ZipOutputStream zos, ProjectDependency dependency, Set archiveEntries) throws IOException { + String cachedFileUrl = trimToNull(dependency.getCachedFileUrl()); + if (cachedFileUrl == null) { + return; + } + + try { + byte[] fileData = archiveSupport.download(cachedFileUrl); + if (fileData == null || fileData.length == 0) { + return; + } + + writeArchiveEntry(zos, archiveEntries, externalFilename(dependency), fileData); + } catch (StorageDownloadException ex) { + logger.warn("Unable to include cached external dependency {} from {} in generated modpack archive.", + dependency.getProjectTitle(), cachedFileUrl, ex); + } + } + + private void writeArchiveEntry(ZipOutputStream zos, Set archiveEntries, String filename, byte[] fileData) throws IOException { + String entryName = uniqueArchiveEntryName(archiveEntries, sanitizeArchiveFilename(filename)); + zos.putNextEntry(new ZipEntry(entryName)); + zos.write(fileData); + zos.closeEntry(); + } + + private String uniqueArchiveEntryName(Set archiveEntries, String filename) { + String candidate = filename; + int counter = 2; + while (!archiveEntries.add(candidate)) { + int extensionStart = filename.lastIndexOf('.'); + if (extensionStart > 0) { + candidate = filename.substring(0, extensionStart) + "-" + counter + filename.substring(extensionStart); + } else { + candidate = 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 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 void cacheArchive(Project pack, ProjectVersion version, byte[] zipBytes) { @@ -125,4 +217,18 @@ private void cacheArchive(Project pack, ProjectVersion version, byte[] zipBytes) pack.getId(), version.getVersionNumber(), ex); } } + + private String jsonEscape(String value) { + if (value == null) { + return ""; + } + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } } 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 008838c7..5b218295 100644 --- a/backend/src/test/java/net/modtale/controller/project/VersionControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/project/VersionControllerTest.java @@ -3,6 +3,7 @@ import java.time.Instant; import java.util.List; 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.project.Project; import net.modtale.model.project.ProjectClassification; @@ -100,15 +101,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); @@ -124,8 +128,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) @@ -206,7 +210,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( @@ -287,4 +291,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 02977573..c5a8a512 100644 --- a/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java +++ b/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java @@ -75,7 +75,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); @@ -89,7 +88,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()); @@ -125,7 +123,12 @@ 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); @@ -137,11 +140,13 @@ 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("/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()); @@ -213,7 +218,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..e6bb37b8 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,7 +126,7 @@ 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"); 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..8d60ae73 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,7 +58,7 @@ 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) ); } @@ -63,10 +67,10 @@ 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 +78,30 @@ 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().getModId()); + 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); + + 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 @@ -104,4 +129,20 @@ 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; + } } 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..d9407e6e 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 @@ -159,7 +159,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/DownloadServiceTest.java b/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java index 3897a90f..bce91d2e 100644 --- a/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java @@ -96,8 +96,8 @@ void generateModpackZipBuildsAndUploadsANewArchiveWhenTheCachedFileIsMissing() t 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")); + 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 +132,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"); 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..c4c1e1f6 100644 --- a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java @@ -52,12 +52,22 @@ void generateModpackZipReturnsCachedArchiveWhenDownloadSucceeds() throws Excepti } @Test - void generateModpackZipRebuildsCachesAndOrganizesDependencyFilesByClassification() throws Exception { + 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 +82,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 +91,11 @@ 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("plugin-binary", entries.get("plugin.jar")); + assertEquals("data-binary", entries.get("data.zip")); + assertEquals("external-binary", entries.get("External-Mod-1.0.0.jar")); assertEquals(true, entries.get("modpack.json").contains("\"id\": \"plugin\"")); + assertEquals(true, entries.get("modpack.json").contains("\"externalId\": \"1450386\"")); verify(projectRepository).save(pack); } diff --git a/frontend/src/data/categories.ts b/frontend/src/data/categories.ts index 7d490fa5..11af3024 100644 --- a/frontend/src/data/categories.ts +++ b/frontend/src/data/categories.ts @@ -23,11 +23,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/modules/core/components/Navbar.tsx b/frontend/src/modules/core/components/Navbar.tsx index 7283d598..9635fc3b 100644 --- a/frontend/src/modules/core/components/Navbar.tsx +++ b/frontend/src/modules/core/components/Navbar.tsx @@ -1,9 +1,10 @@ import React, { lazy, Suspense, useState, useRef, useEffect } from 'react'; -import { Menu, X, Upload, LayoutDashboard, User as UserIcon, LogOut, Shield, Users, LogIn, Code2, ChevronDown, Layout, FileCode, Database, Palette, Save, Layers, LayoutGrid } from 'lucide-react'; +import { Menu, X, Upload, LayoutDashboard, User as UserIcon, LogOut, Shield, Users, LogIn, Code2, ChevronDown, LayoutGrid } from 'lucide-react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { AnimatedThemeToggler } from '@/components/ui/AnimatedThemeToggler'; import { useMobile } from '@/context/MobileContext'; import { SiteRoutes } from '@/utils/routes'; +import { PROJECT_TYPES } from '@/data/categories'; import { isAdminUser } from '@/modules/admin/utils/access'; import type { User } from "@/types.ts"; @@ -142,55 +143,22 @@ export const Navbar: React.FC = ({ {isBrowseDropdownOpen && (
- setIsBrowseDropdownOpen(false)} - className="flex items-center px-4 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors" - > - - All Projects - -
- setIsBrowseDropdownOpen(false)} - className="flex items-center px-4 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors" - > - - Plugins - - setIsBrowseDropdownOpen(false)} - className="flex items-center px-4 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors" - > - - Modpacks - - setIsBrowseDropdownOpen(false)} - className="flex items-center px-4 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors" - > - - Worlds - - setIsBrowseDropdownOpen(false)} - className="flex items-center px-4 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors" - > - - Art Assets - - setIsBrowseDropdownOpen(false)} - className="flex items-center px-4 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors" - > - - Data Assets - + {PROJECT_TYPES.map((type, index) => { + const Icon = type.icon; + return ( + + {index === 1 &&
} + setIsBrowseDropdownOpen(false)} + className="flex items-center px-4 py-2.5 text-sm font-bold text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors" + > + + {type.label} + +
+ ); + })}
)} @@ -331,12 +299,20 @@ export const Navbar: React.FC = ({
Browse
- setIsMobileMenuOpen(false)} className="flex items-center px-5 py-2.5 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left text-sm"> All Projects - setIsMobileMenuOpen(false)} className="flex items-center px-5 py-2.5 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left text-sm"> Plugins - setIsMobileMenuOpen(false)} className="flex items-center px-5 py-2.5 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left text-sm"> Modpacks - setIsMobileMenuOpen(false)} className="flex items-center px-5 py-2.5 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left text-sm"> Worlds - setIsMobileMenuOpen(false)} className="flex items-center px-5 py-2.5 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left text-sm"> Art Assets - setIsMobileMenuOpen(false)} className="flex items-center px-5 py-2.5 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left text-sm"> Data Assets + {PROJECT_TYPES.map((type) => { + const Icon = type.icon; + return ( + setIsMobileMenuOpen(false)} + className="flex items-center px-5 py-2.5 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left text-sm" + > + + {type.label} + + ); + })}
diff --git a/frontend/src/modules/core/views/ApiDocs.tsx b/frontend/src/modules/core/views/ApiDocs.tsx index e13fdcdb..1a9a0ba3 100644 --- a/frontend/src/modules/core/views/ApiDocs.tsx +++ b/frontend/src/modules/core/views/ApiDocs.tsx @@ -521,7 +521,6 @@ const sampleProject = { issues: 'https://github.com/modtale/skyforge-utilities/issues', }, types: ['SERVER'], - modIds: ['67f70e06d5de9b5f94b6a111'], allowModpacks: true, allowComments: true, hmWikiEnabled: true, @@ -733,8 +732,9 @@ const endpointSpecificExample = (method: string, path: string, code: string): un if (path === '/api/v1/projects/{id}/versions/{version}/dependencies' && code === '200') { return { dependencies: [ - { projectId: '67f70e06d5de9b5f94b6a111', projectTitle: 'Skyforge Core', versionNumber: '3.1.0', isOptional: false }, - { projectId: '67f70e06d5de9b5f94b6a222', projectTitle: 'Skyforge Map Layer', versionNumber: '1.4.2', isOptional: true }, + { id: 'b612a7db-3475-4c43-bb46-c951b330bcd2', projectId: '67f70e06d5de9b5f94b6a111', projectTitle: 'Skyforge Core', versionNumber: '3.1.0', dependencyType: 'REQUIRED', source: 'MODTALE' }, + { id: '3c6b637b-76cb-4efe-9d20-c7b5356e2676', projectId: '67f70e06d5de9b5f94b6a222', projectTitle: 'Skyforge Map Layer', versionNumber: '1.4.2', dependencyType: 'OPTIONAL', source: 'MODTALE' }, + { id: '7737a837-76d3-414a-a149-c078ff981c0b', projectId: 'curseforge:1450386', projectTitle: 'SimpleCompost', versionNumber: '1.0.0', dependencyType: 'REQUIRED', source: 'CURSEFORGE', externalId: '1450386', externalUrl: 'https://www.curseforge.com/hytale/mods/simplecompost/files/8227810', externalFileUrl: 'https://www.curseforge.com/api/v1/mods/1450386/files/8227810/download', externalFileName: 'SimpleCompost-1.0.0.jar', cachedFileUrl: 'external-dependencies/curseforge/1450386/8227810/SimpleCompost-1.0.0.jar' }, ], }; } @@ -751,7 +751,6 @@ const endpointSpecificExample = (method: string, path: string, code: string): un versionNumber: '3.1.0', optional: false, confidence: 97, - dependencyEntry: '67f70e06d5de9b5f94b6a111:3.1.0', }, ], }; diff --git a/frontend/src/modules/home/components/FeaturePreviews.tsx b/frontend/src/modules/home/components/FeaturePreviews.tsx index 71b69498..f7a72b53 100644 --- a/frontend/src/modules/home/components/FeaturePreviews.tsx +++ b/frontend/src/modules/home/components/FeaturePreviews.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Link } from 'react-router-dom'; -import { Download, List, X, ChevronDown, ChevronRight, Check, Box, Link as LinkIcon, AlertCircle, Bell, Search, ArrowUpRight, MessageSquare, Send, Save, PieChart, TrendingUp, Eye, ArrowBigUp, ArrowBigDown, Settings } from 'lucide-react'; +import { Download, List, X, ChevronDown, ChevronRight, Check, Box, Link as LinkIcon, AlertCircle, Bell, Search, ArrowUpRight, MessageSquare, Send, Save, PieChart, TrendingUp, Eye, ArrowBigUp, ArrowBigDown, Settings, Layers } from 'lucide-react'; import { OptimizedImage } from '@/components/ui/OptimizedImage'; import { api, BACKEND_URL } from '@/utils/api'; import { SiteRoutes } from '@/utils/routes'; @@ -131,6 +131,99 @@ export const InlineDependencyUI = ({ randomProject, projects }: { randomProject? ); }; +const ModpackProjectRow = ({ + title, + version, + source, + tone, +}: { + title: string; + version: string; + source: string; + tone: 'local' | 'external' | 'warning'; +}) => { + const toneClass = tone === 'external' + ? 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-500/10 border-orange-200 dark:border-orange-500/20' + : tone === 'warning' + ? 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20' + : 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'; + + return ( +
+
+ {tone === 'warning' ? : tone === 'external' ? : } +
+
+
{title}
+
+ {source} + v{version} +
+
+
+ ); +}; + +export const InlineModpackBuilderUI = ({ randomProject }: { randomProject?: Project }) => { + const highlightedProject = randomProject?.title || 'Skylands Expansion'; + + return ( +
+
+
+
+ +
+
+

Modpack Builder

+

Vanilla+ Adventure Pack

+
+
+ + 6 projects + +
+ +
+
+ + Add a Hytale project or CurseForge URL +
+ +
+ + + +
+ +
+
+ +
+
Add 2 required dependencies?
+
QuestAPI and Terrain Shapes are required by selected projects.
+
+ + +
+
+
+
+ +
+
+ +
+
WeatherFX conflicts with Clear Skies
+
Resolve before publishing this pack.
+
+
+
+
+
+ ); +}; + export const InlineDownloadUI = () => { const [view, setView] = useState<'download' | 'changelog'>('download'); const [showExperimental, setShowExperimental] = useState(false); @@ -764,6 +857,28 @@ export const NewReleasesSection = ({ ); }; +export const ModpackPreviewSection = ({ randomProject }: { randomProject?: Project }) => { + return ( +
+
+

+ Modpacks, Upgraded +

+

+ Curated packs with dependency intelligence. +

+

+ Build complete Hytale experiences with required dependency prompts, incompatibility warnings, and CurseForge references when a project is not on Modtale yet. +

+
+
+
+ +
+
+ ); +}; + export const DirectDownloadsSection = () => { return (
diff --git a/frontend/src/modules/home/views/Home.tsx b/frontend/src/modules/home/views/Home.tsx index 861a5bb6..823bfabb 100644 --- a/frontend/src/modules/home/views/Home.tsx +++ b/frontend/src/modules/home/views/Home.tsx @@ -14,6 +14,7 @@ import { MarqueeColumn } from '../components/HeroMarquee'; import { TrendingProjectsSection, NewReleasesSection, + ModpackPreviewSection, DirectDownloadsSection, SmartDependenciesSection, ProjectAnalyticsSection, @@ -1136,6 +1137,10 @@ export const Home: React.FC<{
+ + + + diff --git a/frontend/src/modules/project/api/projectClient.ts b/frontend/src/modules/project/api/projectClient.ts index d25dd532..d5610d2a 100644 --- a/frontend/src/modules/project/api/projectClient.ts +++ b/frontend/src/modules/project/api/projectClient.ts @@ -1,5 +1,5 @@ import { api } from '@/utils/api'; -import type { Comment, GalleryImage, ManifestInspectionResult, Project, ProjectMember, ProjectVersion, ProjectVersionChangelog, User, ProjectRole, GameVersionCatalog } from '@/types'; +import type { Comment, DependencySource, ExternalProjectReference, GalleryImage, GameVersionCatalog, ManifestInspectionResult, Project, ProjectMember, ProjectRole, ProjectVersion, ProjectVersionChangelog, User } from '@/types'; import { normalizeUser, normalizeUsers } from '@/utils/users'; export const projectClient = { @@ -69,6 +69,12 @@ export const projectClient = { }); return res.data || {}; }, + resolveExternalProject: async (url: string, source?: DependencySource) => { + const res = await api.get('/projects/external/resolve', { + params: { url, source } + }); + return res.data; + }, followUser: async (targetId: string) => { await api.post(`/user/follow/${targetId}`); }, diff --git a/frontend/src/modules/project/components/DependencySelector.tsx b/frontend/src/modules/project/components/DependencySelector.tsx index a75a037c..424d71a4 100644 --- a/frontend/src/modules/project/components/DependencySelector.tsx +++ b/frontend/src/modules/project/components/DependencySelector.tsx @@ -1,247 +1,227 @@ -import React, { useState, useEffect, useMemo, useRef } from 'react'; -import { Search, Loader2, X, Plus, AlertTriangle, FileText, CheckSquare, ShieldCheck, RefreshCw, Check, AlertCircle, ChevronRight, ChevronDown, ToggleRight, ToggleLeft } from 'lucide-react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { AlertTriangle, CheckSquare, ChevronDown, ExternalLink, FileText, Loader2, PackagePlus, Plus, RefreshCw, Search, ShieldCheck, ToggleLeft, ToggleRight, X } from 'lucide-react'; import { projectClient } from '@/modules/project/api/projectClient'; import { compareSemVer } from '@/utils/modHelpers'; import { theme } from '@/styles/theme'; import { BACKEND_URL } from '@/utils/api'; -import type { Project, ProjectVersion, ProjectDependency } from '@/types'; +import type { DependencySource, DependencyType, ExternalProjectFile, ExternalProjectReference, Project, ProjectDependency, ProjectVersion } from '@/types'; import { VersionRelationKind } from '@/types'; import { useScrollLock } from '@/hooks/useScrollLock'; -import { parseDependencyEntry, serializeDependencyEntry } from '../utils/dependencyEntries'; +import { dependencyProjectKey, getDependencyType, isExternalDependency, isOptionalDependency, normalizeDependencyReference } from '../utils/dependencyEntries'; import { useToast } from '@/components/ui/Toast'; import { ModalPortal } from '@/components/ui/ModalPortal'; -interface DependencyWizardProps { - previousDeps: ProjectDependency[]; - targetGameVersion: string | undefined; - onConfirm: (newDeps: string[]) => void; - onClose: () => void; +type DependencyMeta = { title: string; author: string; icon: string; source?: string; url?: string }; +type DropdownOption = { value: T; label: string; detail?: string }; +interface DependencySelectorProps { + selectedDeps: ProjectDependency[] | string[]; + onChange: (deps: any[]) => void; + targetGameVersion?: string; + label?: string; + mode?: VersionRelationKind; + previousDependencies?: ProjectDependency[]; + currentProjectId?: string; + isModpack?: boolean; + disabled?: boolean; } -const DependencyRow: React.FC<{ dep: ProjectDependency; targetGameVersion: string | undefined; onSelect: (id: string, version: string | null) => void; initialSelection?: string; }> = ({ dep, targetGameVersion, onSelect, initialSelection }) => { - const [versions, setVersions] = useState([]); - const [loading, setLoading] = useState(false); - const [selectedVer, setSelectedVer] = useState(initialSelection || ''); - const [isOpen, setIsOpen] = useState(false); - const buttonRef = useRef(null); - const [dropdownStyle, setDropdownStyle] = useState({}); - - useEffect(() => { - const fetchVersions = async () => { - setLoading(true); - try { - const projectVersions = await projectClient.getProjectVersions(dep.projectId); - const sorted = projectVersions.sort((a, b) => compareSemVer(b.versionNumber, a.versionNumber)); - setVersions(sorted); - } catch (e) { - } finally { - setLoading(false); - } - }; - fetchVersions(); - }, [dep.projectId]); +const createUuid = () => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `dep-${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && buttonRef.current && !buttonRef.current.contains(event.target as Node)) { - const dropdownEl = document.getElementById(`dropdown-${dep.projectId}`); - if (dropdownEl && !dropdownEl.contains(event.target as Node)) setIsOpen(false); - } - }; - const handleScroll = () => { if (isOpen) setIsOpen(false); }; +const getIconUrl = (path?: string) => { + if (!path) return '/assets/favicon.svg'; + return path.startsWith('http') ? path : `${BACKEND_URL}${path}`; +}; - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('scroll', handleScroll, true); - window.addEventListener('resize', handleScroll); - } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('scroll', handleScroll, true); - window.removeEventListener('resize', handleScroll); - }; - }, [isOpen, dep.projectId]); - - const toggleOpen = () => { - if (!isOpen && buttonRef.current) { - const rect = buttonRef.current.getBoundingClientRect(); - setDropdownStyle({ - position: 'fixed', - top: `${rect.bottom + 4}px`, - left: `${rect.right - 256}px`, - width: '16rem', - zIndex: 9999 - }); - } - setIsOpen(!isOpen); - }; +const normalizeLookup = (value?: string) => (value || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); + +const getCurseForgeProjectSlug = (value: string): string | null => { + try { + const parsed = new URL(value); + const host = parsed.hostname.toLowerCase(); + const path = parsed.pathname.toLowerCase(); + if (!(host === 'curseforge.com' || host.endsWith('.curseforge.com'))) return null; + if (!path.startsWith('/hytale/') || !path.includes('/mods/')) return null; + const segments = parsed.pathname.split('/').filter(Boolean); + const modsIndex = segments.findIndex(segment => segment.toLowerCase() === 'mods'); + const slug = modsIndex >= 0 ? segments[modsIndex + 1] : ''; + return slug || null; + } catch { + return null; + } +}; - const compatibleVersions = useMemo(() => targetGameVersion ? versions.filter(v => v.gameVersions?.includes(targetGameVersion)) : versions, [versions, targetGameVersion]); - const incompatibleVersions = useMemo(() => targetGameVersion ? versions.filter(v => !v.gameVersions?.includes(targetGameVersion)) : [], [versions, targetGameVersion]); +const getSourceLabel = (source?: string) => { + switch (source) { + case 'CURSEFORGE': return 'CurseForge'; + case 'GITHUB': return 'GitHub'; + case 'WEBSITE': return 'Website'; + case 'OTHER': return 'External'; + case 'MODTALE': return 'Modtale'; + default: return 'External'; + } +}; - const handleSelect = (ver: string) => { - setSelectedVer(ver); - onSelect(dep.projectId, ver); - setIsOpen(false); - }; +const EXTERNAL_SOURCE_OPTIONS: DropdownOption[] = [ + { value: '', label: 'Auto-detect source', detail: 'Let Modtale infer the service from the URL' }, + { value: 'CURSEFORGE', label: 'CurseForge', detail: 'Hytale project or file page' }, + { value: 'GITHUB', label: 'GitHub', detail: 'Repository, release, or raw file URL' }, + { value: 'WEBSITE', label: 'Website', detail: 'Public Hytale project page' }, + { value: 'OTHER', label: 'Other', detail: 'Another public Hytale source' } +]; + +const DEPENDENCY_TYPE_OPTIONS: DropdownOption[] = [ + { value: 'REQUIRED', label: 'Required' }, + { value: 'OPTIONAL', label: 'Optional' }, + { value: 'EMBEDDED', label: 'Embedded' } +]; + +const CustomDropdown = ({ + value, + options, + onChange, + disabled, + className = '', + buttonClassName = '' +}: { + value: T; + options: DropdownOption[]; + onChange: (value: T) => void; + disabled?: boolean; + className?: string; + buttonClassName?: string; +}) => { + const [open, setOpen] = useState(false); + const selected = options.find(option => option.value === value) || options[0]; return ( -
-
-
{dep.projectTitle || dep.projectId}
-
- Previous: {dep.versionNumber} - {dep.isOptional && Optional} +
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setOpen(false); + } + }}> + + {open && ( +
+ {options.map(option => { + const selectedOption = option.value === value; + return ( + + ); + })}
-
- -
- {loading ? ( -
Loading...
- ) : ( - <> - - - {isOpen && ( -
- {compatibleVersions.length > 0 ? ( - <> -
Compatible
- {compatibleVersions.map(v => ( - - ))} - - ) : ( -
No compatible versions found.
- )} - - {incompatibleVersions.length > 0 && ( - <> -
Incompatible
- {incompatibleVersions.map(v => ( - - ))} - - )} -
- )} - - )} -
+ )}
); }; -const DependencyUpdateWizard: React.FC = ({ previousDeps, targetGameVersion, onConfirm, onClose }) => { +const buildModtaleDependency = (project: Project, versionNumber: string, dependencyType: DependencyType): ProjectDependency => ({ + id: createUuid(), + projectId: project.id, + projectTitle: project.title, + versionNumber, + dependencyType, + source: 'MODTALE' +}); + +const cloneDependencyForForm = (dependency: ProjectDependency, forceRequired = false): ProjectDependency => ({ + id: dependency.id || createUuid(), + projectId: dependency.projectId, + projectTitle: dependency.projectTitle, + versionNumber: dependency.versionNumber, + dependencyType: forceRequired ? 'REQUIRED' : getDependencyType(dependency), + source: dependency.source || 'MODTALE', + externalId: dependency.externalId, + externalUrl: dependency.externalUrl, + externalFileUrl: dependency.externalFileUrl, + externalFileName: dependency.externalFileName, + cachedFileUrl: dependency.cachedFileUrl, + hytaleProjectConfirmed: dependency.hytaleProjectConfirmed +}); + +const DependencyPrompt = ({ + projectTitle, + dependencies, + onAdd, + onClose +}: { + projectTitle: string; + dependencies: ProjectDependency[]; + onAdd: () => void; + onClose: () => void; +}) => { useScrollLock(true); - const [selections, setSelections] = useState>({}); - - const handleRowSelect = (id: string, ver: string | null) => setSelections(prev => ({ ...prev, [id]: ver })); - - const handleConfirm = () => { - const result: string[] = []; - previousDeps.forEach(dep => { - const newVer = selections[dep.projectId]; - if (newVer) { - result.push(serializeDependencyEntry({ - projectId: dep.projectId, - versionNumber: newVer, - isOptional: Boolean(dep.isOptional), - isEmbedded: Boolean(dep.isEmbedded) - })); - } - }); - onConfirm(result); - }; - - const validCount = Object.values(selections).filter(v => v !== null).length; - return ( -
-
e.stopPropagation()}> -
+
+
-

- Update Dependencies -

-

Select versions for the {previousDeps.length} projects from the previous release.

+

Add Dependencies

+

{projectTitle} needs {dependencies.length} project{dependencies.length === 1 ? '' : 's'} that are not in this pack yet.

- +
- -
- {!targetGameVersion && ( -
- - Note: No Game Version selected. Showing all available versions. Select a Game Version first to filter compatible projects. +
+ {dependencies.map(dep => ( +
+
+
{dep.projectTitle || dep.projectId}
+
v{dep.versionNumber}
+
+ + {getDependencyType(dep)} +
- )} -
- {previousDeps.map(dep => ( - - ))} -
+ ))}
- -
-
{validCount} selected
-
- - -
+
+ +
- ); }; -interface DependencySelectorProps { - selectedDeps: string[]; - onChange: (deps: string[]) => void; - targetGameVersion?: string; - label?: string; - mode?: VersionRelationKind; - previousDependencies?: ProjectDependency[]; - currentProjectId?: string; - isModpack?: boolean; - disabled?: boolean; -} - export const DependencySelector: React.FC = ({ selectedDeps, onChange, targetGameVersion, label, - mode = 'dependency', + mode = VersionRelationKind.DEPENDENCY, previousDependencies, currentProjectId, isModpack = false, @@ -250,237 +230,457 @@ export const DependencySelector: React.FC = ({ const [search, setSearch] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); - const [selectedModForVersion, setSelectedModForVersion] = useState(null); + const [selectedProject, setSelectedProject] = useState(null); const [loadingProjectVersions, setLoadingProjectVersions] = useState(false); - const [showIncompatible, setShowIncompatible] = useState(false); + const [showIncompatibleVersions, setShowIncompatibleVersions] = useState(false); const [showAlphaBeta, setShowAlphaBeta] = useState(false); - const [isOptional, setIsOptional] = useState(false); - const [isEmbedded, setIsEmbedded] = useState(false); - const [showWizard, setShowWizard] = useState(false); + const [dependencyType, setDependencyType] = useState('REQUIRED'); + const [showPreviousImport, setShowPreviousImport] = useState(false); + const [pendingPrompt, setPendingPrompt] = useState<{ projectTitle: string; dependencies: ProjectDependency[]; baseDeps: ProjectDependency[] } | null>(null); + const [projectCache, setProjectCache] = useState>({}); + const [metaCache, setMetaCache] = useState>({}); + const [showExternalModal, setShowExternalModal] = useState(false); + const [externalTitle, setExternalTitle] = useState(''); + const [externalVersion, setExternalVersion] = useState(''); + const [externalUrl, setExternalUrl] = useState(''); + const [externalSource, setExternalSource] = useState(''); + const [externalResolved, setExternalResolved] = useState(null); + const [externalSelectedFileId, setExternalSelectedFileId] = useState(''); + const [externalConfirmed, setExternalConfirmed] = useState(false); + const [resolvingExternal, setResolvingExternal] = useState(false); + const [externalError, setExternalError] = useState(null); + const [externalSuggestions, setExternalSuggestions] = useState([]); + const [loadingExternalSuggestions, setLoadingExternalSuggestions] = useState(false); const { showToast } = useToast(); + const isIncompatibilityMode = mode === VersionRelationKind.INCOMPATIBILITY; + const dependencies = useMemo(() => (isIncompatibilityMode ? [] : (selectedDeps as ProjectDependency[]).map(normalizeDependencyReference)), [isIncompatibilityMode, selectedDeps]); + const incompatibleIds = useMemo(() => (isIncompatibilityMode ? (selectedDeps as string[]) : []), [isIncompatibilityMode, selectedDeps]); const effectiveLabel = label ?? (isIncompatibilityMode ? 'Incompatible Mods' : 'Dependencies'); + const selectedProjectIds = useMemo(() => new Set(isIncompatibilityMode ? incompatibleIds : dependencies.map(dep => dep.projectId)), [dependencies, incompatibleIds, isIncompatibilityMode]); - const [metaCache, setMetaCache] = useState>({}); + useScrollLock(Boolean(selectedProject || pendingPrompt || showExternalModal)); useEffect(() => { - const fetchMeta = async () => { - const missingIds = selectedDeps.map(d => d.split(':')[0]).filter(id => !metaCache[id]); - if (missingIds.length === 0) return; - const newCache = { ...metaCache }; - await Promise.all(missingIds.map(async (id) => { - try { - const data = await projectClient.getDependencyMeta(id); - newCache[id] = { title: data.title, author: data.author, icon: data.icon }; - } catch (e) { newCache[id] = { title: id, author: 'Unknown', icon: '' }; } - })); - setMetaCache(newCache); - }; - fetchMeta(); - }, [selectedDeps.length, metaCache, selectedDeps]); + if (isIncompatibilityMode) return; + + const missing = dependencies + .filter(dep => dep.source === 'MODTALE' && !metaCache[dep.projectId]) + .map(dep => dep.projectId); + const externalCache: Record = {}; + dependencies + .filter(dep => isExternalDependency(dep) && !metaCache[dep.projectId]) + .forEach(dep => { + externalCache[dep.projectId] = { title: dep.projectTitle, author: getSourceLabel(dep.source), icon: '', source: dep.source, url: dep.externalUrl }; + }); - useScrollLock(selectedModForVersion !== null); + if (Object.keys(externalCache).length) { + setMetaCache(prev => ({ ...prev, ...externalCache })); + } + if (!missing.length) return; + + let cancelled = false; + Promise.all([...new Set(missing)].map(async id => { + try { + const data = await projectClient.getDependencyMeta(id); + return [id, { title: data.title, author: data.author, icon: data.icon }] as const; + } catch { + return [id, { title: id, author: 'Unknown', icon: '' }] as const; + } + })).then(entries => { + if (!cancelled) setMetaCache(prev => ({ ...prev, ...Object.fromEntries(entries) })); + }); + return () => { cancelled = true; }; + }, [dependencies, isIncompatibilityMode, metaCache]); useEffect(() => { - if (selectedModForVersion && !isIncompatibilityMode) { - const compatible = (selectedModForVersion.versions || []).filter(v => !targetGameVersion || v.gameVersions?.includes(targetGameVersion)); - const hasRelease = compatible.some(v => !v.channel || v.channel === 'RELEASE'); - const hasAny = compatible.length > 0; - setShowAlphaBeta(hasAny && !hasRelease); - } - }, [selectedModForVersion, targetGameVersion, isIncompatibilityMode]); + if (isIncompatibilityMode) return; + const missing = dependencies + .filter(dep => dep.source === 'MODTALE' && !projectCache[dep.projectId]) + .map(dep => dep.projectId); + if (!missing.length) return; + + let cancelled = false; + Promise.all([...new Set(missing)].map(async id => { + try { + return [id, await projectClient.getProject(id)] as const; + } catch { + return null; + } + })).then(entries => { + if (cancelled) return; + const next = Object.fromEntries(entries.filter((entry): entry is readonly [string, Project] => Boolean(entry))); + if (Object.keys(next).length) setProjectCache(prev => ({ ...prev, ...next })); + }); + return () => { cancelled = true; }; + }, [dependencies, isIncompatibilityMode, projectCache]); useEffect(() => { const timer = setTimeout(async () => { - if (search.length < 2 || disabled) return; + if (search.length < 2 || disabled) { + setResults([]); + return; + } setLoading(true); try { const data = await projectClient.searchProjects(search); - const filtered = data.filter((m: Project) => m.classification !== 'MODPACK' && m.classification !== 'SAVE' && m.id !== currentProjectId); - setResults(filtered); - } catch (e) { setResults([]); } finally { setLoading(false); } + setResults(data.filter((project: Project) => project.classification !== 'MODPACK' && project.classification !== 'SAVE' && project.id !== currentProjectId)); + } catch { + setResults([]); + } finally { + setLoading(false); + } }, 300); return () => clearTimeout(timer); }, [search, currentProjectId, disabled]); - const selectedProjectIds = useMemo(() => new Set( - selectedDeps - .map((dep) => { - if (isIncompatibilityMode) return dep.trim(); - return parseDependencyEntry(dep).projectId; - }) - .filter((id): id is string => Boolean(id)) - ), [selectedDeps, isIncompatibilityMode]); + useEffect(() => { + if (!selectedProject || isIncompatibilityMode) return; + const compatible = (selectedProject.versions || []).filter(v => !targetGameVersion || v.gameVersions?.includes(targetGameVersion)); + setShowAlphaBeta(compatible.length > 0 && !compatible.some(v => !v.channel || v.channel === 'RELEASE')); + }, [selectedProject, targetGameVersion, isIncompatibilityMode]); - const addProjectSelection = (project: Project, versionNumber?: string) => { - if (disabled) return; - if (selectedProjectIds.has(project.id)) { - showToast("Project already added.", 'info'); + useEffect(() => { + if (!showExternalModal || externalTitle.trim().length < 2) { + setExternalSuggestions([]); return; } - setMetaCache(prev => ({ ...prev, [project.id]: { title: project.title, author: project.author, icon: project.imageUrl } })); + let cancelled = false; + setLoadingExternalSuggestions(true); + const timer = setTimeout(async () => { + try { + const data = await projectClient.searchProjects(externalTitle.trim()); + const titleKey = normalizeLookup(externalTitle); + const urlSlug = normalizeLookup(getCurseForgeProjectSlug(externalUrl) || ''); + const suggestions = data + .filter((project: Project) => project.classification !== 'MODPACK' && project.classification !== 'SAVE') + .filter((project: Project) => { + const projectTitle = normalizeLookup(project.title); + const projectSlug = normalizeLookup(project.slug); + return projectTitle === titleKey || projectSlug === titleKey || (urlSlug && projectSlug === urlSlug); + }) + .slice(0, 3); + if (!cancelled) setExternalSuggestions(suggestions); + } catch { + if (!cancelled) setExternalSuggestions([]); + } finally { + if (!cancelled) setLoadingExternalSuggestions(false); + } + }, 250); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [showExternalModal, externalTitle, externalUrl]); - if (isIncompatibilityMode) { - onChange([...selectedDeps, project.id]); - } else if (versionNumber) { - const finalOptional = isModpack ? false : isOptional; - const entry = serializeDependencyEntry({ - projectId: project.id, - versionNumber, - isOptional: finalOptional, - isEmbedded - }); - onChange([...selectedDeps, entry]); + useEffect(() => { + setExternalResolved(null); + setExternalSelectedFileId(''); + }, [externalUrl]); + + const availableVersions = useMemo(() => { + if (!selectedProject?.versions) return []; + return [...selectedProject.versions].sort((a, b) => compareSemVer(b.versionNumber, a.versionNumber)); + }, [selectedProject]); + + const filteredVersions = availableVersions.filter(version => { + const versionMatch = !targetGameVersion || version.gameVersions?.includes(targetGameVersion); + const channelMatch = showAlphaBeta || !version.channel || version.channel === 'RELEASE'; + return (showIncompatibleVersions || versionMatch) && channelMatch; + }); + + const conflictWarnings = useMemo(() => { + if (isIncompatibilityMode) return []; + const internalDeps = dependencies.filter(dep => dep.source === 'MODTALE'); + const selectedIds = new Set(internalDeps.map(dep => dep.projectId)); + const warnings = new Map(); + + for (const dep of internalDeps) { + const project = projectCache[dep.projectId]; + const version = project?.versions?.find(candidate => candidate.versionNumber === dep.versionNumber); + for (const incompatibleId of version?.incompatibleProjectIds || []) { + if (!selectedIds.has(incompatibleId)) continue; + const key = [dep.projectId, incompatibleId].sort().join(':'); + if (!warnings.has(key)) warnings.set(key, { from: dep, toId: incompatibleId }); + } } - setSelectedModForVersion(null); - setIsOptional(false); - setIsEmbedded(false); + return [...warnings.values()].map(({ from, toId }) => ({ + from, + toId, + toTitle: metaCache[toId]?.title || projectCache[toId]?.title || toId + })); + }, [dependencies, isIncompatibilityMode, metaCache, projectCache]); + + const externalDependencyWarnings = useMemo(() => { + if (isIncompatibilityMode) return []; + return dependencies.filter(isExternalDependency).map(dep => ({ + dependency: dep, + sourceLabel: getSourceLabel(dep.source) + })); + }, [dependencies, isIncompatibilityMode]); + + const addDependency = (dependency: ProjectDependency, sourceProject?: Project, sourceVersion?: ProjectVersion) => { + if (disabled) return; + const normalized = normalizeDependencyReference(dependency); + if (selectedProjectIds.has(normalized.projectId)) { + showToast('Project already added.', 'info'); + return; + } + const nextDeps = [...dependencies, normalized]; + + const missingDependencies = !isIncompatibilityMode && sourceVersion?.dependencies + ? sourceVersion.dependencies + .map(dep => cloneDependencyForForm(dep, isModpack)) + .filter(dep => !nextDeps.some(existing => dependencyProjectKey(existing) === dependencyProjectKey(dep))) + .filter(dep => dep.projectId !== currentProjectId) + : []; + + onChange(nextDeps); setSearch(''); setResults([]); + setSelectedProject(null); + setDependencyType('REQUIRED'); + + if (sourceProject) { + setMetaCache(prev => ({ ...prev, [sourceProject.id]: { title: sourceProject.title, author: sourceProject.author, icon: sourceProject.imageUrl } })); + setProjectCache(prev => ({ ...prev, [sourceProject.id]: sourceProject })); + } + if (missingDependencies.length) { + setPendingPrompt({ projectTitle: normalized.projectTitle, dependencies: missingDependencies, baseDeps: nextDeps }); + } }; - const confirmVersion = (versionNumber: string) => { - if (!selectedModForVersion || disabled) return; - addProjectSelection(selectedModForVersion, versionNumber); + const addIncompatibleProject = (project: Project) => { + if (selectedProjectIds.has(project.id)) { + showToast('Project already added.', 'info'); + return; + } + onChange([...incompatibleIds, project.id]); + setSearch(''); + setResults([]); + setMetaCache(prev => ({ ...prev, [project.id]: { title: project.title, author: project.author, icon: project.imageUrl } })); }; - const openVersionPicker = async (mod: Project) => { + const openVersionPicker = async (project: Project) => { if (disabled) return; if (isIncompatibilityMode) { - addProjectSelection(mod); + addIncompatibleProject(project); return; } setLoadingProjectVersions(true); try { - const versions = mod.versions || await projectClient.getProjectVersions(mod.id); - setSelectedModForVersion({ ...mod, versions }); - } catch (e) { - setSelectedModForVersion({ ...mod, versions: mod.versions || [] }); - } finally { + const fullProject = project.versions ? project : await projectClient.getProject(project.id); + setSelectedProject({ ...fullProject, versions: fullProject.versions || [] }); + } catch { + setSelectedProject({ ...project, versions: project.versions || [] }); } finally { setLoadingProjectVersions(false); } }; - const removeDep = (index: number) => { - if(disabled) return; - const next = [...selectedDeps]; next.splice(index, 1); onChange(next); - }; - - const toggleOptionalExisting = (index: number) => { - if (isModpack || disabled) return; + const removeSelected = (index: number) => { + if (disabled) return; const next = [...selectedDeps]; - const parsed = parseDependencyEntry(next[index]); - next[index] = serializeDependencyEntry({ - ...parsed, - isOptional: !parsed.isOptional - }); + next.splice(index, 1); onChange(next); }; - const toggleEmbeddedExisting = (index: number) => { - if (disabled) return; - const next = [...selectedDeps]; - const parsed = parseDependencyEntry(next[index]); - next[index] = serializeDependencyEntry({ - ...parsed, - isEmbedded: !parsed.isEmbedded - }); + const cycleDependencyType = (index: number, nextType: DependencyType) => { + if (disabled || isModpack || isIncompatibilityMode) return; + const next = [...dependencies]; + next[index] = { ...next[index], dependencyType: nextType }; onChange(next); }; - const getIconUrl = (path?: string) => { if (!path) return '/assets/favicon.svg'; return path.startsWith('http') ? path : `${BACKEND_URL}${path}`; }; + const resolveExternalDetails = async () => { + if (!externalUrl.trim()) { + setExternalError('Enter an external project URL.'); + return null; + } - const availableVersions = useMemo(() => { - if (!selectedModForVersion?.versions) return []; - return [...selectedModForVersion.versions].sort((a, b) => compareSemVer(b.versionNumber, a.versionNumber)); - }, [selectedModForVersion]); - - const filteredVersions = availableVersions.filter(v => { - const versionMatch = !targetGameVersion || v.gameVersions?.includes(targetGameVersion); - const channelMatch = showAlphaBeta || !v.channel || v.channel === 'RELEASE'; - return (showIncompatible || versionMatch) && channelMatch; - }); + setResolvingExternal(true); + setExternalError(null); + try { + const resolved = await projectClient.resolveExternalProject(externalUrl.trim(), externalSource || undefined); + setExternalResolved(resolved); + setExternalSource(resolved.source); + setExternalTitle(current => current.trim() || resolved.title || ''); + setExternalVersion(current => current.trim() || resolved.versionNumber || 'latest'); + if (resolved.files?.length) { + const firstFile = resolved.files[0]; + setExternalSelectedFileId(firstFile.id || ''); + if (firstFile.versionNumber) setExternalVersion(firstFile.versionNumber); + } + return resolved; + } catch (error: any) { + setExternalError(error?.response?.data?.message || 'Could not resolve that external project.'); + return null; + } finally { + setResolvingExternal(false); + } + }; + + const selectedExternalFile = useMemo(() => { + if (!externalResolved?.files?.length || !externalSelectedFileId) return null; + return externalResolved.files.find(file => (file.id || file.downloadUrl || file.displayName || file.fileName || '') === externalSelectedFileId) || null; + }, [externalResolved, externalSelectedFileId]); + + const externalFileOptions = useMemo[]>(() => ( + (externalResolved?.files || []).map((file: ExternalProjectFile) => ({ + value: file.id || file.downloadUrl || file.displayName || file.fileName || '', + label: file.displayName || file.fileName || file.id || 'External file', + detail: file.versionNumber ? `Version ${file.versionNumber}` : file.fileName || file.releaseType + })) + ), [externalResolved]); + + const addExternalReference = async () => { + const resolved = externalResolved || await resolveExternalDetails(); + if (!resolved) return; + + const hytaleProjectConfirmed = resolved.hytaleProjectConfirmed || externalConfirmed; + if (!hytaleProjectConfirmed) { + setExternalError('Confirm this external project is for Hytale before adding it.'); + return; + } + + const title = externalTitle.trim() || resolved.title; + const version = selectedExternalFile?.versionNumber || externalVersion.trim() || resolved.versionNumber || 'latest'; + if (!title || !version) { + setExternalError('Title and version are required.'); + return; + } + + const source = resolved.source; + if (source === 'CURSEFORGE' && !selectedExternalFile?.downloadUrl) { + setExternalError('Choose a CurseForge file so Modtale can cache it.'); + return; + } + + const projectId = `${source.toLowerCase()}:${resolved.externalId}`; + if (selectedProjectIds.has(projectId)) { + setExternalError('That external reference is already added.'); + return; + } + let referenceUrl = resolved.externalUrl; + if (source === 'CURSEFORGE' && selectedExternalFile?.id) { + const slug = getCurseForgeProjectSlug(resolved.externalUrl || externalUrl); + if (slug) referenceUrl = `https://www.curseforge.com/hytale/mods/${slug}/files/${selectedExternalFile.id}`; + } + const dependency: ProjectDependency = { + id: createUuid(), + projectId, + projectTitle: title, + versionNumber: version, + dependencyType: isModpack ? 'REQUIRED' : dependencyType, + source, + externalId: resolved.externalId, + externalUrl: referenceUrl, + externalFileUrl: selectedExternalFile?.downloadUrl, + externalFileName: selectedExternalFile?.fileName || selectedExternalFile?.displayName, + hytaleProjectConfirmed + }; + onChange([...dependencies, dependency]); + setMetaCache(prev => ({ ...prev, [projectId]: { title: dependency.projectTitle, author: getSourceLabel(source), icon: resolved.iconUrl || '', source, url: dependency.externalUrl } })); + setShowExternalModal(false); + setExternalTitle(''); + setExternalVersion(''); + setExternalUrl(''); + setExternalSource(''); + setExternalResolved(null); + setExternalSelectedFileId(''); + setExternalConfirmed(false); + setExternalError(null); + }; + + const selectedCount = selectedDeps.length; return (
- {showWizard && previousDependencies && !disabled && !isIncompatibilityMode && ( - { - const currentIds = new Set(selectedDeps.map(d => d.split(':')[0])); - const toAdd = newDeps.filter(d => !currentIds.has(d.split(':')[0])); - onChange([...selectedDeps, ...toAdd]); - setShowWizard(false); + {pendingPrompt && ( + setPendingPrompt(null)} + onAdd={() => { + const existingKeys = new Set(pendingPrompt.baseDeps.map(dependencyProjectKey)); + const toAdd = pendingPrompt.dependencies.filter(dep => !existingKeys.has(dependencyProjectKey(dep))); + onChange([...pendingPrompt.baseDeps, ...toAdd]); + setPendingPrompt(null); }} - onClose={() => setShowWizard(false)} /> )} - {selectedModForVersion && !disabled && !isIncompatibilityMode && ( + {selectedProject && !disabled && !isIncompatibilityMode && (
-
e.stopPropagation()}> -
-

Select Version

- +
+
+
+

Select Version

+

{selectedProject.title}

+
+
-
+
Show Alpha/Beta -
{!isModpack && ( -
setIsOptional(!isOptional)}> -
- {isOptional && } -
- Optional Dependency +
+ {(['REQUIRED', 'OPTIONAL', 'EMBEDDED'] as DependencyType[]).map(type => ( + + ))}
)} -
setIsEmbedded(!isEmbedded)}> -
- {isEmbedded && } -
- Embedded Dependency -
-
+
{loadingProjectVersions ? (
Loading versions...
- ) : filteredVersions.length > 0 ? filteredVersions.map(v => { - const isCompatible = !targetGameVersion || v.gameVersions?.includes(targetGameVersion); + ) : filteredVersions.length > 0 ? filteredVersions.map(version => { + const isCompatible = !targetGameVersion || version.gameVersions?.includes(targetGameVersion); return ( - - ) + ); }) : (
No compatible versions found with current filters.
)}
{targetGameVersion && ( -
- +
+
)}
@@ -488,40 +688,172 @@ export const DependencySelector: React.FC = ({ )} -
+ {showExternalModal && !disabled && !isIncompatibilityMode && ( +
+
+
+
+

External Reference

+

CurseForge, GitHub, or another Hytale source.

+
+ +
+
+
+ setExternalUrl(event.target.value)} className={`w-full ${theme.colors.bgBase} border ${theme.colors.border} rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-modtale-accent ${theme.colors.textPrimary}`} placeholder="Paste an external project URL" /> + +
+ + + {externalResolved && ( +
+
+ {externalResolved.iconUrl ? : } +
+
+
{externalResolved.title}
+
{getSourceLabel(externalResolved.source)} · {externalResolved.externalId}
+ {externalResolved.summary &&

{externalResolved.summary}

} +
+
+ )} + + {externalFileOptions.length > 0 && ( + { + const nextFile = externalResolved?.files?.find(file => (file.id || file.downloadUrl || file.displayName || file.fileName || '') === nextFileId); + setExternalSelectedFileId(nextFileId); + if (nextFile?.versionNumber) setExternalVersion(nextFile.versionNumber); + }} + /> + )} + + {!isModpack && ( +
+ {(['REQUIRED', 'OPTIONAL', 'EMBEDDED'] as DependencyType[]).map(type => ( + + ))} +
+ )} + + setExternalTitle(event.target.value)} className={`w-full ${theme.colors.bgBase} border ${theme.colors.border} rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-modtale-accent ${theme.colors.textPrimary}`} placeholder="Project title" /> + setExternalVersion(event.target.value)} className={`w-full ${theme.colors.bgBase} border ${theme.colors.border} rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-modtale-accent ${theme.colors.textPrimary}`} placeholder="Version" /> + {externalResolved && !externalResolved.hytaleProjectConfirmed && ( + + )} + + {(loadingExternalSuggestions || externalSuggestions.length > 0) && ( +
+
Modtale Matches
+ {loadingExternalSuggestions ? ( +
Searching...
+ ) : externalSuggestions.map(project => ( + + ))} +
+ )} + + {externalError && ( +
+ {externalError} +
+ )} +
+
+ + +
+
+
+ )} + +

{effectiveLabel}

+ {!isIncompatibilityMode && ( + + )}
- {previousDependencies && previousDependencies.length > 0 && selectedDeps.length === 0 && !disabled && !isIncompatibilityMode && ( -
-
- -
+ {previousDependencies && previousDependencies.length > 0 && selectedCount === 0 && !disabled && !isIncompatibilityMode && ( +
+
+ +

Updates Available

Found {previousDependencies.length} projects from the previous release.

- + +
+ )} + + {showPreviousImport && previousDependencies && ( +
+
+ Previous Dependencies + +
+
+ {previousDependencies.map(dep => ( +
+
+
{dep.projectTitle || dep.projectId}
+
v{dep.versionNumber}
+
+ +
+ ))} +
)}
- setSearch(e.target.value)} className={`w-full ${theme.colors.bgBase} border ${theme.colors.border} rounded-xl pl-10 pr-4 py-3 text-sm focus:ring-2 focus:ring-modtale-accent outline-none shadow-sm transition-all ${theme.colors.textPrimary} ${disabled ? `cursor-not-allowed ${theme.colors.bgSurfaceAlt}` : ''}`} placeholder="Search for projects..." /> + setSearch(event.target.value)} className={`w-full ${theme.colors.bgBase} border ${theme.colors.border} rounded-xl pl-10 pr-4 py-3 text-sm focus:ring-2 focus:ring-modtale-accent outline-none shadow-sm transition-all ${theme.colors.textPrimary} ${disabled ? `cursor-not-allowed ${theme.colors.bgSurfaceAlt}` : ''}`} placeholder="Search for projects..." /> {loading &&
}
{results.length > 0 && !disabled && (
- {results.map(mod => ( -
)} + {conflictWarnings.length > 0 && ( +
+
+ Incompatible projects in this set +
+ {conflictWarnings.map(warning => ( +
+ {warning.from.projectTitle} marks {warning.toTitle} as incompatible. +
+ ))} +
+ )} + + {externalDependencyWarnings.length > 0 && ( +
+
+ External service references +
+ {externalDependencyWarnings.map(({ dependency, sourceLabel }) => ( +
+ {dependency.projectTitle || dependency.projectId} is an external {isModpack ? 'modpack entry' : 'dependency'} from {sourceLabel} + {dependency.externalFileUrl ? <>; its file is also sourced from {sourceLabel}. : <>.} +
+ ))} +
+ )} +
-

Selected ({selectedDeps.length})

+

Selected ({selectedCount})

- {selectedDeps.length === 0 ? ( + {selectedCount === 0 ? (
{isIncompatibilityMode ? 'No incompatible mods added.' : 'No dependencies added.'}
) : (
- {selectedDeps.map((entry, idx) => { - const parsed = isIncompatibilityMode ? null : parseDependencyEntry(entry); - const id = isIncompatibilityMode ? entry : (parsed?.projectId || ''); - const ver = isIncompatibilityMode ? '' : (parsed?.versionNumber || ''); - const isOpt = isIncompatibilityMode ? false : Boolean(parsed?.isOptional); - const embedded = isIncompatibilityMode ? false : Boolean(parsed?.isEmbedded); + {(isIncompatibilityMode ? incompatibleIds : dependencies).map((entry: string | ProjectDependency, index: number) => { + const dependency = typeof entry === 'string' ? null : entry; + const id = typeof entry === 'string' ? entry : entry.projectId; const meta = metaCache[id]; + const isExternal = dependency ? isExternalDependency(dependency) : false; + const depType = dependency ? getDependencyType(dependency) : 'REQUIRED'; return ( -
-
+
+
{!isIncompatibilityMode && ( -
- {isOpt ? : } +
+ {depType === 'OPTIONAL' ? : depType === 'EMBEDDED' ? : }
)} - e.currentTarget.src='/assets/favicon.svg'} /> + {isExternal ? ( +
+ +
+ ) : ( + event.currentTarget.src='/assets/favicon.svg'} /> + )}
-
{meta?.title || id}
+
{dependency?.projectTitle || meta?.title || id}
{isIncompatibilityMode ? (
Marked as incompatible
) : ( -
- by {meta?.author || '...'} +
+ {isExternal ? `External: ${getSourceLabel(dependency?.source)}` : `by ${meta?.author || '...'}`} - v{ver} + v{dependency?.versionNumber}
)}
-
- {!isIncompatibilityMode && !isModpack && ( - +
+ {dependency && !isModpack && !isIncompatibilityMode && ( + cycleDependencyType(index, nextType)} + disabled={disabled} + className="w-32" + buttonClassName="rounded-lg px-2 py-1.5 text-xs" + /> )} - {!isIncompatibilityMode && ( - + {dependency && isExternal && dependency.externalUrl && ( + )} - +
); diff --git a/frontend/src/modules/project/components/FormShared.tsx b/frontend/src/modules/project/components/FormShared.tsx index 8e9a58c3..0f760b31 100644 --- a/frontend/src/modules/project/components/FormShared.tsx +++ b/frontend/src/modules/project/components/FormShared.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { theme } from '@/styles/theme'; +import type { ProjectDependency } from '@/types'; export type MetadataFormData = { title: string; @@ -16,15 +17,13 @@ export type MetadataFormData = { }; export type VersionFormData = { - projectIds: string[]; + dependencies: ProjectDependency[]; incompatibleProjectIds: string[]; channel?: 'RELEASE' | 'BETA' | 'ALPHA'; versionNumber: string; gameVersions: string[]; changelog: string; file: File | null; - dependencies: string[]; - modIds: any[]; replaceExisting?: boolean; }; diff --git a/frontend/src/modules/project/components/ProjectCard.tsx b/frontend/src/modules/project/components/ProjectCard.tsx index 4c26c6f2..89baf093 100644 --- a/frontend/src/modules/project/components/ProjectCard.tsx +++ b/frontend/src/modules/project/components/ProjectCard.tsx @@ -133,7 +133,7 @@ export const ProjectCard: React.FC = React.memo(({ project, pa const downloads = (project.downloadCount || 0).toLocaleString(); const timeAgo = project.updatedAt ? formatTimeAgo(project.updatedAt) : null; - const childCount = (project.projectIds || project.childProjectIds || []).length; + const childCount = (project.childProjectIds || []).length; const displayClassification = toTitleCase(classification); const baseFavoriteCount = project.favoriteCount || 0; const [displayFavoriteCount, setDisplayFavoriteCount] = useState(baseFavoriteCount); diff --git a/frontend/src/modules/project/components/ProjectMetaSections.tsx b/frontend/src/modules/project/components/ProjectMetaSections.tsx index 0b4030d0..4597d9a0 100644 --- a/frontend/src/modules/project/components/ProjectMetaSections.tsx +++ b/frontend/src/modules/project/components/ProjectMetaSections.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { AlertTriangle, Box, ChevronRight, Gamepad2, Link as LinkIcon, Tag } from 'lucide-react'; +import { AlertTriangle, Box, ChevronRight, ExternalLink, Gamepad2, Link as LinkIcon, Tag } from 'lucide-react'; import { SidebarSection } from '@/modules/project/components/ProjectLayout'; import { OptimizedImage } from '@/components/ui/OptimizedImage'; @@ -8,6 +8,7 @@ import { SiteRoutes } from '@/utils/routes'; import { BACKEND_URL } from '@/utils/api'; import { buildVersionGroups, compareGameVersionsDesc, type VersionGroup } from '@/utils/modHelpers'; import type { Project, ProjectDependency } from '@/types'; +import { getDependencyType, isEmbeddedDependency, isExternalDependency, isOptionalDependency } from '../utils/dependencyEntries'; interface ProjectMetaSectionsProps { project: Project; @@ -17,6 +18,17 @@ interface ProjectMetaSectionsProps { orderedGameVersions?: string[]; } +const getSourceLabel = (source?: string) => { + switch (source) { + case 'CURSEFORGE': return 'CurseForge'; + case 'GITHUB': return 'GitHub'; + case 'WEBSITE': return 'Website'; + case 'OTHER': return 'External'; + case 'MODTALE': return 'Modtale'; + default: return 'External'; + } +}; + const SupportedVersionPill = ({ version }: { version: string }) => ( {version} @@ -81,6 +93,10 @@ export const ProjectMetaSections: React.FC = React.mem const isModpack = project.classification === 'MODPACK'; const getIconUrl = (path?: string) => path ? (path.startsWith('http') ? path : `${BACKEND_URL}${path}`) : null; + const externalDependencies = React.useMemo( + () => (dependencies || []).filter(isExternalDependency), + [dependencies] + ); return ( <> @@ -108,26 +124,32 @@ export const ProjectMetaSections: React.FC = React.mem {dependencies && dependencies.length > 0 && (
+ {externalDependencies.length > 0 && ( +
+
+ External service references +
+ {externalDependencies.map((dep, idx) => { + const sourceLabel = getSourceLabel(dep.source); + return ( +
+ {dep.projectTitle || dep.projectId} is an external {isModpack ? 'modpack entry' : 'dependency'} from {sourceLabel} + {dep.externalFileUrl ? <>; its file is also sourced from {sourceLabel}. : <>.} +
+ ); + })} +
+ )} {dependencies.map((dep, idx) => { + const isExternal = isExternalDependency(dep); const meta = depMeta[dep.projectId]; - const iconUrl = getIconUrl(dep.icon || meta?.icon); - const title = dep.title || dep.projectTitle || meta?.title || dep.projectId; - - const targetProjectParams = { - id: dep.projectId, - title: title, - slug: dep.slug || meta?.slug, - classification: dep.classification || meta?.classification - }; - - const path = SiteRoutes.project(targetProjectParams); - - return ( - + const iconUrl = isExternal ? null : getIconUrl(dep.icon || meta?.icon); + const title = dep.title || meta?.title || dep.projectTitle || dep.projectId; + const dependencyType = getDependencyType(dep); + const sourceLabel = getSourceLabel(dep.source); + const className = "w-full flex items-center gap-3 p-3 rounded-xl bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-white/5 hover:border-modtale-accent/50 hover:shadow-md transition-all group text-left block"; + const body = ( + <>
{iconUrl ? ( = React.mem baseWidth={32} className="w-full h-full" /> - ) :
-
+
{title}
- {!isModpack && {dep.isOptional ? 'Optional' : 'Required'}} - {dep.isEmbedded && Embedded} + {!isModpack && {dependencyType === 'OPTIONAL' ? 'Optional' : dependencyType === 'EMBEDDED' ? 'Embedded' : 'Required'}} + {isExternal && {sourceLabel}} + {!isModpack && isEmbeddedDependency(dep) && isExternal && Bundled} v{dep.versionNumber}
+ + ); + + if (isExternal) { + return ( + + {body} + + ); + } + + const targetProjectParams = { + id: dep.projectId, + title, + slug: dep.slug || meta?.slug, + classification: dep.classification || meta?.classification + }; + + return ( + + {body} ); })} diff --git a/frontend/src/modules/project/components/VersionFields.tsx b/frontend/src/modules/project/components/VersionFields.tsx index ab61f570..25b81426 100644 --- a/frontend/src/modules/project/components/VersionFields.tsx +++ b/frontend/src/modules/project/components/VersionFields.tsx @@ -13,6 +13,13 @@ const MAX_UPLOAD_ERROR_MESSAGE = 'File exceeds 100MB limit. Cloudflare only supp const STRICT_VERSION_REGEX = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; +const createDependencyId = () => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `dep-${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + const uniqueVersions = (versions: string[]) => Array.from(new Set(versions.filter(Boolean))); const getCatalogVersions = (catalog: GameVersionCatalog | null | undefined) => { @@ -169,13 +176,21 @@ export const VersionFields: React.FC = ({ data, onChange, is }; const addManifestSuggestions = (suggestions: ManifestDependencySuggestion[]) => { - const existingIds = new Set((data.projectIds || []).map(dep => dep.split(':')[0])); + const existingIds = new Set((data.dependencies || []).map(dep => dep.projectId)); const nextSuggestions = suggestions .filter(suggestion => !existingIds.has(suggestion.projectId)) - .map(suggestion => suggestion.dependencyEntry); + .map(suggestion => ({ + id: createDependencyId(), + projectId: suggestion.projectId, + projectTitle: suggestion.projectTitle, + versionNumber: suggestion.versionNumber, + dependencyType: suggestion.optional ? 'OPTIONAL' : 'REQUIRED', + source: 'MODTALE' + })); if (nextSuggestions.length === 0) return; - onChange({ ...data, projectIds: [...(data.projectIds || []), ...nextSuggestions] }); - setManifestSuggestions(prev => prev.filter(suggestion => !nextSuggestions.includes(suggestion.dependencyEntry))); + onChange({ ...data, dependencies: [...(data.dependencies || []), ...nextSuggestions] }); + const addedIds = new Set(nextSuggestions.map(suggestion => suggestion.projectId)); + setManifestSuggestions(prev => prev.filter(suggestion => !addedIds.has(suggestion.projectId))); }; useEffect(() => { @@ -358,8 +373,8 @@ export const VersionFields: React.FC = ({ data, onChange, is
)} onChange({ ...data, projectIds: deps })} + selectedDeps={data.dependencies || []} + onChange={(deps) => onChange({ ...data, dependencies: deps as ProjectDependency[] })} targetGameVersion={data.gameVersions?.[0]} label="Add Dependency" previousDependencies={previousDependencies} @@ -375,8 +390,8 @@ export const VersionFields: React.FC = ({ data, onChange, is

Select the projects to include in this modpack version.

onChange({ ...data, projectIds: deps })} + selectedDeps={data.dependencies || []} + onChange={(deps) => onChange({ ...data, dependencies: deps as ProjectDependency[] })} targetGameVersion={data.gameVersions?.[0]} label="Add Projects" isModpack={true} @@ -391,7 +406,7 @@ export const VersionFields: React.FC = ({ data, onChange, is

Mark mods that should not be used alongside this version.

onChange({ ...data, incompatibleProjectIds: deps })} + onChange={(deps) => onChange({ ...data, incompatibleProjectIds: deps as string[] })} label="Add Incompatible Mod" mode={VersionRelationKind.INCOMPATIBILITY} currentProjectId={currentProjectId} diff --git a/frontend/src/modules/project/components/dialogs/DependencyModal.tsx b/frontend/src/modules/project/components/dialogs/DependencyModal.tsx index 67260ea1..11e408b1 100644 --- a/frontend/src/modules/project/components/dialogs/DependencyModal.tsx +++ b/frontend/src/modules/project/components/dialogs/DependencyModal.tsx @@ -4,6 +4,7 @@ import { theme } from '@/styles/theme'; import { api, BACKEND_URL } from '@/utils/api'; import { useScrollLock } from '@/hooks/useScrollLock'; import type { ProjectVersion } from '@/types'; +import { isEmbeddedDependency, isExternalDependency, isOptionalDependency } from '@/modules/project/utils/dependencyEntries'; import { ModalPortal } from '@/components/ui/ModalPortal'; interface DependencyModalProps { @@ -28,10 +29,10 @@ export const DependencyModal: React.FC = ({ useScrollLock(!isInline); const [selected, setSelected] = useState>(() => { if (initialSelected) return new Set(initialSelected); - return new Set(dependencies.filter(d => !d.isEmbedded).map(d => d.projectId)); + return new Set(dependencies.filter(d => !isEmbeddedDependency(d) && !isExternalDependency(d)).map(d => d.projectId)); }); const [metaCache, setMetaCache] = useState>(() => initialMetaCache || {}); - const selectableDependencies = dependencies.filter(dep => !dep.isEmbedded); + const selectableDependencies = dependencies.filter(dep => !isEmbeddedDependency(dep) && !isExternalDependency(dep)); useEffect(() => { const fetchMeta = async () => { @@ -52,7 +53,7 @@ export const DependencyModal: React.FC = ({ fetchMeta(); }, [selectableDependencies, metaCache]); - const missingRequired = selectableDependencies.filter(d => !d.isOptional && !selected.has(d.projectId)).length > 0; + const missingRequired = selectableDependencies.filter(d => !isOptionalDependency(d) && !selected.has(d.projectId)).length > 0; const toggleDep = (id: string) => { const next = new Set(selected); @@ -98,7 +99,7 @@ export const DependencyModal: React.FC = ({ {selectableDependencies.map(dep => { const meta = metaCache[dep.projectId]; const isSelected = selected.has(dep.projectId); - const isRequiredMissing = !dep.isOptional && !isSelected; + const isRequiredMissing = !isOptionalDependency(dep) && !isSelected; return (
= ({
{isRequiredMissing ? ( Required - ) : !dep.isOptional ? ( + ) : !isOptionalDependency(dep) ? ( Required ) : ( Optional diff --git a/frontend/src/modules/project/components/dialogs/DownloadModal.tsx b/frontend/src/modules/project/components/dialogs/DownloadModal.tsx index 01bcc9ac..5cf4093e 100644 --- a/frontend/src/modules/project/components/dialogs/DownloadModal.tsx +++ b/frontend/src/modules/project/components/dialogs/DownloadModal.tsx @@ -3,6 +3,8 @@ import { Download, X, ChevronDown, FileText, AlertCircle, ChevronRight, Check } import { theme } from '@/styles/theme'; import { buildVersionGroups, compareSemVer, formatTimeAgo, type VersionGroup } from '@/utils/modHelpers'; import { useScrollLock } from '@/hooks/useScrollLock'; +import { getExternalDependencies } from '@/modules/project/utils/dependencyEntries'; +import type { ProjectDependency } from '@/types'; import { ModalPortal } from '@/components/ui/ModalPortal'; interface VersionMultiSelectDropdownProps { @@ -184,12 +186,13 @@ interface DownloadModalProps { showExperimental: boolean; onToggleExperimental: () => void; onViewHistory: () => void; + isModpack?: boolean; isInline?: boolean; containerRef?: React.Ref; } export const DownloadModal: React.FC = ({ - show, onClose, versionsByGame, preReleaseGameVersions = [], orderedGameVersions = [], onDownload, showExperimental, onToggleExperimental, onViewHistory, isInline = false, containerRef + show, onClose, versionsByGame, preReleaseGameVersions = [], orderedGameVersions = [], onDownload, showExperimental, onToggleExperimental, onViewHistory, isModpack = false, isInline = false, containerRef }) => { useScrollLock(show && !isInline); const [selectedGameVersions, setSelectedGameVersions] = useState([]); @@ -382,6 +385,36 @@ export const DownloadModal: React.FC = ({ return versions.filter((gv: string) => gv !== gameVersion); }; + const externalDependenciesFor = (ver: any): ProjectDependency[] => ( + isModpack ? getExternalDependencies(ver?.dependencies) : [] + ); + + const formatExternalDependencyNames = (dependencies: ProjectDependency[]) => { + const names = dependencies.map(dep => dep.projectTitle || dep.externalId || dep.projectId).filter(Boolean); + const visibleNames = names.slice(0, 3); + const remainingCount = names.length - visibleNames.length; + return `${visibleNames.join(', ')}${remainingCount > 0 ? `, +${remainingCount} more` : ''}`; + }; + + const renderExternalDependencyNotice = (ver: any) => { + const externalDependencies = externalDependenciesFor(ver); + if (externalDependencies.length === 0) return null; + + return ( +
+
+
+
+ ); + }; + const content = (
e.stopPropagation()}>
@@ -443,6 +476,7 @@ export const DownloadModal: React.FC = ({
)} + {renderExternalDependencyNotice(latestVer)}
@@ -477,6 +511,12 @@ export const DownloadModal: React.FC = ({ Also supports: {otherCompatibleVersions(ver, gameVersion).join(', ')}
)} + {externalDependenciesFor(ver).length > 0 && ( +
+
+ )}