From 424b954f0bedce2cb16aae72b25f13f7a4023743 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:58:28 -0400 Subject: [PATCH 01/13] Modpack/dependencies v2 --- .../net/modtale/config/db/DataSeeder.java | 13 +- .../db/ProjectDependencySchemaMigration.java | 140 +++ .../project/ExternalProjectController.java | 36 + .../net/modtale/mapper/ProjectMapper.java | 16 +- .../project/ExternalProjectReferenceDTO.java | 28 + .../project/ManifestDependencySuggestion.java | 5 - .../modtale/model/dto/project/ProjectDTO.java | 3 - .../dto/project/ProjectDependencyDTO.java | 12 +- .../request/project/CreateVersionRequest.java | 10 +- .../project/DependencyReferenceRequest.java | 67 ++ .../request/project/UpdateVersionRequest.java | 10 +- .../net/modtale/model/project/Project.java | 4 - .../model/project/ProjectDependency.java | 133 ++- .../repository/project/ProjectRepository.java | 16 +- .../lifecycle/ProjectDeletionService.java | 10 +- .../ExternalDependencyArtifactService.java | 437 +++++++ .../ExternalProjectReferenceService.java | 390 ++++++ .../VersionCreationCommandHandler.java | 9 +- .../version/VersionDependencyService.java | 270 ++++- .../VersionDownloadOrchestrationService.java | 13 +- .../VersionMutationApplicationService.java | 17 +- .../VersionMutationOrchestrationService.java | 34 +- .../project/version/VersionService.java | 9 +- .../version/VersionUpdateCommandHandler.java | 15 +- .../storage/BundlePackagingService.java | 5 +- .../storage/DownloadArchiveSupport.java | 6 +- .../storage/ModpackArchiveService.java | 101 +- .../service/storage/StorageService.java | 15 + .../project/VersionControllerTest.java | 19 +- .../net/modtale/mapper/ProjectMapperTest.java | 15 +- .../lifecycle/ProjectDeletionServiceTest.java | 2 +- .../version/VersionDependencyServiceTest.java | 57 +- ...rsionDownloadOrchestrationServiceTest.java | 2 +- .../VersionManifestMatchingServiceTest.java | 4 +- .../project/version/VersionServiceTest.java | 24 +- .../storage/BundlePackagingServiceTest.java | 2 +- .../service/storage/DownloadServiceTest.java | 2 +- frontend/src/data/categories.ts | 4 +- .../src/modules/core/components/Navbar.tsx | 14 +- frontend/src/modules/core/views/ApiDocs.tsx | 7 +- .../home/components/FeaturePreviews.tsx | 123 +- frontend/src/modules/home/views/Home.tsx | 5 + .../src/modules/project/api/projectClient.ts | 8 +- .../project/components/DependencySelector.tsx | 1063 +++++++++++------ .../modules/project/components/FormShared.tsx | 5 +- .../project/components/ProjectCard.tsx | 2 +- .../components/ProjectMetaSections.tsx | 98 +- .../project/components/VersionFields.tsx | 33 +- .../components/dialogs/DependencyModal.tsx | 11 +- .../modules/project/hooks/useProjectDetail.ts | 6 +- frontend/src/modules/project/tabs/Files.tsx | 12 +- .../project/utils/dependencyEntries.ts | 52 +- .../modules/project/views/ProjectDetails.tsx | 3 +- .../modules/project/views/ProjectEditor.tsx | 33 +- frontend/src/types.ts | 37 +- frontend/tests/modules/project/Files.test.tsx | 2 - .../tests/modules/project/tabs/Files.test.tsx | 34 +- .../project/utils/dependencyEntries.test.ts | 65 +- 58 files changed, 2850 insertions(+), 718 deletions(-) create mode 100644 backend/src/main/java/net/modtale/config/db/ProjectDependencySchemaMigration.java create mode 100644 backend/src/main/java/net/modtale/controller/project/ExternalProjectController.java create mode 100644 backend/src/main/java/net/modtale/model/dto/project/ExternalProjectReferenceDTO.java create mode 100644 backend/src/main/java/net/modtale/model/dto/request/project/DependencyReferenceRequest.java create mode 100644 backend/src/main/java/net/modtale/service/project/version/ExternalDependencyArtifactService.java create mode 100644 backend/src/main/java/net/modtale/service/project/version/ExternalProjectReferenceService.java diff --git a/backend/src/main/java/net/modtale/config/db/DataSeeder.java b/backend/src/main/java/net/modtale/config/db/DataSeeder.java index 422b4bb6..27a53209 100644 --- a/backend/src/main/java/net/modtale/config/db/DataSeeder.java +++ b/backend/src/main/java/net/modtale/config/db/DataSeeder.java @@ -407,9 +407,16 @@ private Set extractDependencyIds(Document project) { if (!(dependencyObj instanceof Document dependencyDoc)) { continue; } - Object rawModId = dependencyDoc.get("modId"); - if (rawModId != null) { - dependencyIds.add(rawModId.toString()); + Object rawSource = dependencyDoc.get("source"); + if (rawSource != null && !"MODTALE".equalsIgnoreCase(rawSource.toString())) { + continue; + } + Object rawProjectId = dependencyDoc.get("projectId"); + if (rawProjectId == null) { + rawProjectId = dependencyDoc.get("modId"); + } + if (rawProjectId != null) { + dependencyIds.add(rawProjectId.toString()); } } } 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 d93ad398..5d26a9cd 100644 --- a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java +++ b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java @@ -146,7 +146,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()); @@ -272,11 +271,18 @@ 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.isOptional(), - dependency.isEmbedded() + dependency.getDependencyType(), + dependency.getSource(), + dependency.getExternalId(), + dependency.getExternalUrl(), + dependency.getExternalFileUrl(), + dependency.getExternalFileName(), + dependency.getCachedFileUrl(), + dependency.isHytaleProjectConfirmed() ); } } 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 ccb4dc5e..03015beb 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 @@ -34,7 +34,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; @@ -102,8 +101,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 1cb83b18..753f4598 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 @@ -1,12 +1,20 @@ package net.modtale.model.dto.project; import com.fasterxml.jackson.annotation.JsonInclude; +import net.modtale.model.project.ProjectDependency; @JsonInclude(JsonInclude.Include.NON_NULL) public record ProjectDependencyDTO( + String id, String projectId, String projectTitle, String versionNumber, - boolean isOptional, - boolean isEmbedded + ProjectDependency.DependencyType dependencyType, + ProjectDependency.Source source, + String externalId, + String externalUrl, + String externalFileUrl, + String externalFileName, + String cachedFileUrl, + boolean hytaleProjectConfirmed ) {} 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 24a17ad7..91569988 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.") @@ -45,12 +45,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 1c81f852..d61f9328 100644 --- a/backend/src/main/java/net/modtale/model/project/Project.java +++ b/backend/src/main/java/net/modtale/model/project/Project.java @@ -173,8 +173,6 @@ public ProjectMember(String userId, String roleId) { private List types; private List childProjectIds; - - private List modIds; private boolean allowModpacks = true; private boolean allowComments = true; @@ -273,8 +271,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 b23384f0..67550f24 100644 --- a/backend/src/main/java/net/modtale/model/project/ProjectDependency.java +++ b/backend/src/main/java/net/modtale/model/project/ProjectDependency.java @@ -1,59 +1,130 @@ package net.modtale.model.project; -import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.UUID; 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; - 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; } - @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 String getExternalId() { return externalId; } + public void setExternalId(String externalId) { this.externalId = externalId; } - public void setOptional(boolean optional) { isOptional = optional; } + public String getExternalUrl() { return externalUrl; } + public void setExternalUrl(String externalUrl) { this.externalUrl = externalUrl; } - @JsonProperty("isEmbedded") - public boolean isEmbedded() { return isEmbedded; } + public String getExternalFileUrl() { return externalFileUrl; } + public void setExternalFileUrl(String externalFileUrl) { this.externalFileUrl = externalFileUrl; } - public void setEmbedded(boolean embedded) { isEmbedded = embedded; } + 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; + } + + public boolean isEmbedded() { + return getDependencyType() == DependencyType.EMBEDDED; + } + + public boolean isOptional() { + return getDependencyType() == DependencyType.OPTIONAL; + } } diff --git a/backend/src/main/java/net/modtale/repository/project/ProjectRepository.java b/backend/src/main/java/net/modtale/repository/project/ProjectRepository.java index 8f892caa..89facad2 100644 --- a/backend/src/main/java/net/modtale/repository/project/ProjectRepository.java +++ b/backend/src/main/java/net/modtale/repository/project/ProjectRepository.java @@ -39,49 +39,49 @@ public interface ProjectRepository extends MongoRepository, Pro @Query( value = "{ '_id': ?0, 'deletedAt': null }", - fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'modIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'teamInvites': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.changelog': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" + fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'teamInvites': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.changelog': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" ) Optional findViewerDetailById(String id); @Query( value = "{ 'slug': ?0, 'deletedAt': null }", - fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'modIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'teamInvites': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.changelog': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" + fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'teamInvites': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.changelog': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" ) Optional findViewerDetailBySlug(String slug); @Query( value = "{ '_id': ?0, 'deletedAt': null }", - fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'modIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'teamInvites': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" + fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'teamInvites': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" ) Optional findViewerPageDetailById(String id); @Query( value = "{ 'slug': ?0, 'deletedAt': null }", - fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'modIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'teamInvites': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" + fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'teamInvites': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" ) Optional findViewerPageDetailBySlug(String slug); @Query( value = "{ '_id': ?0, 'status': { $in: ['PUBLISHED', 'UNLISTED', 'ARCHIVED'] }, 'deletedAt': null }", - fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'modIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.changelog': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" + fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.changelog': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" ) Optional findPublicDetailById(String id); @Query( value = "{ 'slug': ?0, 'status': { $in: ['PUBLISHED', 'UNLISTED', 'ARCHIVED'] }, 'deletedAt': null }", - fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'modIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.changelog': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" + fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.changelog': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" ) Optional findPublicDetailBySlug(String slug); @Query( value = "{ '_id': ?0, 'status': { $in: ['PUBLISHED', 'UNLISTED', 'ARCHIVED'] }, 'deletedAt': null }", - fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'modIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" + fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" ) Optional findPublicPageDetailById(String id); @Query( value = "{ 'slug': ?0, 'status': { $in: ['PUBLISHED', 'UNLISTED', 'ARCHIVED'] }, 'deletedAt': null }", - fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'modIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" + fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'about': 1, 'description': 1, 'authorId': 1, 'author': 1, 'imageUrl': 1, 'bannerUrl': 1, 'classification': 1, 'categories': 1, 'tags': 1, 'downloadCount': 1, 'favoriteCount': 1, 'trendScore': 1, 'relevanceScore': 1, 'popularScore': 1, 'repositoryUrl': 1, 'updatedAt': 1, 'createdAt': 1, 'license': 1, 'lastTrendingNotification': 1, 'links': 1, 'types': 1, 'childProjectIds': 1, 'allowModpacks': 1, 'allowComments': 1, 'hmWikiEnabled': 1, 'hmWikiSlug': 1, 'status': 1, 'expiresAt': 1, 'deletedAt': 1, 'projectRoles': 1, 'teamMembers': 1, 'galleryImages': 1, 'comments': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.gameVersions': 1, 'versions.fileUrl': 1, 'versions.downloadCount': 1, 'versions.releaseDate': 1, 'versions.dependencies': 1, 'versions.incompatibleProjectIds': 1, 'versions.channel': 1, 'versions.reviewStatus': 1 }" ) Optional findPublicPageDetailBySlug(String slug); 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 8bcd419c..64077343 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/version/ExternalDependencyArtifactService.java b/backend/src/main/java/net/modtale/service/project/version/ExternalDependencyArtifactService.java new file mode 100644 index 00000000..c34209fd --- /dev/null +++ b/backend/src/main/java/net/modtale/service/project/version/ExternalDependencyArtifactService.java @@ -0,0 +1,437 @@ +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.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; + + 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 194a1138..a63a8f70 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 @@ -8,6 +8,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; @@ -51,7 +52,7 @@ public void addVersion( List gameVersions, MultipartFile file, String changelog, - List projectIds, + List dependencies, List incompatibleProjectIds, ProjectVersion.Channel channel, User user @@ -73,9 +74,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()); } @@ -87,7 +88,7 @@ public void addVersion( } if (modpack) { - project.setModIds(simpleProjectIds); + project.setChildProjectIds(simpleProjectIds); } project.getVersions().add(0, version); 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 c768fd36..878be427 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(), currentUser @@ -41,7 +42,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(), @@ -54,14 +55,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 60feb113..e962ba1b 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 bef85635..4ba7b660 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,12 +100,12 @@ public void addVersion( List gameVersions, MultipartFile file, String changelog, - List projectIds, + List dependencies, List incompatibleProjectIds, ProjectVersion.Channel channel, User user ) { - versionCreationCommandHandler.addVersion(id, versionNumber, gameVersions, file, changelog, projectIds, incompatibleProjectIds, channel, user); + versionCreationCommandHandler.addVersion(id, versionNumber, gameVersions, file, changelog, dependencies, incompatibleProjectIds, channel, 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..279048d1 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,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.Locale; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import net.modtale.exception.StorageDownloadException; @@ -65,12 +66,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(","); } @@ -89,6 +108,11 @@ private void writeDependencyFiles(ZipOutputStream zos, ProjectVersion version) t } for (ProjectDependency dependency : version.getDependencies()) { + if (dependency.isExternal()) { + writeExternalDependencyFile(zos, dependency); + continue; + } + DownloadArchiveSupport.ResolvedDependency resolvedDependency = archiveSupport.resolveDependency(dependency); if (resolvedDependency == null || resolvedDependency.version().getFileUrl() == null) { continue; @@ -107,6 +131,63 @@ private void writeDependencyFiles(ZipOutputStream zos, ProjectVersion version) t } } + private void writeExternalDependencyFile(ZipOutputStream zos, ProjectDependency dependency) throws IOException { + String cachedFileUrl = trimToNull(dependency.getCachedFileUrl()); + if (cachedFileUrl == null) { + return; + } + + try { + byte[] fileData = archiveSupport.download(cachedFileUrl); + if (fileData == null || fileData.length == 0) { + return; + } + + zos.putNextEntry(new ZipEntry("external/" + externalSourceFolder(dependency) + "/" + externalFilename(dependency))); + zos.write(fileData); + zos.closeEntry(); + } catch (StorageDownloadException ex) { + logger.warn("Unable to include cached external dependency {} from {} in generated modpack archive.", + dependency.getProjectTitle(), cachedFileUrl, ex); + } + } + + private String externalSourceFolder(ProjectDependency dependency) { + return dependency.getSource().name().toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9._-]+", "-") + .replaceAll("-+", "-") + .replaceAll("(^-|-$)", ""); + } + + 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) { try { String fileName = (pack.getSlug() != null && !pack.getSlug().isEmpty() ? pack.getSlug() : pack.getId()) @@ -125,4 +206,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/main/java/net/modtale/service/storage/StorageService.java b/backend/src/main/java/net/modtale/service/storage/StorageService.java index 4a136306..68b1852c 100644 --- a/backend/src/main/java/net/modtale/service/storage/StorageService.java +++ b/backend/src/main/java/net/modtale/service/storage/StorageService.java @@ -173,6 +173,21 @@ public byte[] download(String fileName) { } } + public boolean exists(String fileName) { + try { + HeadObjectRequest headReq = HeadObjectRequest.builder() + .bucket(bucketName) + .key(fileName) + .build(); + s3Client.headObject(headReq); + return true; + } catch (NoSuchKeyException e) { + return false; + } catch (SdkException e) { + throw StorageDownloadException.from(e, "Failed to check the requested file."); + } + } + public InputStream getStream(String fileName) { try { GetObjectRequest getReq = GetObjectRequest.builder() 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 bbb7331d..5907016a 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); @@ -123,8 +127,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(currentUser) ); @@ -204,7 +208,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( @@ -285,4 +289,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 c43a82d0..64bd70fc 100644 --- a/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java +++ b/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java @@ -74,7 +74,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.setProjectRoles(List.of(new Project.ProjectRole("role-1", "Editor", "#fff", Set.of(ApiKey.ApiPermission.PROJECT_EDIT_METADATA)))); project.setTeamMembers(List.of(new Project.ProjectMember("user-1", "role-1"))); @@ -86,7 +85,6 @@ void toDTOMapsCommentsVersionsAndVotesForFullResponses() { assertEquals("Deep project details", dto.getAbout()); assertEquals(List.of("child-1"), dto.getChildProjectIds()); - assertEquals(List.of("mod-1"), dto.getModIds()); assertEquals(1, dto.getComments().size()); assertEquals(1, dto.getVersions().size()); assertEquals(1, dto.getProjectRoles().size()); @@ -119,15 +117,20 @@ 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 + ); assertNull(withoutReview.reviewStatus()); assertEquals(ProjectVersion.ReviewStatus.APPROVED, withReview.reviewStatus()); assertEquals("Security review cleared", withReview.rejectionReason()); assertNotNull(adminVersion.scanResult()); assertEquals("modtale:core", ProjectMapper.toDependencyDTO(dependency).projectId()); - assertTrue(ProjectMapper.toDependencyDTO(dependency).isOptional()); - assertTrue(ProjectMapper.toDependencyDTO(dependency).isEmbedded()); + assertEquals(ProjectDependency.DependencyType.EMBEDDED, ProjectMapper.toDependencyDTO(dependency).dependencyType()); + assertEquals(ProjectDependency.Source.MODTALE, ProjectMapper.toDependencyDTO(dependency).source()); assertEquals("Lock in complete", ProjectMapper.toVersionDTO(version).getChangelog()); assertEquals(List.of("modtale:legacy"), ProjectMapper.toVersionDTO(version).getIncompatibleProjectIds()); } @@ -196,7 +199,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 e0da05db..b784d6dd 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, user @@ -199,7 +201,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"); @@ -212,7 +214,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") @@ -221,7 +224,7 @@ void updateVersionClearsCachedModpackArchivesAndRefreshesLatestDependencyIds() { service.updateVersion( "project-1", "version-1", - List.of("new-dep:2.0.0"), + List.of(dependency), List.of(), null, null, @@ -230,8 +233,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); @@ -282,4 +285,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..82241379 100644 --- a/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java @@ -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/frontend/src/data/categories.ts b/frontend/src/data/categories.ts index 272b61c9..bc3cb110 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: 'SAVE', label: 'Worlds', icon: Save } ]; export const LICENSES = [ diff --git a/frontend/src/modules/core/components/Navbar.tsx b/frontend/src/modules/core/components/Navbar.tsx index 5df8a36f..423c6bc4 100644 --- a/frontend/src/modules/core/components/Navbar.tsx +++ b/frontend/src/modules/core/components/Navbar.tsx @@ -151,20 +151,20 @@ export const Navbar: React.FC = ({
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 + + 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" > - - Modpacks + + Plugins = ({
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"> 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"> 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 diff --git a/frontend/src/modules/core/views/ApiDocs.tsx b/frontend/src/modules/core/views/ApiDocs.tsx index 7097e402..58c58423 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, @@ -681,8 +680,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' }, ], }; } @@ -699,7 +699,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 26e5924b..187caeb6 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'; @@ -18,9 +18,9 @@ import { HistoryModal } from '@/modules/project/components/dialogs/HistoryModal' export const InlineDependencyUI = ({ randomProject }: { randomProject?: Project }) => { const mockDeps = useMemo(() => [ - { projectId: 'hytale-core', projectTitle: 'Hytale Core Library', isOptional: false, isEmbedded: false, versionNumber: '1.2.0' }, - { projectId: 'mathlib', projectTitle: 'MathLib', isOptional: false, isEmbedded: false, versionNumber: '2.1.0' }, - ...(randomProject ? [{ projectId: randomProject.id, projectTitle: randomProject.title, isOptional: true, isEmbedded: false, versionNumber: randomProject.versions?.[0]?.versionNumber || '1.0.0' }] : []) + { projectId: 'hytale-core', projectTitle: 'Hytale Core Library', dependencyType: 'REQUIRED', source: 'MODTALE', versionNumber: '1.2.0' }, + { projectId: 'mathlib', projectTitle: 'MathLib', dependencyType: 'REQUIRED', source: 'MODTALE', versionNumber: '2.1.0' }, + ...(randomProject ? [{ projectId: randomProject.id, projectTitle: randomProject.title, dependencyType: 'OPTIONAL', source: 'MODTALE', versionNumber: randomProject.versions?.[0]?.versionNumber || '1.0.0' }] : []) ], [randomProject]); const initialMetaCache = useMemo(() => { @@ -51,6 +51,99 @@ export const InlineDependencyUI = ({ randomProject }: { randomProject?: Project ); }; +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); @@ -687,6 +780,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 5dea620e..4549a8fb 100644 --- a/frontend/src/modules/home/views/Home.tsx +++ b/frontend/src/modules/home/views/Home.tsx @@ -13,6 +13,7 @@ import { MarqueeColumn } from '../components/HeroMarquee'; import { TrendingProjectsSection, NewReleasesSection, + ModpackPreviewSection, DirectDownloadsSection, SmartDependenciesSection, ProjectAnalyticsSection, @@ -1080,6 +1081,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 b979ec5c..a5ad7563 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 { ManifestInspectionResult, Project, ProjectVersionChangelog, User, ProjectRole, GameVersionCatalog } from '@/types'; +import type { ManifestInspectionResult, Project, ProjectVersionChangelog, User, ProjectRole, GameVersionCatalog, DependencySource, ExternalProjectReference } from '@/types'; export const projectClient = { getProject: async (id: string) => { @@ -41,6 +41,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 8878029a..a45d060b 100644 --- a/frontend/src/modules/project/components/DependencySelector.tsx +++ b/frontend/src/modules/project/components/DependencySelector.tsx @@ -1,244 +1,144 @@ -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'; -interface DependencyWizardProps { - previousDeps: ProjectDependency[]; - targetGameVersion: string | undefined; - onConfirm: (newDeps: string[]) => void; - onClose: () => void; -} - -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 project = await projectClient.getProject(dep.projectId); - const sorted = (project.versions || []).sort((a, b) => compareSemVer(b.versionNumber, a.versionNumber)); - setVersions(sorted); - } catch (e) { - } finally { - setLoading(false); - } - }; - fetchVersions(); - }, [dep.projectId]); +type DependencyMeta = { title: string; author: string; icon: string; source?: string; url?: string }; - 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); }; - - 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 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]); +interface DependencySelectorProps { + selectedDeps: ProjectDependency[] | string[]; + onChange: (deps: any[]) => void; + targetGameVersion?: string; + label?: string; + mode?: VersionRelationKind; + previousDependencies?: ProjectDependency[]; + currentProjectId?: string; + isModpack?: boolean; + disabled?: boolean; +} - const handleSelect = (ver: string) => { - setSelectedVer(ver); - onSelect(dep.projectId, ver); - setIsOpen(false); - }; +const createUuid = () => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `dep-${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; - return ( -
-
-
{dep.projectTitle || dep.projectId}
-
- Previous: {dep.versionNumber} - {dep.isOptional && Optional} -
-
+const getIconUrl = (path?: string) => { + if (!path) return '/assets/favicon.svg'; + return path.startsWith('http') ? path : `${BACKEND_URL}${path}`; +}; -
- {loading ? ( -
Loading...
- ) : ( - <> - +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; + } +}; - {isOpen && ( -
- {compatibleVersions.length > 0 ? ( - <> -
Compatible
- {compatibleVersions.map(v => ( - - ))} - - ) : ( -
No compatible versions found.
- )} - - {incompatibleVersions.length > 0 && ( - <> -
Incompatible
- {incompatibleVersions.map(v => ( - - ))} - - )} -
- )} - - )} -
-
- ); +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 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, @@ -247,276 +147,625 @@ 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 fullProject = mod.versions ? mod : await projectClient.getProject(mod.id); - setSelectedModForVersion({ ...fullProject, versions: fullProject.versions || [] }); - } catch (e) { - setSelectedModForVersion({ ...mod, versions: mod.versions || [] }); + 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 === externalSelectedFileId) || null; + }, [externalResolved, externalSelectedFileId]); + + 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 && ( -
- +
+
)}
)} -
+ {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}

} +
+
+ )} + + {externalResolved?.files && externalResolved.files.length > 0 && ( + + )} + + {!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 && ( + )} - {!isIncompatibilityMode && ( - + {dependency && isExternal && dependency.externalUrl && ( + )} - +
); diff --git a/frontend/src/modules/project/components/FormShared.tsx b/frontend/src/modules/project/components/FormShared.tsx index 64db72f5..4baa70d7 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; @@ -15,15 +16,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[]; }; export const Label = ({ children, required, className }: { children: React.ReactNode, required?: boolean, className?: string }) => ( diff --git a/frontend/src/modules/project/components/ProjectCard.tsx b/frontend/src/modules/project/components/ProjectCard.tsx index d86ed396..2bbcf164 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 favorites = (project.favoriteCount || 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 resolveUrl = (url: string) => { diff --git a/frontend/src/modules/project/components/ProjectMetaSections.tsx b/frontend/src/modules/project/components/ProjectMetaSections.tsx index 932774cd..fe4ac99c 100644 --- a/frontend/src/modules/project/components/ProjectMetaSections.tsx +++ b/frontend/src/modules/project/components/ProjectMetaSections.tsx @@ -1,12 +1,13 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { AlertTriangle, Box, Gamepad2, Link as LinkIcon, Tag } from 'lucide-react'; +import { AlertTriangle, Box, ExternalLink, Gamepad2, Link as LinkIcon, Tag } from 'lucide-react'; import { SidebarSection } from '@/modules/project/components/ProjectLayout'; import { OptimizedImage } from '@/components/ui/OptimizedImage'; import { SiteRoutes } from '@/utils/routes'; import { BACKEND_URL } from '@/utils/api'; import type { Project, ProjectDependency } from '@/types'; +import { getDependencyType, isEmbeddedDependency, isExternalDependency, isOptionalDependency } from '../utils/dependencyEntries'; interface ProjectMetaSectionsProps { project: Project; @@ -15,6 +16,17 @@ interface ProjectMetaSectionsProps { depMeta: Record; } +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'; + } +}; + export const ProjectMetaSections: React.FC = React.memo(({ project, dependencies, @@ -29,6 +41,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 ( <> @@ -58,26 +74,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(meta?.icon); + const iconUrl = isExternal ? null : getIconUrl(meta?.icon); const title = meta?.title || dep.projectTitle || dep.projectId; - - const targetProjectParams = { - id: dep.projectId, - title: title, - slug: meta?.slug, - classification: meta?.classification - }; - - const path = SiteRoutes.project(targetProjectParams); - - return ( - + 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: meta?.slug, + classification: meta?.classification + }; + + return ( + + {body} ); })} diff --git a/frontend/src/modules/project/components/VersionFields.tsx b/frontend/src/modules/project/components/VersionFields.tsx index d8951a96..a5c12e50 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)}`; +}; + interface VersionFieldsProps { data: VersionFormData; onChange: (d: VersionFormData) => void; @@ -127,13 +134,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(() => { @@ -316,8 +331,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} @@ -333,8 +348,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} @@ -349,7 +364,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 0ec7a0e5..d9eb1869 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'; interface DependencyModalProps { dependencies: NonNullable; @@ -27,10 +28,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 () => { @@ -51,7 +52,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); @@ -97,7 +98,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/hooks/useProjectDetail.ts b/frontend/src/modules/project/hooks/useProjectDetail.ts index 050d4a99..e15961e6 100644 --- a/frontend/src/modules/project/hooks/useProjectDetail.ts +++ b/frontend/src/modules/project/hooks/useProjectDetail.ts @@ -4,6 +4,7 @@ import { SiteRoutes } from '@/utils/routes'; import { consumePrefetchedProject } from '@/utils/prefetch'; import type { Project, User } from '@/types'; import { mergeProjectVersionChangelogs, projectNeedsChangelogHydration } from '../utils/changelogHydration'; +import { isExternalDependency } from '../utils/dependencyEntries'; const consumeProjectBootstrap = async (routeKey: string) => { const prefetched = await consumePrefetchedProject(routeKey); @@ -187,7 +188,10 @@ export const useProjectDetail = ( }, [project?.versions]); useEffect(() => { - const latestProjectIds = [...latestDependencies.map(dep => dep.projectId), ...latestIncompatibleProjectIds]; + const latestProjectIds = [ + ...latestDependencies.filter(dep => !isExternalDependency(dep)).map(dep => dep.projectId), + ...latestIncompatibleProjectIds + ]; if (!latestProjectIds.length) return; const fetchMeta = async () => { const missing = latestProjectIds.filter((projectId) => projectId && !depMeta[projectId] && !fetchedDepMeta.current.has(projectId)); diff --git a/frontend/src/modules/project/tabs/Files.tsx b/frontend/src/modules/project/tabs/Files.tsx index ff75cdc7..b4c85eed 100644 --- a/frontend/src/modules/project/tabs/Files.tsx +++ b/frontend/src/modules/project/tabs/Files.tsx @@ -7,7 +7,7 @@ import type { Project, ProjectVersion } from '@/types'; import type { VersionFormData } from '../components/FormShared'; import { Permission } from '@/modules/permissions/permissions'; import { compareSemVer } from '@/utils/modHelpers'; -import { parseDependencyEntry, serializeProjectDependency } from '../utils/dependencyEntries'; +import { dependencyProjectKey } from '../utils/dependencyEntries'; interface FilesProps { projectData: Project | null; @@ -30,7 +30,7 @@ export const Files: React.FC = ({ projectData, versionData, setVersi ? [...projectData.versions].sort((a, b) => compareSemVer(b.versionNumber, a.versionNumber))[0] : null; const canReuseLatestSetup = Boolean(latestVersion) && !readOnly && hasProjectPermission(Permission.VERSION_CREATE) && !reuseLatestSetupDismissed; - const hasSelectedDependencies = (versionData.projectIds || []).length > 0; + const hasSelectedDependencies = (versionData.dependencies || []).length > 0; useEffect(() => { setReuseLatestSetupDismissed(false); @@ -39,9 +39,9 @@ export const Files: React.FC = ({ projectData, versionData, setVersi const handleReuseLatestSetup = () => { if (!latestVersion) return; - const nextDependencyEntries = (latestVersion.dependencies || []).map(serializeProjectDependency); - const dependencyIds = new Set(nextDependencyEntries.map((entry) => parseDependencyEntry(entry).projectId)); - const preservedEntries = (versionData.projectIds || []).filter((entry) => !dependencyIds.has(parseDependencyEntry(entry).projectId)); + const nextDependencies = latestVersion.dependencies || []; + const dependencyKeys = new Set(nextDependencies.map(dependencyProjectKey)); + const preservedDependencies = (versionData.dependencies || []).filter((dependency) => !dependencyKeys.has(dependencyProjectKey(dependency))); const nextIncompatibleIds = latestVersion.incompatibleProjectIds || []; const incompatibleIds = new Set(nextIncompatibleIds); const preservedIncompatibles = (versionData.incompatibleProjectIds || []).filter((projectId) => !incompatibleIds.has(projectId)); @@ -50,7 +50,7 @@ export const Files: React.FC = ({ projectData, versionData, setVersi ...prev, gameVersions: latestVersion.gameVersions || (latestVersion.gameVersion ? [latestVersion.gameVersion] : prev.gameVersions), channel: latestVersion.channel || prev.channel || 'RELEASE', - projectIds: [...nextDependencyEntries, ...preservedEntries], + dependencies: [...nextDependencies, ...preservedDependencies], incompatibleProjectIds: [...nextIncompatibleIds, ...preservedIncompatibles] })); setReuseLatestSetupDismissed(true); diff --git a/frontend/src/modules/project/utils/dependencyEntries.ts b/frontend/src/modules/project/utils/dependencyEntries.ts index 306f24d2..eb554192 100644 --- a/frontend/src/modules/project/utils/dependencyEntries.ts +++ b/frontend/src/modules/project/utils/dependencyEntries.ts @@ -1,40 +1,22 @@ -import type { ProjectDependency } from '@/types'; +import type { DependencyType, ProjectDependency } from '@/types'; -export interface ParsedDependencyEntry { - projectId: string; - versionNumber: string; - isOptional: boolean; - isEmbedded: boolean; -} +export const getDependencyType = (dependency?: Pick): DependencyType => + dependency?.dependencyType || 'REQUIRED'; -export const parseDependencyEntry = (entry: string): ParsedDependencyEntry => { - const [projectId = '', versionNumber = '', ...flagParts] = entry.split(':'); - const flags = new Set(flagParts.map((flag) => flag.trim().toLowerCase()).filter(Boolean)); +export const isOptionalDependency = (dependency?: Pick): boolean => + getDependencyType(dependency) === 'OPTIONAL'; - return { - projectId, - versionNumber, - isOptional: flags.has('optional'), - isEmbedded: flags.has('embedded') - }; -}; +export const isEmbeddedDependency = (dependency?: Pick): boolean => + getDependencyType(dependency) === 'EMBEDDED'; -export const serializeDependencyEntry = ({ - projectId, - versionNumber, - isOptional, - isEmbedded -}: ParsedDependencyEntry): string => { - const flags: string[] = []; - if (isOptional) flags.push('optional'); - if (isEmbedded) flags.push('embedded'); - return [projectId, versionNumber, ...flags].join(':'); -}; +export const isExternalDependency = (dependency?: Pick): boolean => + (dependency?.source || 'MODTALE') !== 'MODTALE'; -export const serializeProjectDependency = (dependency: ProjectDependency): string => - serializeDependencyEntry({ - projectId: dependency.projectId, - versionNumber: dependency.versionNumber, - isOptional: Boolean(dependency.isOptional), - isEmbedded: Boolean(dependency.isEmbedded) - }); +export const normalizeDependencyReference = (dependency: ProjectDependency): ProjectDependency => ({ + ...dependency, + dependencyType: dependency.dependencyType || 'REQUIRED', + source: dependency.source || 'MODTALE' +}); + +export const dependencyProjectKey = (dependency: ProjectDependency): string => + `${dependency.source || 'MODTALE'}:${dependency.projectId}`; diff --git a/frontend/src/modules/project/views/ProjectDetails.tsx b/frontend/src/modules/project/views/ProjectDetails.tsx index 0a3b1df6..b0ef2e07 100644 --- a/frontend/src/modules/project/views/ProjectDetails.tsx +++ b/frontend/src/modules/project/views/ProjectDetails.tsx @@ -29,6 +29,7 @@ import { StatusModal } from '@/components/ui/StatusModal'; import { api, extractApiErrorMessage } from '@/utils/api'; import { projectClient } from '../api/projectClient'; import { mergeProjectVersionChangelogs, projectNeedsChangelogHydration } from '../utils/changelogHydration'; +import { isEmbeddedDependency, isExternalDependency } from '../utils/dependencyEntries'; import { useScrollLock } from '@/hooks/useScrollLock'; import '../styles/downloadFx.css'; @@ -419,7 +420,7 @@ export const ProjectDetails: React.FC = ({ return; } - const selectableDeps = (deps || []).filter(dep => getDependencyId(dep) && !dep?.isEmbedded); + const selectableDeps = (deps || []).filter(dep => getDependencyId(dep) && !isEmbeddedDependency(dep) && !isExternalDependency(dep)); if (selectableDeps.length > 0) { setPendingDownload({ versionNumber, gameVersion, dependencies: selectableDeps, channel: downloadChannel }); setIsDepModalOpen(true); diff --git a/frontend/src/modules/project/views/ProjectEditor.tsx b/frontend/src/modules/project/views/ProjectEditor.tsx index 9fc47dbf..b8e344b9 100644 --- a/frontend/src/modules/project/views/ProjectEditor.tsx +++ b/frontend/src/modules/project/views/ProjectEditor.tsx @@ -3,7 +3,7 @@ import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'; import { createPortal } from 'react-dom'; import { Save, UploadCloud, Eye, Image as ImageIcon, Users, BookOpen, Settings, FileText, ExternalLink, Send, Check, X, Tag, Scale, Link as LinkIcon, Edit2, Edit3, XCircle, Undo2, AlertTriangle, Info } from 'lucide-react'; -import type { User, Project, ProjectVersion } from '@/types'; +import type { ProjectDependency, User, Project, ProjectVersion } from '@/types'; import { theme } from '@/styles/theme'; import { SiteRoutes } from '@/utils/routes'; import { GLOBAL_TAGS, LICENSES } from '@/data/categories'; @@ -20,7 +20,6 @@ import { Settings as SettingsTab } from '../tabs/Settings'; import { WikiPreview } from '../tabs/WikiPreview'; import { projectClient } from '../api/projectClient'; import { api, extractApiErrorMessage } from '@/utils/api'; -import { serializeProjectDependency } from '../utils/dependencyEntries'; import { Spinner } from '@/components/ui/Spinner'; import { ImageCropperModal } from '@/components/ui/ImageCropperModal'; import { StatusModal } from '@/components/ui/StatusModal'; @@ -34,6 +33,22 @@ import { VersionFields } from '../components/VersionFields'; const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; const MAX_UPLOAD_ERROR_MESSAGE = 'File exceeds 100MB limit. Cloudflare only supports uploads up to 100MB.'; + +const appendDependenciesToFormData = (formData: FormData, dependencies: ProjectDependency[] = []) => { + dependencies.forEach((dependency, index) => { + if (dependency.id) formData.append(`dependencies[${index}].id`, dependency.id); + formData.append(`dependencies[${index}].projectId`, dependency.projectId); + formData.append(`dependencies[${index}].projectTitle`, dependency.projectTitle || ''); + formData.append(`dependencies[${index}].versionNumber`, dependency.versionNumber); + formData.append(`dependencies[${index}].dependencyType`, dependency.dependencyType || 'REQUIRED'); + formData.append(`dependencies[${index}].source`, dependency.source || 'MODTALE'); + if (dependency.externalId) formData.append(`dependencies[${index}].externalId`, dependency.externalId); + if (dependency.externalUrl) formData.append(`dependencies[${index}].externalUrl`, dependency.externalUrl); + if (dependency.externalFileUrl) formData.append(`dependencies[${index}].externalFileUrl`, dependency.externalFileUrl); + if (dependency.externalFileName) formData.append(`dependencies[${index}].externalFileName`, dependency.externalFileName); + if (dependency.hytaleProjectConfirmed) formData.append(`dependencies[${index}].hytaleProjectConfirmed`, 'true'); + }); +}; const isFileOverUploadLimit = (file: File) => file.size > MAX_UPLOAD_BYTES; interface ProjectEditorViewProps { @@ -55,7 +70,7 @@ export const ProjectEditorView: React.FC = ({ currentUse title: '', summary: '', description: '', tags: [], links: {}, repositoryUrl: '', iconFile: null, iconPreview: null, slug: '' }); const [versionData, setVersionData] = useState({ - projectIds: [], incompatibleProjectIds: [], versionNumber: '', gameVersions: [], changelog: '', file: null, dependencies: [], modIds: [], channel: 'RELEASE' + dependencies: [], incompatibleProjectIds: [], versionNumber: '', gameVersions: [], changelog: '', file: null, channel: 'RELEASE' }); const [bannerFile, setBannerFile] = useState(null); @@ -361,7 +376,7 @@ export const ProjectEditorView: React.FC = ({ currentUse formData.append('versionNumber', versionData.versionNumber); versionData.gameVersions.forEach(version => formData.append('gameVersions', version)); if (versionData.file) formData.append('file', versionData.file); - (versionData.projectIds || []).forEach(dep => formData.append('modIds', dep)); + appendDependenciesToFormData(formData, versionData.dependencies || []); (versionData.incompatibleProjectIds || []).forEach(projectId => formData.append('incompatibleProjectIds', projectId)); if (versionData.changelog) formData.append('changelog', versionData.changelog); formData.append('channel', versionData.channel || 'RELEASE'); @@ -373,14 +388,12 @@ export const ProjectEditorView: React.FC = ({ currentUse const refreshed = await projectClient.getProject(projectData.id); setProjectData(refreshed); setVersionData({ - projectIds: versionData.projectIds || [], + dependencies: versionData.dependencies || [], incompatibleProjectIds: versionData.incompatibleProjectIds || [], versionNumber: '', gameVersions: versionData.gameVersions, changelog: '', file: null, - dependencies: [], - modIds: [], channel: versionData.channel || 'RELEASE' }); onShowStatus('success', 'Uploaded', 'Version uploaded successfully.'); @@ -394,14 +407,12 @@ export const ProjectEditorView: React.FC = ({ currentUse const handleStartEditVersion = (version: ProjectVersion) => { setEditingVersion(version); setEditVersionData({ - projectIds: (version.dependencies || []).map(serializeProjectDependency), + dependencies: version.dependencies || [], incompatibleProjectIds: version.incompatibleProjectIds || [], versionNumber: version.versionNumber || '', gameVersions: version.gameVersions || (version.gameVersion ? [version.gameVersion] : []), changelog: version.changelog || '', file: null, - dependencies: [], - modIds: [], channel: version.channel || 'RELEASE' }); }; @@ -415,7 +426,7 @@ export const ProjectEditorView: React.FC = ({ currentUse setIsSavingVersion(true); try { await projectClient.updateVersion(projectData.id, editingVersion.id, { - modIds: editVersionData.projectIds || [], + dependencies: editVersionData.dependencies || [], incompatibleProjectIds: editVersionData.incompatibleProjectIds || [], gameVersions: editVersionData.gameVersions, changelog: editVersionData.changelog || '', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8252cd2c..f47fabfb 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -5,6 +5,9 @@ export enum VersionRelationKind { INCOMPATIBILITY = 'INCOMPATIBILITY' } +export type DependencySource = 'MODTALE' | 'CURSEFORGE' | 'GITHUB' | 'WEBSITE' | 'OTHER'; +export type DependencyType = 'REQUIRED' | 'OPTIONAL' | 'EMBEDDED'; + export interface ConnectedAccount { provider: string; providerId: string; @@ -57,11 +60,39 @@ export interface User { } export interface ProjectDependency { + id?: string; projectId: string; projectTitle: string; versionNumber: string; - isOptional?: boolean; - isEmbedded?: boolean; + dependencyType?: DependencyType; + source?: DependencySource | string; + externalId?: string; + externalUrl?: string; + externalFileUrl?: string; + externalFileName?: string; + cachedFileUrl?: string; + hytaleProjectConfirmed?: boolean; +} + +export interface ExternalProjectFile { + id?: string; + displayName?: string; + fileName?: string; + versionNumber?: string; + releaseType?: string; + downloadUrl?: string; +} + +export interface ExternalProjectReference { + source: DependencySource; + externalId: string; + title: string; + versionNumber: string; + externalUrl: string; + iconUrl?: string; + summary?: string; + hytaleProjectConfirmed: boolean; + files?: ExternalProjectFile[]; } export interface ManifestDependencySuggestion { @@ -72,7 +103,6 @@ export interface ManifestDependencySuggestion { versionNumber: string; optional: boolean; confidence: number; - dependencyEntry: string; } export interface ManifestInspectionResult { @@ -248,7 +278,6 @@ export interface Project { favoriteCount: number; updatedAt: string; createdAt?: string; - projectIds?: string[]; childProjectIds?: string[]; sizeBytes?: number; comments: Comment[]; diff --git a/frontend/tests/modules/project/Files.test.tsx b/frontend/tests/modules/project/Files.test.tsx index cd40be55..084798fa 100644 --- a/frontend/tests/modules/project/Files.test.tsx +++ b/frontend/tests/modules/project/Files.test.tsx @@ -32,13 +32,11 @@ const projectData = { } as any; const versionData = { - projectIds: [], versionNumber: '1.0.1', gameVersions: ['2026.03.11'], changelog: '', file: null, dependencies: [], - modIds: [], channel: 'RELEASE' } as any; diff --git a/frontend/tests/modules/project/tabs/Files.test.tsx b/frontend/tests/modules/project/tabs/Files.test.tsx index 708b7a26..b0e7ef57 100644 --- a/frontend/tests/modules/project/tabs/Files.test.tsx +++ b/frontend/tests/modules/project/tabs/Files.test.tsx @@ -4,20 +4,19 @@ import { createRoot, type Root } from 'react-dom/client'; import { Files } from '@/modules/project/tabs/Files'; import type { VersionFormData } from '@/modules/project/components/FormShared'; -const mockVersionFields = vi.fn(() =>
); +const mockVersionFields = vi.fn((props: any) =>
); vi.mock('@/modules/project/components/VersionFields', () => ({ VersionFields: (props: any) => mockVersionFields(props) })); const versionData: VersionFormData = { - projectIds: [], versionNumber: '', gameVersions: [], changelog: '', file: null, dependencies: [], - modIds: [], + incompatibleProjectIds: [], channel: 'RELEASE' }; @@ -132,9 +131,9 @@ describe('Files upload rules for drafts', () => { }); expect(mockVersionFields).toHaveBeenCalled(); - const props = mockVersionFields.mock.calls.at(-1)?.[0]; - expect(props.existingVersions).toEqual(['1.0.0']); - expect(props.previousDependencies).toEqual([{ + const props = mockVersionFields.mock.calls.at(-1)?.[0] as any; + expect(props?.existingVersions).toEqual(['1.0.0']); + expect(props?.previousDependencies).toEqual([{ projectId: 'dep-1', projectTitle: 'Dependency One', versionNumber: '2.0.0' @@ -144,7 +143,13 @@ describe('Files upload rules for drafts', () => { it('hides previous dependency import when dependencies are already selected', async () => { const populatedVersionData: VersionFormData = { ...versionData, - projectIds: ['dep-2:3.0.0'] + dependencies: [{ + projectId: 'dep-2', + projectTitle: 'Dependency Two', + versionNumber: '3.0.0', + dependencyType: 'REQUIRED', + source: 'MODTALE' + }] }; await act(async () => { @@ -180,8 +185,8 @@ describe('Files upload rules for drafts', () => { ); }); - const props = mockVersionFields.mock.calls.at(-1)?.[0]; - expect(props.previousDependencies).toBeUndefined(); + const props = mockVersionFields.mock.calls.at(-1)?.[0] as any; + expect(props?.previousDependencies).toBeUndefined(); }); it('lets creators reuse the latest setup in one click', async () => { @@ -201,7 +206,8 @@ describe('Files upload rules for drafts', () => { projectId: 'dep-1', projectTitle: 'Dependency One', versionNumber: '2.0.0', - isOptional: true + dependencyType: 'OPTIONAL', + source: 'MODTALE' }], channel: 'BETA', fileUrl: '/download.jar', @@ -235,7 +241,13 @@ describe('Files upload rules for drafts', () => { expect(updater(versionData)).toMatchObject({ gameVersions: ['1.21'], channel: 'BETA', - projectIds: ['dep-1:2.0.0:optional'] + dependencies: [{ + projectId: 'dep-1', + projectTitle: 'Dependency One', + versionNumber: '2.0.0', + dependencyType: 'OPTIONAL', + source: 'MODTALE' + }] }); }); }); diff --git a/frontend/tests/modules/project/utils/dependencyEntries.test.ts b/frontend/tests/modules/project/utils/dependencyEntries.test.ts index def016bf..5025b0e8 100644 --- a/frontend/tests/modules/project/utils/dependencyEntries.test.ts +++ b/frontend/tests/modules/project/utils/dependencyEntries.test.ts @@ -1,39 +1,50 @@ import { describe, expect, it } from 'vitest'; -import { parseDependencyEntry, serializeDependencyEntry, serializeProjectDependency } from '@/modules/project/utils/dependencyEntries'; +import { + dependencyProjectKey, + getDependencyType, + isEmbeddedDependency, + isExternalDependency, + isOptionalDependency, + normalizeDependencyReference +} from '@/modules/project/utils/dependencyEntries'; +import type { ProjectDependency } from '@/types'; describe('dependencyEntries', () => { - it('parses optional and embedded flags in any order', () => { - expect(parseDependencyEntry('dep-1:1.2.3:embedded:optional')).toEqual({ + it('normalizes missing dependency shape defaults', () => { + const dependency = normalizeDependencyReference({ projectId: 'dep-1', - versionNumber: '1.2.3', - isOptional: true, - isEmbedded: true + projectTitle: 'Dependency One', + versionNumber: '1.2.3' }); + + expect(dependency.dependencyType).toBe('REQUIRED'); + expect(dependency.source).toBe('MODTALE'); }); - it('serializes dependency flags without dropping compatibility for empty flags', () => { - expect(serializeDependencyEntry({ - projectId: 'dep-1', - versionNumber: '1.2.3', - isOptional: false, - isEmbedded: false - })).toBe('dep-1:1.2.3'); + it('classifies dependency types from structured fields', () => { + const optional = dependency('OPTIONAL'); + const embedded = dependency('EMBEDDED'); - expect(serializeDependencyEntry({ - projectId: 'dep-1', - versionNumber: '1.2.3', - isOptional: true, - isEmbedded: true - })).toBe('dep-1:1.2.3:optional:embedded'); + expect(getDependencyType(optional)).toBe('OPTIONAL'); + expect(isOptionalDependency(optional)).toBe(true); + expect(isEmbeddedDependency(optional)).toBe(false); + expect(isEmbeddedDependency(embedded)).toBe(true); }); - it('serializes project dependency objects from API data', () => { - expect(serializeProjectDependency({ - projectId: 'dep-1', - projectTitle: 'Dependency One', - versionNumber: '1.2.3', - isOptional: true, - isEmbedded: true - })).toBe('dep-1:1.2.3:optional:embedded'); + it('keeps source in the dependency key so external IDs do not collide with Modtale IDs', () => { + expect(dependencyProjectKey(dependency('REQUIRED', 'MODTALE'))).toBe('MODTALE:dep-1'); + expect(dependencyProjectKey(dependency('REQUIRED', 'GITHUB'))).toBe('GITHUB:dep-1'); + expect(isExternalDependency(dependency('REQUIRED', 'GITHUB'))).toBe(true); }); }); + +const dependency = ( + dependencyType: ProjectDependency['dependencyType'], + source: ProjectDependency['source'] = 'MODTALE' +): ProjectDependency => ({ + projectId: 'dep-1', + projectTitle: 'Dependency One', + versionNumber: '1.2.3', + dependencyType, + source +}); From a1562cfbd675e1428c846385c158dbb43ad2bdff Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:06:28 -0400 Subject: [PATCH 02/13] Fix tests --- frontend/src/data/categories.ts | 4 ++-- frontend/tests/modules/home/Home.test.tsx | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/data/categories.ts b/frontend/src/data/categories.ts index bc3cb110..272b61c9 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: 'SAVE', label: 'Worlds', icon: Save }, + { id: 'MODPACK', label: 'Modpacks', icon: Layers } ]; export const LICENSES = [ diff --git a/frontend/tests/modules/home/Home.test.tsx b/frontend/tests/modules/home/Home.test.tsx index f7f7dd61..12dd2d8d 100644 --- a/frontend/tests/modules/home/Home.test.tsx +++ b/frontend/tests/modules/home/Home.test.tsx @@ -23,6 +23,7 @@ vi.mock('@/modules/home/components/HeroMarquee', () => ({ vi.mock('@/modules/home/components/FeaturePreviews', () => ({ TrendingProjectsSection: () =>
, NewReleasesSection: () =>
, + ModpackPreviewSection: () =>
, DirectDownloadsSection: () =>
, SmartDependenciesSection: () =>
, ProjectAnalyticsSection: () =>
, From db84996cbca622a523e54bfed756165e856ce839 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:14:30 -0400 Subject: [PATCH 03/13] Fix deployment --- .../project/version/ExternalDependencyArtifactService.java | 2 ++ 1 file changed, 2 insertions(+) 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 index c34209fd..e3886acc 100644 --- a/backend/src/main/java/net/modtale/service/project/version/ExternalDependencyArtifactService.java +++ b/backend/src/main/java/net/modtale/service/project/version/ExternalDependencyArtifactService.java @@ -22,6 +22,7 @@ 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 @@ -41,6 +42,7 @@ public class ExternalDependencyArtifactService { private final StorageService storageService; private final HttpClient httpClient; + @Autowired public ExternalDependencyArtifactService(StorageService storageService) { this( storageService, From 2b5ecb4e0898f85daa46974b18c12ee16b6289a2 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:25:08 -0400 Subject: [PATCH 04/13] Put mods in the root --- .../storage/ModpackArchiveService.java | 49 ++++++++++++------- .../storage/ModpackArchiveServiceTest.java | 21 ++++++-- 2 files changed, 47 insertions(+), 23 deletions(-) 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 279048d1..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,7 +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; @@ -56,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(); } @@ -102,14 +106,14 @@ 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); + writeExternalDependencyFile(zos, dependency, archiveEntries); continue; } @@ -119,19 +123,13 @@ private void writeDependencyFiles(ZipOutputStream zos, ProjectVersion version) t } 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) throws IOException { + private void writeExternalDependencyFile(ZipOutputStream zos, ProjectDependency dependency, Set archiveEntries) throws IOException { String cachedFileUrl = trimToNull(dependency.getCachedFileUrl()); if (cachedFileUrl == null) { return; @@ -143,20 +141,33 @@ private void writeExternalDependencyFile(ZipOutputStream zos, ProjectDependency return; } - zos.putNextEntry(new ZipEntry("external/" + externalSourceFolder(dependency) + "/" + externalFilename(dependency))); - zos.write(fileData); - zos.closeEntry(); + 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 String externalSourceFolder(ProjectDependency dependency) { - return dependency.getSource().name().toLowerCase(Locale.ROOT) - .replaceAll("[^a-z0-9._-]+", "-") - .replaceAll("-+", "-") - .replaceAll("(^-|-$)", ""); + 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) { 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); } From 283164a6287f66aa3c183617354d33fb3ecc7489 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:25:39 -0400 Subject: [PATCH 05/13] Make external sources even more clear --- .../service/storage/DownloadServiceTest.java | 4 +- .../project/components/DependencySelector.tsx | 140 ++++++++++++++---- .../components/dialogs/DownloadModal.tsx | 42 +++++- .../components/dialogs/HistoryModal.tsx | 19 ++- .../project/utils/dependencyEntries.ts | 3 + .../modules/project/views/ProjectDetails.tsx | 2 + .../project/components/DownloadModal.test.tsx | 87 +++++++++++ 7 files changed, 266 insertions(+), 31 deletions(-) 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 82241379..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); diff --git a/frontend/src/modules/project/components/DependencySelector.tsx b/frontend/src/modules/project/components/DependencySelector.tsx index a45d060b..19a46c7f 100644 --- a/frontend/src/modules/project/components/DependencySelector.tsx +++ b/frontend/src/modules/project/components/DependencySelector.tsx @@ -11,6 +11,7 @@ import { dependencyProjectKey, getDependencyType, isExternalDependency, isOption import { useToast } from '@/components/ui/Toast'; 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[]; @@ -65,6 +66,88 @@ const getSourceLabel = (source?: string) => { } }; +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 ( +
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setOpen(false); + } + }}> + + {open && ( +
+ {options.map(option => { + const selectedOption = option.value === value; + return ( + + ); + })} +
+ )} +
+ ); +}; + const buildModtaleDependency = (project: Project, versionNumber: string, dependencyType: DependencyType): ProjectDependency => ({ id: createUuid(), projectId: project.id, @@ -441,9 +524,17 @@ export const DependencySelector: React.FC = ({ const selectedExternalFile = useMemo(() => { if (!externalResolved?.files?.length || !externalSelectedFileId) return null; - return externalResolved.files.find(file => file.id === externalSelectedFileId) || 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; @@ -614,13 +705,11 @@ export const DependencySelector: React.FC = ({ Fetch
- + {externalResolved && (
@@ -635,18 +724,16 @@ export const DependencySelector: React.FC = ({
)} - {externalResolved?.files && externalResolved.files.length > 0 && ( - + {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 && ( @@ -847,11 +934,14 @@ export const DependencySelector: React.FC = ({
{dependency && !isModpack && !isIncompatibilityMode && ( - + cycleDependencyType(index, nextType)} + disabled={disabled} + className="w-32" + buttonClassName="rounded-lg px-2 py-1.5 text-xs" + /> )} {dependency && isExternal && dependency.externalUrl && ( diff --git a/frontend/src/modules/project/components/dialogs/DownloadModal.tsx b/frontend/src/modules/project/components/dialogs/DownloadModal.tsx index 63fd2b36..f9226f91 100644 --- a/frontend/src/modules/project/components/dialogs/DownloadModal.tsx +++ b/frontend/src/modules/project/components/dialogs/DownloadModal.tsx @@ -5,6 +5,8 @@ import { DropdownSelect, type DropdownOption } from '@/components/ui/DropdownSel import { theme } from '@/styles/theme'; import { formatTimeAgo, compareSemVer } from '@/utils/modHelpers'; import { useScrollLock } from '@/hooks/useScrollLock'; +import { getExternalDependencies } from '@/modules/project/utils/dependencyEntries'; +import type { ProjectDependency } from '@/types'; interface DownloadModalProps { show: boolean; @@ -16,12 +18,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 [selectedGameVer, setSelectedGameVer] = useState(''); @@ -193,6 +196,36 @@ export const DownloadModal: React.FC = ({ return versions.filter((gv: string) => gv !== selectedGameVer); }; + 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()}>
@@ -254,6 +287,7 @@ export const DownloadModal: React.FC = ({
)} + {renderExternalDependencyNotice(latestVer)}
@@ -286,6 +320,12 @@ export const DownloadModal: React.FC = ({ Also supports: {otherCompatibleVersions(ver).join(', ')}
)} + {externalDependenciesFor(ver).length > 0 && ( +
+
+ )}
void; hasExperimentalVersions?: boolean; hasStableVersions?: boolean; + isModpack?: boolean; isInline?: boolean; inlineHeight?: number; } @@ -24,15 +26,19 @@ const HistoryVersionItem = memo(({ isExpanded, onToggleExpand, onDownload, - badgeClass + badgeClass, + isModpack }: { ver: any; isExpanded: boolean; onToggleExpand: (id: string) => void; onDownload: (e: React.MouseEvent, ver: any) => void; badgeClass: string; + isModpack: boolean; }) => { const isLong = ver.changelog && ver.changelog.length > 300; + const externalDependencyCount = isModpack ? getExternalDependencies(ver.dependencies).length : 0; + return (
{(ver.downloadCount || 0).toLocaleString()}
+ {externalDependencyCount > 0 && ( +
+
+ )}
= ({ - show, onClose, history, showExperimental, onToggleExperimental, onDownload, hasExperimentalVersions, hasStableVersions, isInline = false, inlineHeight + show, onClose, history, showExperimental, onToggleExperimental, onDownload, hasExperimentalVersions, hasStableVersions, isModpack = false, isInline = false, inlineHeight }) => { useScrollLock(show && !isInline); const [expandedChangelog, setExpandedChangelog] = useState(null); @@ -156,6 +168,7 @@ export const HistoryModal: React.FC = ({ onToggleExpand={handleToggleExpand} onDownload={handleDownloadClick} badgeClass={getVersionBadgeColor(ver.channel)} + isModpack={isModpack} /> ); })} diff --git a/frontend/src/modules/project/utils/dependencyEntries.ts b/frontend/src/modules/project/utils/dependencyEntries.ts index eb554192..6f6c1bcc 100644 --- a/frontend/src/modules/project/utils/dependencyEntries.ts +++ b/frontend/src/modules/project/utils/dependencyEntries.ts @@ -12,6 +12,9 @@ export const isEmbeddedDependency = (dependency?: Pick): boolean => (dependency?.source || 'MODTALE') !== 'MODTALE'; +export const getExternalDependencies = (dependencies?: ProjectDependency[] | null): ProjectDependency[] => + (dependencies || []).filter(dependency => isExternalDependency(dependency) && !isEmbeddedDependency(dependency)); + export const normalizeDependencyReference = (dependency: ProjectDependency): ProjectDependency => ({ ...dependency, dependencyType: dependency.dependencyType || 'REQUIRED', diff --git a/frontend/src/modules/project/views/ProjectDetails.tsx b/frontend/src/modules/project/views/ProjectDetails.tsx index b0ef2e07..5a7c8a6d 100644 --- a/frontend/src/modules/project/views/ProjectDetails.tsx +++ b/frontend/src/modules/project/views/ProjectDetails.tsx @@ -512,6 +512,7 @@ export const ProjectDetails: React.FC = ({ onToggleExperimental={toggleExperimental} onDownload={handleDownloadClick} hasStableVersions={hasStableBuilds} + isModpack={project.classification === 'MODPACK'} /> )} {isDownloadOpen && ( @@ -525,6 +526,7 @@ export const ProjectDetails: React.FC = ({ showExperimental={showExperimental} onToggleExperimental={toggleExperimental} onViewHistory={() => navigate(projectUrl + '/changelog')} + isModpack={project.classification === 'MODPACK'} /> )} {isDepModalOpen && pendingDownload && ( diff --git a/frontend/tests/modules/project/components/DownloadModal.test.tsx b/frontend/tests/modules/project/components/DownloadModal.test.tsx index ab5aa94e..1b611f46 100644 --- a/frontend/tests/modules/project/components/DownloadModal.test.tsx +++ b/frontend/tests/modules/project/components/DownloadModal.test.tsx @@ -139,4 +139,91 @@ describe('DownloadModal Toggle Visibility', () => { expect(container.textContent).not.toContain('Show Pre-Release Game Versions'); }); + + it('warns when a modpack version includes external mods', async () => { + const versionsByGame = { + '0.5.4': [ + { + id: 'v1', + versionNumber: '1.0.0', + channel: 'RELEASE', + gameVersion: '0.5.4', + releaseDate: new Date().toISOString(), + dependencies: [ + { + projectId: 'external-shader', + projectTitle: 'External Shader', + versionNumber: '2.0.0', + source: 'CURSEFORGE' + } + ] + } + ] + }; + + await act(async () => { + root.render( + + + + ); + }); + + expect(container.textContent).toContain('This modpack uses external mods'); + expect(container.textContent).toContain('External Shader'); + }); + + it('does not show the external mod warning for non-modpack projects', async () => { + const versionsByGame = { + '0.5.4': [ + { + id: 'v1', + versionNumber: '1.0.0', + channel: 'RELEASE', + gameVersion: '0.5.4', + releaseDate: new Date().toISOString(), + dependencies: [ + { + projectId: 'external-library', + projectTitle: 'External Library', + versionNumber: '2.0.0', + source: 'CURSEFORGE' + } + ] + } + ] + }; + + await act(async () => { + root.render( + + + + ); + }); + + expect(container.textContent).not.toContain('This modpack uses external mods'); + expect(container.textContent).not.toContain('External Library'); + }); }); From 5db1d95f8c5f4916fda2ec18ebda06cc6d2e13cb Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:24:09 -0400 Subject: [PATCH 06/13] Make the orgder of navbar and category selector line up --- frontend/src/data/categories.ts | 6 +- .../src/modules/core/components/Navbar.tsx | 88 +++++++------------ frontend/tests/data/categories.test.ts | 6 +- 3 files changed, 38 insertions(+), 62 deletions(-) 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 423c6bc4..1e029ec3 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 type { User } from "@/types.ts"; const NotificationMenu = lazy(() => import('@/modules/user/components/NotificationMenu').then((module) => ({ default: module.NotificationMenu }))); @@ -141,55 +142,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" - > - - 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" - > - - 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" - > - - 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} + +
+ ); + })}
)}
@@ -330,12 +298,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"> 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"> 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"> 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/tests/data/categories.test.ts b/frontend/tests/data/categories.test.ts index 5667934d..12997fc9 100644 --- a/frontend/tests/data/categories.test.ts +++ b/frontend/tests/data/categories.test.ts @@ -21,11 +21,11 @@ describe('category data', () => { it('lists supported project types and license options', () => { expect(PROJECT_TYPES.map(type => type.id)).toEqual([ 'All', + 'MODPACK', 'PLUGIN', - 'DATA', - 'ART', 'SAVE', - 'MODPACK' + 'ART', + 'DATA' ]); expect(LICENSES.some(license => license.id === 'MIT')).toBe(true); expect(LICENSES.some(license => license.id === 'CC-BY-NC-SA-4.0')).toBe(true); From 7dbfed01a281f5eb8df8afb2ff03115de7570002 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:01:47 -0400 Subject: [PATCH 07/13] Attempt to redeploy --- backend/cloudbuild.yml | 2 +- frontend/cloudbuild.yml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) 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/frontend/cloudbuild.yml b/frontend/cloudbuild.yml index 0a589510..00a1bc08 100644 --- a/frontend/cloudbuild.yml +++ b/frontend/cloudbuild.yml @@ -19,5 +19,4 @@ substitutions: _TAG: latest options: - defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET - \ No newline at end of file + defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET \ No newline at end of file From 003b5db1b629aaae2a36b325a7252425462142a2 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:06:18 -0400 Subject: [PATCH 08/13] Use account-owned Cloudflare token endpoints --- .github/scripts/cloudflare-r2-preview.mjs | 49 +++++++++++++++++++---- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/.github/scripts/cloudflare-r2-preview.mjs b/.github/scripts/cloudflare-r2-preview.mjs index c5551d12..c72edd28 100644 --- a/.github/scripts/cloudflare-r2-preview.mjs +++ b/.github/scripts/cloudflare-r2-preview.mjs @@ -4,6 +4,8 @@ import { createHash } from "node:crypto"; const API_BASE = "https://api.cloudflare.com/client/v4"; const R2_BUCKET_ITEM_WRITE = "Workers R2 Storage Bucket Item Write"; +const TOKEN_OWNER_ACCOUNT = "account"; +const TOKEN_OWNER_USER = "user"; const mode = process.argv[2]; @@ -46,6 +48,30 @@ function endpointFor(accountId, jurisdiction) { return `https://${accountId}.r2.cloudflarestorage.com`; } +function tokenOwner() { + const owner = optional("CLOUDFLARE_API_TOKEN_OWNER", TOKEN_OWNER_ACCOUNT); + if (![TOKEN_OWNER_ACCOUNT, TOKEN_OWNER_USER].includes(owner)) { + throw new Error( + `Unsupported CLOUDFLARE_API_TOKEN_OWNER '${owner}'. Use '${TOKEN_OWNER_ACCOUNT}' or '${TOKEN_OWNER_USER}'.`, + ); + } + return owner; +} + +function tokenCollectionPath(accountId) { + return tokenOwner() === TOKEN_OWNER_USER + ? "/user/tokens" + : `/accounts/${accountId}/tokens`; +} + +function tokenItemPath(accountId, tokenId) { + return `${tokenCollectionPath(accountId)}/${encodeURIComponent(tokenId)}`; +} + +function tokenPermissionGroupsPath(accountId) { + return `${tokenCollectionPath(accountId)}/permission_groups`; +} + function bucketResource(accountId, jurisdiction, bucketName) { return `com.cloudflare.edge.r2.bucket.${accountId}_${jurisdiction}_${bucketName}`; } @@ -150,8 +176,11 @@ async function ensureBucket(accountId, token, bucketName, jurisdiction) { console.log(`Created R2 bucket '${bucketName}'.`); } -async function permissionGroupId(token, permissionName) { - const payload = await cloudflare(token, "/user/tokens/permission_groups"); +async function permissionGroupId(accountId, token, permissionName) { + const payload = await cloudflare( + token, + `${tokenPermissionGroupsPath(accountId)}?name=${encodeURIComponent(permissionName)}`, + ); const match = payload.result?.find((group) => group.name === permissionName); if (!match?.id) { throw new Error(`Cloudflare permission group '${permissionName}' was not found.`); @@ -159,12 +188,12 @@ async function permissionGroupId(token, permissionName) { return match.id; } -async function revokeToken(token, tokenId, reason) { +async function revokeToken(accountId, token, tokenId, reason) { if (!tokenId) { return; } try { - await cloudflare(token, `/user/tokens/${encodeURIComponent(tokenId)}`, { + await cloudflare(token, tokenItemPath(accountId, tokenId), { method: "DELETE", }); console.log(`Revoked Cloudflare token '${tokenId}'${reason ? ` (${reason})` : ""}.`); @@ -178,7 +207,7 @@ async function revokeToken(token, tokenId, reason) { } async function createRuntimeToken(accountId, token, bucketName, jurisdiction) { - const groupId = await permissionGroupId(token, R2_BUCKET_ITEM_WRITE); + const groupId = await permissionGroupId(accountId, token, R2_BUCKET_ITEM_WRITE); const tokenName = optional("R2_TOKEN_NAME", `modtale preview ${bucketName}`); const expiresOn = optional("R2_TOKEN_EXPIRES_ON"); @@ -199,7 +228,7 @@ async function createRuntimeToken(accountId, token, bucketName, jurisdiction) { body.expires_on = expiresOn; } - const payload = await cloudflare(token, "/user/tokens", { + const payload = await cloudflare(token, tokenCollectionPath(accountId), { method: "POST", body: JSON.stringify(body), }); @@ -235,6 +264,7 @@ async function provision() { await ensureBucket(accountId, bucketToken, bucketName, jurisdiction); await revokeToken( + accountId, tokenProvisioner, optional("EXISTING_R2_RUNTIME_TOKEN_ID"), "replacing runtime token", @@ -301,7 +331,12 @@ async function cleanup() { } if (tokenProvisioner) { - await revokeToken(tokenProvisioner, optional("R2_RUNTIME_TOKEN_ID"), "preview cleanup"); + await revokeToken( + accountId, + tokenProvisioner, + optional("R2_RUNTIME_TOKEN_ID"), + "preview cleanup", + ); } } From 70dbd3b37a76f0783c81941cb946108e5a09d55c Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:07:48 -0400 Subject: [PATCH 09/13] Attempt to redeploy --- backend/cloudbuild.yml | 2 +- frontend/cloudbuild.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/cloudbuild.yml b/backend/cloudbuild.yml index df8f8b71..814d16b5 100644 --- a/backend/cloudbuild.yml +++ b/backend/cloudbuild.yml @@ -17,4 +17,4 @@ substitutions: _TAG: latest options: - defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET \ No newline at end of file + defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET diff --git a/frontend/cloudbuild.yml b/frontend/cloudbuild.yml index 00a1bc08..0a589510 100644 --- a/frontend/cloudbuild.yml +++ b/frontend/cloudbuild.yml @@ -19,4 +19,5 @@ substitutions: _TAG: latest options: - defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET \ No newline at end of file + defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET + \ No newline at end of file From 79dc398cd5dd27961f8f2d2c9c9b242be541c23c Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:10:49 -0400 Subject: [PATCH 10/13] Load branch R2 runtime credentials in current step --- .github/scripts/cloudflare-r2-preview.mjs | 23 +++++++++++++++++++++-- .github/workflows/ci-cd.yml | 6 ++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/scripts/cloudflare-r2-preview.mjs b/.github/scripts/cloudflare-r2-preview.mjs index c72edd28..4b12f2f1 100644 --- a/.github/scripts/cloudflare-r2-preview.mjs +++ b/.github/scripts/cloudflare-r2-preview.mjs @@ -94,6 +94,25 @@ function appendGitHubEnv(values) { }); } +async function writeShellEnvFile(values) { + const envFile = optional("R2_RUNTIME_ENV_FILE"); + if (!envFile) { + return; + } + + const { appendFileSync } = await import("node:fs"); + const lines = Object.entries(values).map(([key, value]) => { + const escaped = String(value).replace(/'/g, "'\\''"); + return `${key}='${escaped}'`; + }); + appendFileSync(envFile, `${lines.join("\n")}\n`, "utf8"); +} + +async function exportRuntimeValues(values) { + await appendGitHubEnv(values); + await writeShellEnvFile(values); +} + async function cloudflare(token, path, init = {}) { const response = await fetch(`${API_BASE}${path}`, { ...init, @@ -277,7 +296,7 @@ async function provision() { jurisdiction, ); - await appendGitHubEnv({ + await exportRuntimeValues({ R2_RUNTIME_ACCESS_KEY: credentials.accessKey, R2_RUNTIME_SECRET_KEY: credentials.secretKey, R2_RUNTIME_TOKEN_ID: credentials.tokenId, @@ -297,7 +316,7 @@ async function ensureOnly() { const jurisdiction = normalizeJurisdiction(optional("CLOUDFLARE_R2_JURISDICTION")); await ensureBucket(accountId, bucketToken, bucketName, jurisdiction); - await appendGitHubEnv({ + await exportRuntimeValues({ R2_RUNTIME_ENDPOINT: endpointFor(accountId, jurisdiction), }); } diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index f92a4c22..dffbf671 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -179,7 +179,13 @@ jobs: echo "::add-mask::$existing_token_id" export EXISTING_R2_RUNTIME_TOKEN_ID="$existing_token_id" + runtime_env_file="$(mktemp)" + trap 'rm -f "$runtime_env_file"' EXIT + export R2_RUNTIME_ENV_FILE="$runtime_env_file" node .github/scripts/cloudflare-r2-preview.mjs provision + set -a + . "$runtime_env_file" + set +a upsert_secret() { local name="$1" From 6c9a29ecb0233de3149e5eae54c41c0d5cfaa3a9 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:15:23 -0400 Subject: [PATCH 11/13] Attempt to redeploy --- backend/cloudbuild.yml | 2 +- frontend/cloudbuild.yml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) 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/frontend/cloudbuild.yml b/frontend/cloudbuild.yml index 0a589510..00a1bc08 100644 --- a/frontend/cloudbuild.yml +++ b/frontend/cloudbuild.yml @@ -19,5 +19,4 @@ substitutions: _TAG: latest options: - defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET - \ No newline at end of file + defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET \ No newline at end of file From 2a702d4b2339a7a96e29cfb584e6d39d595eaa5b Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:30:23 -0400 Subject: [PATCH 12/13] Fix modpack dependency update fallout --- frontend/src/types.ts | 1 + .../modules/project/components/DownloadModal.test.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 81560236..96e3ebf0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -291,6 +291,7 @@ export interface Project { favoriteCount: number; updatedAt: string; createdAt?: string; + projectIds?: string[]; childProjectIds?: string[]; sizeBytes?: number; comments?: Comment[]; diff --git a/frontend/tests/modules/project/components/DownloadModal.test.tsx b/frontend/tests/modules/project/components/DownloadModal.test.tsx index 1895e841..7820ac4b 100644 --- a/frontend/tests/modules/project/components/DownloadModal.test.tsx +++ b/frontend/tests/modules/project/components/DownloadModal.test.tsx @@ -460,8 +460,8 @@ describe('DownloadModal Toggle Visibility', () => { ); }); - expect(container.textContent).toContain('This modpack uses external mods'); - expect(container.textContent).toContain('External Shader'); + expect(pageText()).toContain('This modpack uses external mods'); + expect(pageText()).toContain('External Shader'); }); it('does not show the external mod warning for non-modpack projects', async () => { @@ -503,7 +503,7 @@ describe('DownloadModal Toggle Visibility', () => { ); }); - expect(container.textContent).not.toContain('This modpack uses external mods'); - expect(container.textContent).not.toContain('External Library'); + expect(pageText()).not.toContain('This modpack uses external mods'); + expect(pageText()).not.toContain('External Library'); }); }); From 4c09893fd3e52f5cc23b54dfd5e3b1c2f3ba408f Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:03:26 -0400 Subject: [PATCH 13/13] Fix backend startup and render budget --- backend/src/main/java/net/modtale/ModtaleApplication.java | 6 ++++++ .../net/modtale/status/DetachedStatusServiceTest.java | 7 ++++--- frontend/src/modules/project/views/ProjectDetails.tsx | 8 +++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/net/modtale/ModtaleApplication.java b/backend/src/main/java/net/modtale/ModtaleApplication.java index 4313660d..fbdd90ce 100644 --- a/backend/src/main/java/net/modtale/ModtaleApplication.java +++ b/backend/src/main/java/net/modtale/ModtaleApplication.java @@ -4,9 +4,15 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @SpringBootApplication +@ComponentScan(excludeFilters = @ComponentScan.Filter( + type = FilterType.REGEX, + pattern = "net\\.modtale\\.status\\..*" +)) @ConfigurationPropertiesScan @EnableMethodSecurity public class ModtaleApplication { diff --git a/backend/src/test/java/net/modtale/status/DetachedStatusServiceTest.java b/backend/src/test/java/net/modtale/status/DetachedStatusServiceTest.java index 8bf1fb2f..cb0eb507 100644 --- a/backend/src/test/java/net/modtale/status/DetachedStatusServiceTest.java +++ b/backend/src/test/java/net/modtale/status/DetachedStatusServiceTest.java @@ -25,7 +25,7 @@ class DetachedStatusServiceTest { @Test void refreshBuildsStatusSnapshotWithoutDependingOnMainApplication() { - StatusHistoryEntry entry = entry(Instant.parse("2026-06-26T20:00:00Z"), SystemStatus.DEGRADED); + StatusHistoryEntry entry = entry(Instant.now().minusSeconds(60), SystemStatus.DEGRADED); DetachedStatusService service = serviceWith(entry); service.refreshSnapshots(); @@ -41,8 +41,9 @@ void refreshBuildsStatusSnapshotWithoutDependingOnMainApplication() { @Test void hydratePreservesExistingHistoryBeforeCurrentProbe() { - StatusHistoryEntry previous = entry(Instant.parse("2026-06-26T19:00:00Z"), SystemStatus.OPERATIONAL); - StatusHistoryEntry current = entry(Instant.parse("2026-06-26T20:00:00Z"), SystemStatus.OUTAGE); + Instant now = Instant.now(); + StatusHistoryEntry previous = entry(now.minusSeconds(60 * 60), SystemStatus.OPERATIONAL); + StatusHistoryEntry current = entry(now.minusSeconds(60), SystemStatus.OUTAGE); when(snapshotFileStore.readHistory()).thenReturn(List.of(previous)); when(mongoStatusStore.findHistoryAfter(any())).thenReturn(List.of()); when(mongoStatusStore.findLatestHistory()).thenReturn(Optional.empty()); diff --git a/frontend/src/modules/project/views/ProjectDetails.tsx b/frontend/src/modules/project/views/ProjectDetails.tsx index 8cec57d7..28d31cb8 100644 --- a/frontend/src/modules/project/views/ProjectDetails.tsx +++ b/frontend/src/modules/project/views/ProjectDetails.tsx @@ -127,6 +127,7 @@ export const ProjectDetails: React.FC = ({ const scrollPosRef = useRef(0); const downloadFxTimeoutRef = useRef(null); const changelogFetchKeyRef = useRef(''); + const gameVersionCatalogProjectRef = useRef(null); const [galleryIndex, setGalleryIndex] = useState(0); const galleryItems = useMemo( () => resolveGalleryImages(project?.galleryImages || [], project?.galleryImageCaptions || {}), @@ -176,10 +177,11 @@ export const ProjectDetails: React.FC = ({ }, [wikiLookupKey]); useEffect(() => { - if (!project?.versions?.length) return; - if (orderedGameVersions.length > 0 || preReleaseGameVersions.length > 0) return; + if (!isDownloadOpen || !project?.id || !project.versions?.length) return; + if (gameVersionCatalogProjectRef.current === project.id) return; let isCancelled = false; + gameVersionCatalogProjectRef.current = project.id; projectClient.getMetaGameVersionCatalog() .then((catalog) => { @@ -196,7 +198,7 @@ export const ProjectDetails: React.FC = ({ return () => { isCancelled = true; }; - }, [project?.versions?.length, orderedGameVersions.length, preReleaseGameVersions.length]); + }, [isDownloadOpen, project?.id, project?.versions?.length]); useEffect(() => { if (!isDownloadOpen) return;