Add simple version upload API

Closes #941
This commit is contained in:
Nassim Jahnke 2023-01-23 12:08:01 +01:00
parent a7c5a2f3cb
commit 0c71864347
No known key found for this signature in database
GPG Key ID: 6BE3B555EBC5982B
10 changed files with 187 additions and 72 deletions

View File

@ -13,19 +13,23 @@ import io.papermc.hangar.model.api.requests.RequestPagination;
import io.papermc.hangar.model.common.NamedPermission;
import io.papermc.hangar.model.common.PermissionType;
import io.papermc.hangar.model.common.Platform;
import io.papermc.hangar.model.internal.versions.VersionUpload;
import io.papermc.hangar.security.annotations.Anyone;
import io.papermc.hangar.security.annotations.permission.PermissionRequired;
import io.papermc.hangar.security.annotations.ratelimit.RateLimit;
import io.papermc.hangar.security.annotations.unlocked.Unlocked;
import io.papermc.hangar.security.annotations.visibility.VisibilityRequired;
import io.papermc.hangar.service.api.VersionsApiService;
import io.papermc.hangar.service.internal.versions.DownloadService;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
@Anyone
@Controller
@ -42,6 +46,14 @@ public class VersionsController implements IVersionsController {
this.versionsApiService = versionsApiService;
}
@Unlocked
@Override
@RateLimit(overdraft = 5, refillTokens = 1, refillSeconds = 5)
@PermissionRequired(type = PermissionType.PROJECT, perms = NamedPermission.CREATE_VERSION, args = "{#author, #slug}")
public void uploadVersion(final String author, final String slug, final List<MultipartFile> files, final VersionUpload versionUpload) {
this.versionsApiService.uploadVersion(author, slug, files, versionUpload);
}
@Override
@VisibilityRequired(type = VisibilityRequired.Type.PROJECT, args = "{#author, #slug}")
public Version getVersion(final String author, final String slug, final String name) {

View File

@ -5,6 +5,7 @@ import io.papermc.hangar.model.api.project.version.Version;
import io.papermc.hangar.model.api.project.version.VersionStats;
import io.papermc.hangar.model.api.requests.RequestPagination;
import io.papermc.hangar.model.common.Platform;
import io.papermc.hangar.model.internal.versions.VersionUpload;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@ -12,34 +13,42 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import org.jetbrains.annotations.NotNull;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "Versions")
@RequestMapping(path = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE)
public interface IVersionsController {
// TODO implement version creation via API
/*@Operation(
@Operation(
summary ="Creates a new version",
operationId = "deployVersion",
notes = "Creates a new version for a project. Requires the `create_version` permission in the project or owning organization.",
operationId = "uploadVersion",
description = "Creates a new version for a project. Requires the `create_version` permission in the project or owning organization.",
security = @SecurityRequirement(name = "Session"),
tags = "Versions"
)
@ApiResponses({
@ApiResponse(responseCode = 201, description = "Ok"),
@ApiResponse(responseCode = 401, description = "Api session missing, invalid or expired"),
@ApiResponse(responseCode = 403, description = "Not enough permissions to use this endpoint")
@ApiResponse(responseCode = "201", description = "Ok"),
@ApiResponse(responseCode = "401", description = "Api session missing, invalid or expired"),
@ApiResponse(responseCode = "403", description = "Not enough permissions to use this endpoint")
})
@PostMapping(path = "/projects/{author}/{slug}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<Version> deployVersion()*/
@PostMapping(path = "/projects/{author}/{slug}/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
void uploadVersion(@Parameter(description = "The author of the project to return versions for") @PathVariable String author,
@Parameter(description = "The slug of the project to return versions for") @PathVariable String slug,
@Parameter(description = "The version files in order of selected platforms, if any") @RequestPart(required = false) @Size(max = 3, message = "version.new.error.invalidNumOfPlatforms") List<@Valid MultipartFile> files,
@Parameter(description = "Version data") @RequestPart @Valid VersionUpload versionUpload);
@Operation(
summary = "Returns a specific version of a project",

View File

@ -83,7 +83,7 @@ public class VersionController extends HangarComponent {
@RequestPart @Size(min = 1, max = 3, message = "version.new.error.invalidNumOfPlatforms") final List<@Valid MultipartFileOrUrl> data,
@RequestPart @NotBlank final String channel) {
// Use separate lists to hack around multipart form data limitations
return ResponseEntity.ok(this.versionFactory.createPendingVersion(projectId, data, files, channel));
return ResponseEntity.ok(this.versionFactory.createPendingVersion(projectId, data, files, channel, true));
}
@Unlocked

View File

@ -33,6 +33,9 @@ public interface ProjectChannelsDAO {
@SqlQuery("SELECT * FROM project_channels WHERE project_id = :projectId AND name = :name AND color = :color")
ProjectChannelTable getProjectChannel(long projectId, String name, @EnumByOrdinal Color color);
@SqlQuery("SELECT * FROM project_channels WHERE project_id = :projectId AND name = :name")
ProjectChannelTable getProjectChannel(long projectId, String name);
@SqlQuery("SELECT * FROM project_channels WHERE id = :channelId")
ProjectChannelTable getProjectChannel(long channelId);

View File

@ -35,13 +35,12 @@ public class PendingVersion {
// @el(root: String)
@NotBlank(message = "version.new.error.channel.noName")
private final @Validate(SpEL = "@validate.regex(#root, @hangarConfig.channels.nameRegex)", message = "channel.modal.error.invalidName") String channelName;
@NotNull(message = "version.new.error.channel.noColor")
private final Color channelColor;
private final Set<ChannelFlag> channelFlags;
private final boolean forumSync;
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public PendingVersion(final String versionString, final Map<Platform, Set<PluginDependency>> pluginDependencies, final EnumMap<Platform, SortedSet<String>> platformDependencies, final String description, final List<PendingVersionFile> files, final String channelName, final Color channelColor, final Set<ChannelFlag> channelFlags, final boolean forumSync) {
public PendingVersion(final String versionString, final Map<Platform, Set<PluginDependency>> pluginDependencies, final EnumMap<Platform, SortedSet<String>> platformDependencies, final String description, final List<PendingVersionFile> files, final String channelName, final @Nullable Color channelColor, final Set<ChannelFlag> channelFlags, final boolean forumSync) {
this.versionString = versionString;
this.pluginDependencies = pluginDependencies;
this.platformDependencies = platformDependencies;
@ -90,7 +89,7 @@ public class PendingVersion {
return this.channelName;
}
public Color getChannelColor() {
public @Nullable Color getChannelColor() {
return this.channelColor;
}

View File

@ -1,33 +0,0 @@
package io.papermc.hangar.model.internal.versions;
import com.fasterxml.jackson.annotation.JsonCreator;
import io.papermc.hangar.model.common.Platform;
import java.util.List;
public class PlatformDependency {
private final Platform platform;
private final List<String> versions;
@JsonCreator
public PlatformDependency(final Platform platform, final List<String> versions) {
this.platform = platform;
this.versions = versions;
}
public Platform getPlatform() {
return this.platform;
}
public List<String> getVersions() {
return this.versions;
}
@Override
public String toString() {
return "PlatformDependency{" +
"platform=" + this.platform +
", versions=" + this.versions +
'}';
}
}

View File

@ -0,0 +1,89 @@
package io.papermc.hangar.model.internal.versions;
import com.fasterxml.jackson.annotation.JsonCreator;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.*;
import io.papermc.hangar.controller.validations.Validate;
import io.papermc.hangar.model.api.project.version.PluginDependency;
import io.papermc.hangar.model.common.Platform;
public class VersionUpload {
// @el(root: String)
@NotBlank(message = "version.new.error.invalidVersionString")
private final @Validate(SpEL = "@validate.regex(#root, @hangarConfig.projects.versionNameRegex)", message = "version.new.error.invalidVersionString") String versionString;
private final Map<Platform, Set<@Valid PluginDependency>> pluginDependencies;
@Size(min = 1, max = 3, message = "version.new.error.invalidNumOfPlatforms")
private final Map<Platform, @Size(min = 1, message = "version.edit.error.noPlatformVersions") SortedSet<@NotBlank(message = "version.new.error.invalidPlatformVersion") String>> platformDependencies;
// @el(root: String)
@NotBlank(message = "version.new.error.noDescription")
private final @Validate(SpEL = "@validate.max(#root, @hangarConfig.pages.maxLen)", message = "page.new.error.maxLength") String description;
@Size(min = 1, max = 3, message = "version.new.error.invalidNumOfPlatforms")
private final List<@Valid MultipartFileOrUrl> files;
// @el(root: String)
@NotBlank(message = "version.new.error.channel.noName")
private final @Validate(SpEL = "@validate.regex(#root, @hangarConfig.channels.nameRegex)", message = "channel.modal.error.invalidName") String channelName;
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public VersionUpload(final String versionString, final Map<Platform, Set<PluginDependency>> pluginDependencies, final EnumMap<Platform, SortedSet<String>> platformDependencies, final String description, final List<MultipartFileOrUrl> files, final String channelName) {
this.versionString = versionString;
this.pluginDependencies = pluginDependencies;
this.platformDependencies = platformDependencies;
this.description = description;
this.files = files;
this.channelName = channelName;
}
public String getVersionString() {
return this.versionString;
}
public Map<Platform, Set<PluginDependency>> getPluginDependencies() {
return this.pluginDependencies;
}
public Map<Platform, SortedSet<String>> getPlatformDependencies() {
return this.platformDependencies;
}
public String getDescription() {
return this.description;
}
public List<MultipartFileOrUrl> getFiles() {
return this.files;
}
public String getChannelName() {
return this.channelName;
}
public PendingVersion toPendingVersion(final List<PendingVersionFile> files) {
return new PendingVersion(this.versionString,
this.pluginDependencies,
(EnumMap<Platform, SortedSet<String>>) this.platformDependencies,
this.description,
files,
this.channelName,
null,
null,
false
);
}
@Override
public String toString() {
return "VersionUpload{" +
"versionString='" + this.versionString + '\'' +
", pluginDependencies=" + this.pluginDependencies +
", platformDependencies=" + this.platformDependencies +
", description='" + this.description + '\'' +
", files=" + this.files +
", channelName='" + this.channelName + '\'' +
'}';
}
}

View File

@ -5,30 +5,52 @@ import io.papermc.hangar.db.dao.v1.VersionsApiDAO;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.model.api.PaginatedResult;
import io.papermc.hangar.model.api.Pagination;
import io.papermc.hangar.model.api.project.Project;
import io.papermc.hangar.model.api.project.version.Version;
import io.papermc.hangar.model.api.project.version.VersionStats;
import io.papermc.hangar.model.api.requests.RequestPagination;
import io.papermc.hangar.model.common.Permission;
import io.papermc.hangar.model.common.Platform;
import io.papermc.hangar.model.internal.versions.PendingVersion;
import io.papermc.hangar.model.internal.versions.VersionUpload;
import io.papermc.hangar.service.internal.versions.VersionDependencyService;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import io.papermc.hangar.service.internal.versions.VersionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@Service
public class VersionsApiService extends HangarComponent {
private final VersionsApiDAO versionsApiDAO;
private final VersionDependencyService versionDependencyService;
private final VersionFactory versionFactory;
private final ProjectsApiService projectsApiService;
@Autowired
public VersionsApiService(final VersionsApiDAO versionsApiDAO, final VersionDependencyService versionDependencyService) {
public VersionsApiService(final VersionsApiDAO versionsApiDAO, final VersionDependencyService versionDependencyService, final VersionFactory versionFactory, final ProjectsApiService projectsApiService) {
this.versionsApiDAO = versionsApiDAO;
this.versionDependencyService = versionDependencyService;
this.versionFactory = versionFactory;
this.projectsApiService = projectsApiService;
}
@Transactional
public void uploadVersion(final String author, final String slug, final List<MultipartFile> files, final VersionUpload versionUpload) {
final Project project = this.projectsApiService.getProject(author, slug);
if (project == null) {
throw new HangarApiException(HttpStatus.NOT_FOUND);
}
// TODO Do the upload in one step
final PendingVersion preparedPendingVersion = this.versionFactory.createPendingVersion(project.getId(), versionUpload.getFiles(), files, versionUpload.getChannelName(), false);
final PendingVersion pendingVersion = versionUpload.toPendingVersion(preparedPendingVersion.getFiles());
this.versionFactory.publishPendingVersion(project.getId(), pendingVersion);
}
@Transactional

View File

@ -145,6 +145,10 @@ public class ChannelService extends HangarComponent {
return this.projectChannelsDAO.getProjectChannel(projectId, name, color);
}
public ProjectChannelTable getProjectChannel(final long projectId, final String name) {
return this.projectChannelsDAO.getProjectChannel(projectId, name);
}
public ProjectChannelTable getProjectChannel(final long channelId) {
return this.projectChannelsDAO.getProjectChannel(channelId);
}

View File

@ -110,7 +110,7 @@ public class VersionFactory extends HangarComponent {
}
@Transactional
public PendingVersion createPendingVersion(final long projectId, final List<MultipartFileOrUrl> data, final List<MultipartFile> files, final String channel) {
public PendingVersion createPendingVersion(final long projectId, final List<MultipartFileOrUrl> data, final List<MultipartFile> files, final String channel, final boolean prefillDependencies) {
final ProjectTable projectTable = this.projectService.getProjectTable(projectId);
if (projectTable == null) {
throw new IllegalArgumentException();
@ -135,9 +135,12 @@ public class VersionFactory extends HangarComponent {
if (fileOrUrl.isUrl()) {
// Handle external url
this.createPendingUrl(channel, projectTable, pluginDependencies, platformDependencies, pendingFiles, fileOrUrl);
pendingFiles.add(new PendingVersionFile(fileOrUrl.platforms(), null, fileOrUrl.externalUrl()));
if (prefillDependencies) {
this.createPendingUrl(channel, projectTable, pluginDependencies, platformDependencies, fileOrUrl);
}
} else {
versionString = this.createPendingFile(files.remove(0), channel, projectTable, pluginDependencies, platformDependencies, pendingFiles, versionString, userTempDir, fileOrUrl);
versionString = this.createPendingFile(files.remove(0), channel, projectTable, pluginDependencies, platformDependencies, pendingFiles, versionString, userTempDir, fileOrUrl, prefillDependencies);
}
}
@ -148,7 +151,9 @@ public class VersionFactory extends HangarComponent {
return new PendingVersion(versionString, pluginDependencies, platformDependencies, pendingFiles, projectTable.isForumSync());
}
private String createPendingFile(final MultipartFile file, final String channel, final ProjectTable projectTable, final Map<Platform, Set<PluginDependency>> pluginDependencies, final Map<Platform, SortedSet<String>> platformDependencies, final List<PendingVersionFile> pendingFiles, String versionString, final String userTempDir, final MultipartFileOrUrl fileOrUrl) {
private String createPendingFile(final MultipartFile file, final String channel, final ProjectTable projectTable, final Map<Platform, Set<PluginDependency>> pluginDependencies,
final Map<Platform, SortedSet<String>> platformDependencies, final List<PendingVersionFile> pendingFiles, String versionString,
final String userTempDir, final MultipartFileOrUrl fileOrUrl, final boolean prefillDependencies) {
// check extension
final String pluginFileName = file.getOriginalFilename();
if (pluginFileName == null || (!pluginFileName.endsWith(".zip") && !pluginFileName.endsWith(".jar"))) {
@ -186,6 +191,7 @@ public class VersionFactory extends HangarComponent {
pendingFiles.add(new PendingVersionFile(fileOrUrl.platforms(), fileInfo, null));
// setup dependencies
if (prefillDependencies) {
for (final Platform platform : fileOrUrl.platforms()) {
final LastDependencies lastDependencies = this.getLastVersionDependencies(projectTable.getOwnerName(), projectTable.getSlug(), channel, platform);
if (lastDependencies != null) {
@ -205,11 +211,11 @@ public class VersionFactory extends HangarComponent {
platformDependencies.put(platform, loadedPlatformDependencies);
}
}
}
return versionString;
}
private void createPendingUrl(final String channel, final ProjectTable projectTable, final Map<Platform, Set<PluginDependency>> pluginDependencies, final Map<Platform, SortedSet<String>> platformDependencies, final List<PendingVersionFile> pendingFiles, final MultipartFileOrUrl fileOrUrl) {
pendingFiles.add(new PendingVersionFile(fileOrUrl.platforms(), null, fileOrUrl.externalUrl()));
private void createPendingUrl(final String channel, final ProjectTable projectTable, final Map<Platform, Set<PluginDependency>> pluginDependencies, final Map<Platform, SortedSet<String>> platformDependencies, final MultipartFileOrUrl fileOrUrl) {
for (final Platform platform : fileOrUrl.platforms()) {
final LastDependencies lastDependencies = this.getLastVersionDependencies(projectTable.getOwnerName(), projectTable.getSlug(), channel, platform);
if (lastDependencies != null) {
@ -244,8 +250,12 @@ public class VersionFactory extends HangarComponent {
final String versionDir = this.projectFiles.getVersionDir(projectTable.getOwnerName(), projectTable.getSlug(), pendingVersion.getVersionString());
try {
// find channel
ProjectChannelTable projectChannelTable = this.channelService.getProjectChannel(projectId, pendingVersion.getChannelName(), pendingVersion.getChannelColor());
ProjectChannelTable projectChannelTable = this.channelService.getProjectChannel(projectId, pendingVersion.getChannelName());
if (projectChannelTable == null) {
if (pendingVersion.getChannelColor() == null) {
throw new HangarApiException("version.new.error.channel.noColor");
}
projectChannelTable = this.channelService.createProjectChannel(pendingVersion.getChannelName(), pendingVersion.getChannelColor(), projectId, pendingVersion.getChannelFlags().stream().filter(ChannelFlag::isEditable).collect(Collectors.toSet()));
}