convert to versionname.id format (#258)

Co-authored-by: MiniDigger <admin@minidigger.me>
This commit is contained in:
Jake Potrebic 2020-12-20 05:26:45 -08:00 committed by GitHub
parent a80c153870
commit ca6e601f45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 385 additions and 148 deletions

View File

@ -76,7 +76,7 @@
class="list-group-item list-group-item-action"
:href="ROUTES.parse('VERSIONS_SHOW', project.ownerName, project.slug, version.version)"
>
{{ version.version }}
{{ version.version.substring(0, version.version.lastIndexOf('.')) }}
<Tag v-for="(tag, index) in version.tags" :key="index" :color="tag.color" :data="tag.display_data" :name="tag.name"></Tag>
</a>
</div>

View File

@ -14,7 +14,7 @@
<div class="list-group">
<a
v-for="(version, index) in filteredVersions"
:href="ROUTES.parse('VERSIONS_SHOW', htmlDecode(ownerName), htmlDecode(projectSlug), version.name)"
:href="ROUTES.parse('VERSIONS_SHOW', htmlDecode(ownerName), htmlDecode(projectSlug), version.url_name)"
class="list-group-item list-group-item-action"
:class="[classForVisibility(version.visibility)]"
:key="index"

View File

@ -1,6 +1,6 @@
<template>
<Editor
:save-call="ROUTES.parse('VERSIONS_SAVE_DESCRIPTION', project.ownerName, project.slug, version.versionString)"
:save-call="ROUTES.parse('VERSIONS_SAVE_DESCRIPTION', project.ownerName, project.slug, version.versionStringUrl)"
:enabled="canEditPages"
subject="Version"
:raw="version.description || ''"

View File

@ -2,7 +2,7 @@
<div class="col-md-12 header-flags">
<div class="clearfix">
<h1 class="float-left">
<a :href="ROUTES.parse('VERSIONS_SHOW', project.ownerName, project.slug, version.versionString)" class="btn btn-primary">
<a :href="ROUTES.parse('VERSIONS_SHOW', project.ownerName, project.slug, version.versionStringUrl)" class="btn btn-primary">
<i class="fas fa-arrow-left"></i>
</a>
{{ project.name }}
@ -22,7 +22,7 @@
{{ version.reviewState.value !== 2 ? 'Remove from queue' : 'Add to queue' }}
</a>
<a :href="ROUTES.parse('PROJECTS_SHOW', project.ownerName, project.slug)" class="btn btn-info"> Project Page</a>
<a :href="ROUTES.parse('VERSIONS_DOWNLOAD_JAR', project.ownerName, project.slug, version.versionString)" class="btn btn-info"
<a :href="ROUTES.parse('VERSIONS_DOWNLOAD_JAR', project.ownerName, project.slug, version.versionStringUrl)" class="btn btn-info"
>Download File</a
>
</div>
@ -123,7 +123,7 @@ export default {
skip() {
axios
.post(
this.ROUTES.parse('REVIEWS_BACKLOG_TOGGLE', this.project.ownerName, this.project.slug, this.version.versionString),
this.ROUTES.parse('REVIEWS_BACKLOG_TOGGLE', this.project.ownerName, this.project.slug, this.version.versionStringUrl),
null,
window.ajaxSettings
)
@ -136,7 +136,7 @@ export default {
toggleSpin(icon).classList.toggle('fa-stop-circle');
axios
.post(
this.ROUTES.parse('REVIEWS_STOP_REVIEW', this.project.ownerName, this.project.slug, this.version.versionString),
this.ROUTES.parse('REVIEWS_STOP_REVIEW', this.project.ownerName, this.project.slug, this.version.versionStringUrl),
stringify({
content: this.reason.stop,
}),
@ -158,14 +158,14 @@ export default {
const promises = [];
promises.push(
axios.post(
this.ROUTES.parse('REVIEWS_APPROVE_REVIEW', this.project.ownerName, this.project.slug, this.version.versionString),
this.ROUTES.parse('REVIEWS_APPROVE_REVIEW', this.project.ownerName, this.project.slug, this.version.versionStringUrl),
null,
window.ajaxSettings
)
);
const urlKey = partial ? 'VERSIONS_APPROVE_PARTIAL' : 'VERSIONS_APPROVE';
promises.push(
axios.post(this.ROUTES.parse(urlKey, this.project.ownerName, this.project.slug, this.version.versionString), null, window.ajaxSettings)
axios.post(this.ROUTES.parse(urlKey, this.project.ownerName, this.project.slug, this.version.versionStringUrl), null, window.ajaxSettings)
);
Promise.all(promises).then(() => {
location.reload();
@ -175,7 +175,7 @@ export default {
toggleSpin(e.target.querySelector('[data-fa-i2svg]')).classList.toggle('fa-clipboard');
axios
.post(
this.ROUTES.parse('REVIEWS_TAKEOVER_REVIEW', this.project.ownerName, this.project.slug, this.version.versionString),
this.ROUTES.parse('REVIEWS_TAKEOVER_REVIEW', this.project.ownerName, this.project.slug, this.version.versionStringUrl),
stringify({ content: this.reason.takeover }),
{
headers: {
@ -193,7 +193,7 @@ export default {
event.target.disabled = true;
axios
.post(
this.ROUTES.parse('REVIEWS_CREATE_REVIEW', this.project.ownerName, this.project.slug, this.version.versionString),
this.ROUTES.parse('REVIEWS_CREATE_REVIEW', this.project.ownerName, this.project.slug, this.version.versionStringUrl),
null,
window.ajaxSettings
)
@ -210,7 +210,7 @@ export default {
e.target.disabled = true;
axios
.post(
this.ROUTES.parse('REVIEWS_REOPEN_REVIEW', this.project.ownerName, this.project.slug, this.version.versionString),
this.ROUTES.parse('REVIEWS_REOPEN_REVIEW', this.project.ownerName, this.project.slug, this.version.versionStringUrl),
null,
window.ajaxSettings
)

View File

@ -35,7 +35,7 @@ export default {
toggleSpin(e.target.querySelector('[data-fa-i2svg]')).classList.toggle('fa-clipboard');
axios
.post(
this.ROUTES.parse('REVIEWS_ADD_MESSAGE', this.project.ownerName, this.project.slug, this.version.versionString),
this.ROUTES.parse('REVIEWS_ADD_MESSAGE', this.project.ownerName, this.project.slug, this.version.versionStringUrl),
stringify({ content: this.message }),
{
headers: {

View File

@ -12,7 +12,9 @@ import java.util.regex.Pattern;
public class ProjectsConfig {
private String nameRegex = "^[a-zA-Z0-9-_]{3,}$";
private String versionNameRegex = "^[a-zA-Z0-9-_.]+$";
private Pattern namePattern = Pattern.compile(this.nameRegex);
private Pattern versionNamePattern = Pattern.compile(this.versionNameRegex);
private int maxNameLen = 25;
private int maxPages = 50;
private int maxChannels = 5;
@ -35,11 +37,24 @@ public class ProjectsConfig {
return namePattern.asMatchPredicate();
}
public Predicate<String> getVersionNameMatcher() {
return versionNamePattern.asMatchPredicate();
}
public void setNameRegex(String nameRegex) {
this.nameRegex = nameRegex;
this.namePattern = Pattern.compile(nameRegex);
}
public String getVersionNameRegex() {
return versionNameRegex;
}
public void setVersionNameRegex(String versionNameRegex) {
this.versionNameRegex = versionNameRegex;
this.versionNamePattern = Pattern.compile(versionNameRegex);
}
public int getMaxNameLen() {
return maxNameLen;
}

View File

@ -72,7 +72,6 @@ import org.springframework.web.util.WebUtils;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.net.URI;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
@ -384,7 +383,7 @@ public class VersionsController extends HangarController {
cacheManager.getCache(CacheConfig.NEW_VERSION_CACHE).evict(projData.getProject().getId() + "/" + versionName);
cacheManager.getCache(CacheConfig.PENDING_VERSION_CACHE).evict(projData.getProject().getId() + "/" + versionName);
return Routes.VERSIONS_SHOW.getRedirect(author, slug, versionName);
return Routes.VERSIONS_SHOW.getRedirect(author, slug, version.getVersionStringUrl());
}
@GetMapping("/{author}/{slug}/versions/{version:.*}")
@ -458,7 +457,7 @@ public class VersionsController extends HangarController {
if (api) {
removeAddWarnings(address, expiration, token);
headers.setContentType(MediaType.APPLICATION_JSON);
String downloadUrl = versionsTable.getExternalUrl() != null ? versionsTable.getExternalUrl() : Routes.VERSIONS_DOWNLOAD_JAR_BY_ID.getRouteUrl(project.getOwnerName(), project.getSlug(), versionsTable.getVersionString(), token);
String downloadUrl = versionsTable.getExternalUrl() != null ? versionsTable.getExternalUrl() : Routes.VERSIONS_DOWNLOAD_JAR_BY_ID.getRouteUrl(project.getOwnerName(), project.getSlug(), versionsTable.getVersionStringUrl(), token);
ObjectNode objectNode = mapper.createObjectNode()
.put("message", apiMsg)
.put("post", Routes.VERSIONS_CONFIRM_DOWNLOAD.getRouteUrl(author, slug, version, downloadType.ordinal() + "", token, null))
@ -582,7 +581,7 @@ public class VersionsController extends HangarController {
return Routes.VERSIONS_SHOW_DOWNLOAD_CONFIRM.getRedirect(
project.getOwnerName(),
project.getSlug(),
version.getVersionString(),
version.getVersionStringUrl(),
(version.getExternalUrl() != null ? DownloadType.EXTERNAL_DOWNLOAD.ordinal() : DownloadType.UPLOADED_FILE.ordinal()) + "",
false + "",
"dummy"
@ -662,7 +661,7 @@ public class VersionsController extends HangarController {
return Routes.VERSIONS_SHOW_DOWNLOAD_CONFIRM.getRedirect(
project.getOwnerName(),
project.getSlug(),
version.getVersionString(),
version.getVersionStringUrl(),
DownloadType.JAR_FILE.ordinal() + "",
api + "",
null

View File

@ -30,90 +30,132 @@ import java.util.Map;
@RequestMapping({"/api", "/api/v1"})
public interface VersionsApi {
@ApiOperation(value = "Creates a new version", nickname = "deployVersion", notes = "Creates a new version for a project. Requires the `create_version` permission in the project or owning organization.", response = Version.class, authorizations = {
@Authorization(value = "Session")}, tags = "Versions")
@ApiResponses(value = {
@ApiOperation(
value = "Creates a new version",
nickname = "deployVersion",
notes = "Creates a new version for a project. Requires the `create_version` permission in the project or owning organization.",
response = Version.class,
authorizations = {
@Authorization(value = "Session")
},
tags = "Versions"
)
@ApiResponses({
@ApiResponse(code = 201, message = "Ok", response = Version.class),
@ApiResponse(code = 401, message = "Api session missing, invalid or expired"),
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")})
@PostMapping(value = "/projects/{author}/{slug}/versions",
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<Version> deployVersion(@ApiParam(value = "", required = true) @RequestParam(value = "plugin-info", required = true) DeployVersionInfo pluginInfo
, @ApiParam(value = "file detail") @Valid @RequestPart("file") MultipartFile pluginFile
, @ApiParam(value = "The author of the project to create the version for", required = true) @PathVariable("author") String author
, @ApiParam(value = "The slug of the project to create the version for", required = true) @PathVariable("slug") String slug
);
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")
})
@PostMapping(value = "/projects/{author}/{slug}/versions", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<Version> deployVersion(@ApiParam(value = "", required = true) @RequestParam(value = "plugin-info", required = true) DeployVersionInfo pluginInfo,
@ApiParam(value = "file detail") @Valid @RequestPart("file") MultipartFile pluginFile,
@ApiParam(value = "The author of the project to create the version for", required = true) @PathVariable("author") String author,
@ApiParam(value = "The slug of the project to create the version for", required = true) @PathVariable("slug") String slug);
@ApiOperation(value = "Returns the versions of a project", nickname = "listVersions", notes = "Returns the versions of a project. Requires the `view_public_info` permission in the project or owning organization.", response = PaginatedVersionResult.class, authorizations = {
@Authorization(value = "Session")}, tags = "Versions")
@ApiResponses(value = {
@ApiOperation(
value = "Returns the versions of a project",
nickname = "listVersions",
notes = "Returns the versions of a project. Requires the `view_public_info` permission in the project or owning organization.",
response = PaginatedVersionResult.class,
authorizations = {
@Authorization(value = "Session")
},
tags = "Versions"
)
@ApiResponses({
@ApiResponse(code = 200, message = "Ok", response = PaginatedVersionResult.class),
@ApiResponse(code = 401, message = "Api session missing, invalid or expired"),
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")})
@GetMapping(value = "/projects/{author}/{slug}/versions",
produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<PaginatedVersionResult> listVersions(@ApiParam(value = "The author of the project to return versions for", required = true) @PathVariable("author") String author
, @ApiParam(value = "The slug of the project to return versions for", required = true) @PathVariable("slug") String slug
, @ApiParam(value = "A list of tags all the returned versions should have. Should be formated either as `tagname` or `tagname:tagdata`.") @Valid @RequestParam(value = "tags", required = false) List<String> tags
, @ApiParam(value = "The maximum amount of versions to return") @Valid @RequestParam(value = "limit", required = false) Long limit
, @ApiParam(value = "Where to start returning", defaultValue = "0") @Valid @RequestParam(value = "offset", required = false, defaultValue = "0") Long offset
);
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")
})
@GetMapping(value = "/projects/{author}/{slug}/versions", produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<PaginatedVersionResult> listVersions(@ApiParam(value = "The author of the project to return versions for", required = true) @PathVariable("author") String author,
@ApiParam(value = "The slug of the project to return versions for", required = true) @PathVariable("slug") String slug,
@ApiParam(value = "A list of tags all the returned versions should have. Should be formated either as `tagname` or `tagname:tagdata`.") @Valid @RequestParam(value = "tags", required = false) List<String> tags,
@ApiParam(value = "The maximum amount of versions to return") @Valid @RequestParam(value = "limit", required = false) Long limit,
@ApiParam(value = "Where to start returning", defaultValue = "0") @Valid @RequestParam(value = "offset", required = false, defaultValue = "0") Long offset);
@ApiOperation(value = "Returns a specific version of a project", nickname = "showVersion", notes = "Returns a specific version of a project. Requires the `view_public_info` permission in the project or owning organization.", response = Version.class, authorizations = {
@Authorization(value = "Session")}, tags = "Versions")
@ApiResponses(value = {
@ApiOperation(
value = "Returns a specific version of a project",
nickname = "showVersion",
notes = "Returns a specific version of a project. Requires the `view_public_info` permission in the project or owning organization.",
response = Version.class,
authorizations = {
@Authorization(value = "Session")
},
tags = "Versions"
)
@ApiResponses({
@ApiResponse(code = 200, message = "Ok", response = Version.class),
@ApiResponse(code = 401, message = "Api session missing, invalid or expired"),
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")})
@GetMapping(value = "/projects/{author}/{slug}/versions/{name:.*}",
produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<Version> showVersion(@ApiParam(value = "The author of the project to return the version for", required = true) @PathVariable("author") String author
, @ApiParam(value = "The slug of the project to return", required = true) @PathVariable("slug") String slug
, @ApiParam(value = "The name of the version to return", required = true) @PathVariable("name") String name);
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")
})
@GetMapping(value = "/projects/{author}/{slug}/versions/{name:.*}", produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<Version> showVersion(@ApiParam(value = "The author of the project to return the version for", required = true) @PathVariable("author") String author,
@ApiParam(value = "The slug of the project to return", required = true) @PathVariable("slug") String slug,
@ApiParam(value = "The name of the version to return", required = true) @PathVariable("name") String name);
@ApiOperation(value = "Returns the stats for a version", nickname = "showVersionStats", notes = "Returns the stats(downloads) for a version per day for a certain date range. Requires the `is_subject_member` permission.", response = VersionStatsDay.class, responseContainer = "Map", authorizations = {
@Authorization(value = "Session")}, tags = "Versions")
@ApiResponses(value = {
@ApiOperation(
value = "Returns the stats for a version",
nickname = "showVersionStats",
notes = "Returns the stats(downloads) for a version per day for a certain date range. Requires the `is_subject_member` permission.",
response = VersionStatsDay.class,
responseContainer = "Map",
authorizations = {
@Authorization(value = "Session")
},
tags = "Versions"
)
@ApiResponses({
@ApiResponse(code = 200, message = "Ok", response = VersionStatsDay.class, responseContainer = "Map"),
@ApiResponse(code = 401, message = "Api session missing, invalid or expired"),
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")})
@GetMapping(value = "/projects/{author}/{slug}/versions/{version}/stats",
produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<Map<String, VersionStatsDay>> showVersionStats(
@ApiParam(value = "The author of the version to return the stats for", required = true) @PathVariable("author") String author,
@ApiParam(value = "The slug of the project to return stats for", required = true) @PathVariable("slug") String slug,
@ApiParam(value = "The version to return the stats for", required = true) @PathVariable("version") String version,
@ApiParam(value = "The first date to include in the result", required = true) @RequestParam(value = "fromDate") @NotNull @Valid String fromDate,
@ApiParam(value = "The last date to include in the result", required = true) @RequestParam(value = "toDate") @NotNull @Valid String toDate
);
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")
})
@GetMapping(value = "/projects/{author}/{slug}/versions/{version}/stats", produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<Map<String, VersionStatsDay>> showVersionStats(@ApiParam(value = "The author of the version to return the stats for", required = true) @PathVariable("author") String author,
@ApiParam(value = "The slug of the project to return stats for", required = true) @PathVariable("slug") String slug,
@ApiParam(value = "The version to return the stats for", required = true) @PathVariable("version") String version,
@ApiParam(value = "The first date to include in the result", required = true) @RequestParam(value = "fromDate") @NotNull @Valid String fromDate,
@ApiParam(value = "The last date to include in the result", required = true) @RequestParam(value = "toDate") @NotNull @Valid String toDate);
@ApiOperation(value = "Downloads the recommended version", nickname = "downloadRecommended", notes = "Downloads the file of the recommended version of this project", response = Object.class, authorizations = {
@Authorization(value = "Session")}, tags = "Versions")
@ApiResponses(value = {
@ApiOperation(
value = "Downloads the recommended version",
nickname = "downloadRecommended",
notes = "Downloads the file of the recommended version of this project",
response = Object.class,
authorizations = {
@Authorization(value = "Session")
},
tags = "Versions"
)
@ApiResponses({
@ApiResponse(code = 200, message = "Ok", response = Object.class),
@ApiResponse(code = 401, message = "Api session missing, invalid or expired"),
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")})
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")
})
@GetMapping(value = "/projects/{author}/{slug}/versions/recommended/download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
Object downloadRecommended(
@ApiParam(value = "The author of the version to return the stats for", required = true) @PathVariable("author") String author,
@ApiParam(value = "The slug of the project to return stats for", required = true) @PathVariable("slug") String slug,
@ApiParam(value = "The download token") @RequestParam(required = false) String token
);
Object downloadRecommended(@ApiParam(value = "The author of the version to return the stats for", required = true) @PathVariable("author") String author,
@ApiParam(value = "The slug of the project to return stats for", required = true) @PathVariable("slug") String slug,
@ApiParam(value = "The download token") @RequestParam(required = false) String token);
@ApiOperation(value = "Downloads the version", nickname = "download", notes = "Downloads the file of the given version of this project", response = Object.class, authorizations = {
@Authorization(value = "Session")}, tags = "Versions")
@ApiResponses(value = {
@ApiOperation(
value = "Downloads the version",
nickname = "download",
notes = "Downloads the file of the given version of this project",
response = Object.class,
authorizations = {
@Authorization(value = "Session")
},
tags = "Versions"
)
@ApiResponses({
@ApiResponse(code = 200, message = "Ok", response = Object.class),
@ApiResponse(code = 401, message = "Api session missing, invalid or expired"),
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")})
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")
})
@GetMapping(value = "/projects/{author}/{slug}/versions/{name}/download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
Object download(
@ApiParam(value = "The author of the version to return the stats for", required = true) @PathVariable("author") String author,
@ApiParam(value = "The slug of the project to return stats for", required = true) @PathVariable("slug") String slug,
@ApiParam(value = "The name of the version", required = true) @PathVariable("name") String name,
@ApiParam(value = "The download token") @RequestParam(required = false) String token
);
Object download(@ApiParam(value = "The author of the version to return the stats for", required = true) @PathVariable("author") String author,
@ApiParam(value = "The slug of the project to return stats for", required = true) @PathVariable("slug") String slug,
@ApiParam(value = "The name of the version", required = true) @PathVariable("name") String name,
@ApiParam(value = "The download token") @RequestParam(required = false) String token);
@ApiOperation(
value = "Returns a list of platforms and their information",

View File

@ -1,25 +1,6 @@
package io.papermc.hangar.controller.api;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import io.papermc.hangar.config.hangar.HangarConfig;
import io.papermc.hangar.controller.exceptions.HangarApiException;
import io.papermc.hangar.model.ApiAuthInfo;
@ -34,6 +15,24 @@ import io.papermc.hangar.model.generated.Version;
import io.papermc.hangar.model.generated.VersionStatsDay;
import io.papermc.hangar.service.api.VersionApiService;
import io.papermc.hangar.util.ApiUtil;
import io.papermc.hangar.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Controller
public class VersionsApiController implements VersionsApi {
@ -77,7 +76,7 @@ public class VersionsApiController implements VersionsApi {
@Override
@PreAuthorize("@authenticationService.authApiRequest(T(io.papermc.hangar.model.Permission).ViewPublicInfo, T(io.papermc.hangar.controller.util.ApiScope).forProject(#author, #slug))")
public ResponseEntity<Version> showVersion(String author, String slug, String name) {
Version version = versionApiService.getVersion(author, slug, name, apiAuthInfo.getGlobalPerms().has(Permission.SeeHidden), ApiUtil.userIdOrNull(apiAuthInfo.getUser()));
Version version = versionApiService.getVersion(author, slug, StringUtils.getVersionId(name, new HangarApiException(HttpStatus.BAD_REQUEST, "badly formatted version string")), apiAuthInfo.getGlobalPerms().has(Permission.SeeHidden), ApiUtil.userIdOrNull(apiAuthInfo.getUser()));
if (version == null) {
throw new HangarApiException(HttpStatus.NOT_FOUND);
} else {
@ -94,7 +93,7 @@ public class VersionsApiController implements VersionsApi {
if (from.isAfter(to)) {
throw new HangarApiException(HttpStatus.BAD_REQUEST, "From date is after to date");
}
Map<String, VersionStatsDay> versionStats = versionApiService.getVersionStats(author, slug, version, from, to);
Map<String, VersionStatsDay> versionStats = versionApiService.getVersionStats(author, slug, StringUtils.getVersionId(version, new HangarApiException(HttpStatus.BAD_REQUEST, "badly formatted version string")), from, to);
if (versionStats.isEmpty()) {
throw new HangarApiException(HttpStatus.NOT_FOUND); // TODO Not found might not be right here?
}

View File

@ -47,6 +47,7 @@ public interface ProjectVersionDao {
" sq.project_slug," +
" sq.project_name," +
" sq.version_string," +
" sq.version_string_url," +
" sq.version_created_at," +
" sq.channel_name," +
" sq.channel_color," +
@ -59,6 +60,7 @@ public interface ProjectVersionDao {
" p.name AS project_name," +
" p.slug AS project_slug," +
" v.version_string," +
" v.version_string || '.' || v.id AS version_string_url," +
" v.created_at AS version_created_at," +
" c.name AS channel_name," +
" c.color AS channel_color," +

View File

@ -1,7 +1,10 @@
package io.papermc.hangar.db.dao;
import io.papermc.hangar.db.model.UsersTable;
import io.papermc.hangar.model.viewhelpers.Author;
import io.papermc.hangar.model.viewhelpers.FlagActivity;
import io.papermc.hangar.model.viewhelpers.ReviewActivity;
import io.papermc.hangar.model.viewhelpers.Staff;
import org.jdbi.v3.sqlobject.config.RegisterBeanMapper;
import org.jdbi.v3.sqlobject.customizer.BindBean;
import org.jdbi.v3.sqlobject.customizer.BindList;
@ -16,10 +19,6 @@ import org.springframework.stereotype.Repository;
import java.util.List;
import io.papermc.hangar.db.model.UsersTable;
import io.papermc.hangar.model.viewhelpers.Author;
import io.papermc.hangar.model.viewhelpers.Staff;
@Repository
@RegisterBeanMapper(UsersTable.class)
public interface UserDao {
@ -109,7 +108,7 @@ public interface UserDao {
void removeStargazing(long projectId, long userId);
@SqlQuery("SELECT pvr.ended_at, pv.version_string, p.owner_name \"owner\", p.slug" +
@SqlQuery("SELECT pvr.ended_at, pv.version_string, pv.version_string || '.' || pv.id AS version_string_url, p.owner_name \"owner\", p.slug" +
" FROM users u" +
" JOIN project_version_reviews pvr ON u.id = pvr.user_id" +
" JOIN project_versions pv ON pvr.version_id = pv.id" +

View File

@ -25,7 +25,8 @@ public interface VersionsApiDao {
@UseStringTemplateEngine
@RegisterColumnMapper(VersionDependenciesMapper.class)
@SqlQuery("SELECT pv.created_at," +
@SqlQuery("SELECT pv.id," +
"pv.created_at," +
"pv.version_string," +
"pv.dependencies," +
"pv.visibility," +
@ -48,14 +49,15 @@ public interface VersionsApiDao {
"<if(userId)>OR (<userId> IN (SELECT pm.user_id FROM project_members_all pm WHERE pm.id = p.id) AND pv.visibility != 4) <endif>) AND <endif> " +
"p.slug = :slug AND " +
"p.owner_name = :author AND " +
"pv.version_string = :versionString " +
"pv.id = :versionId " +
"GROUP BY p.id, pv.id, u.id, pc.id " +
"ORDER BY pv.created_at DESC LIMIT 1")
Version getVersion(String author, String slug, String versionString, @Define boolean canSeeHidden, @Define Long userId);
Version getVersion(String author, String slug, long versionId, @Define boolean canSeeHidden, @Define Long userId);
@RegisterColumnMapper(VersionDependenciesMapper.class)
@UseStringTemplateEngine
@SqlQuery("SELECT pv.created_at," +
@SqlQuery("SELECT pv.id," +
"pv.created_at," +
"pv.version_string," +
"pv.dependencies," +
"pv.visibility," +
@ -118,7 +120,7 @@ public interface VersionsApiDao {
" LEFT JOIN project_versions_downloads pvd ON dates.day = pvd.day" +
" WHERE p.owner_name = :author" +
" AND pv.slug = :slug" +
" AND pv.version_string = :versionString" +
" AND pv.id = :versionId" +
" AND (pvd IS NULL OR (pvd.project_id = p.id AND pvd.version_id = pv.id));")
Map<String, VersionStatsDay> versionStats(String author, String slug, String versionString, LocalDate fromDate, LocalDate toDate);
Map<String, VersionStatsDay> versionStats(String author, String slug, long versionId, LocalDate fromDate, LocalDate toDate);
}

View File

@ -54,6 +54,7 @@ public class VersionMapper implements RowMapper<Version> {
return new Version()
.createdAt(rs.getObject("created_at", OffsetDateTime.class))
.name(rs.getString("version_string"))
.urlName(rs.getString("version_string") + "." + rs.getLong("id"))
.dependencies(versionDependenciesColumnMapper.get().map(rs, rs.findColumn("dependencies"), ctx))
.visibility(Visibility.fromId(rs.getLong("visibility")))
.description(rs.getString("description"))

View File

@ -229,6 +229,11 @@ public class ProjectVersionsTable {
return this.externalUrl != null && this.fileName == null;
}
@Unmappable
public String getVersionStringUrl() {
return this.versionString + "." + this.id;
}
@Override
public String toString() {
return "ProjectVersionsTable{" +

View File

@ -30,6 +30,9 @@ public class Version {
@JsonProperty("name")
private String name = null;
@JsonProperty("url_name")
private String urlName = null;
@JsonProperty("dependencies")
@Valid
private Map<Platform, List<Dependency>> dependencies = new EnumMap<>(Platform.class);
@ -102,6 +105,26 @@ public class Version {
this.name = name;
}
public Version urlName(String urlName) {
this.urlName = urlName;
return this;
}
/**
* Get url name
* @return url name
*/
@ApiModelProperty(required = true, value = "")
@NotNull
public String getUrlName() {
return urlName;
}
public void setUrlName(String urlName) {
this.urlName = urlName;
}
public Version dependencies(Map<Platform, List<Dependency>> dependencies) {
this.dependencies = dependencies;
return this;

View File

@ -6,6 +6,7 @@ public class ReviewActivity extends Activity {
private OffsetDateTime endedAt;
public String versionString;
private String versionStringUrl;
public OffsetDateTime getEndedAt() {
return endedAt;
@ -22,4 +23,12 @@ public class ReviewActivity extends Activity {
public void setVersionString(String versionString) {
this.versionString = versionString;
}
public String getVersionStringUrl() {
return versionStringUrl;
}
public void setVersionStringUrl(String versionStringUrl) {
this.versionStringUrl = versionStringUrl;
}
}

View File

@ -1,7 +1,6 @@
package io.papermc.hangar.model.viewhelpers;
import io.papermc.hangar.model.Color;
import org.jdbi.v3.core.enums.EnumByOrdinal;
import org.jetbrains.annotations.Nullable;
@ -13,6 +12,7 @@ public class ReviewQueueEntry {
private String projectName;
private String projectSlug;
private String versionString;
private String versionStringUrl;
private OffsetDateTime versionCreatedAt;
private String channelName;
private Color channelColor;
@ -26,11 +26,12 @@ public class ReviewQueueEntry {
public ReviewQueueEntry() {
}
public ReviewQueueEntry(String projectAuthor, String projectName, String projectSlug, String versionString, OffsetDateTime versionCreatedAt, String channelName, Color channelColor, String versionAuthor, @Nullable Long reviewerId, @Nullable String reviewerName, @Nullable OffsetDateTime reviewStarted, @Nullable OffsetDateTime reviewEnded) {
public ReviewQueueEntry(String projectAuthor, String projectName, String projectSlug, String versionString, String versionStringUrl, OffsetDateTime versionCreatedAt, String channelName, Color channelColor, String versionAuthor, @Nullable Long reviewerId, @Nullable String reviewerName, @Nullable OffsetDateTime reviewStarted, @Nullable OffsetDateTime reviewEnded) {
this.projectAuthor = projectAuthor;
this.projectName = projectName;
this.projectSlug = projectSlug;
this.versionString = versionString;
this.versionStringUrl = versionStringUrl;
this.versionCreatedAt = versionCreatedAt;
this.channelName = channelName;
this.channelColor = channelColor;
@ -77,6 +78,10 @@ public class ReviewQueueEntry {
return versionString;
}
public String getVersionStringUrl() {
return versionStringUrl;
}
public OffsetDateTime getVersionCreatedAt() {
return versionCreatedAt;
}
@ -130,6 +135,10 @@ public class ReviewQueueEntry {
this.versionString = versionString;
}
public void setVersionStringUrl(String versionStringUrl) {
this.versionStringUrl = versionStringUrl;
}
public void setVersionCreatedAt(OffsetDateTime versionCreatedAt) {
this.versionCreatedAt = versionCreatedAt;
}
@ -166,10 +175,11 @@ public class ReviewQueueEntry {
@Override
public String toString() {
return "ReviewQueueEntry{" +
"author='" + projectAuthor + '\'' +
"projectAuthor='" + projectAuthor + '\'' +
", projectName='" + projectName + '\'' +
", slug='" + projectSlug + '\'' +
", projectSlug='" + projectSlug + '\'' +
", versionString='" + versionString + '\'' +
", versionStringUrl='" + versionStringUrl + '\'' +
", versionCreatedAt=" + versionCreatedAt +
", channelName='" + channelName + '\'' +
", channelColor=" + channelColor +

View File

@ -25,6 +25,7 @@ import io.papermc.hangar.service.pluginupload.PendingVersion;
import io.papermc.hangar.service.project.ChannelService;
import io.papermc.hangar.service.project.ProjectService;
import io.papermc.hangar.util.RequestUtil;
import io.papermc.hangar.util.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
@ -71,7 +72,7 @@ public class VersionService extends HangarService {
public Supplier<ProjectVersionsTable> projectVersionsTable() {
Map<String, String> pathParams = RequestUtil.getPathParams(request);
if (pathParams.keySet().containsAll(Set.of("author", "slug", "version"))) {
ProjectVersionsTable pvt = this.getVersion(pathParams.get("author"), pathParams.get("slug"), pathParams.get("version"));
ProjectVersionsTable pvt = this.getVersion(pathParams.get("author"), pathParams.get("slug"), StringUtils.getVersionId(pathParams.get("version"), new ResponseStatusException(HttpStatus.BAD_REQUEST, "Badly formatted version string")));
if (pvt == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
@ -104,18 +105,18 @@ public class VersionService extends HangarService {
return versionDao.get().getProjectVersion(project.getId(), "", project.getRecommendedVersionId());
}
public ProjectVersionsTable getVersion(long projectId, String versionString) {
public ProjectVersionsTable getVersion(long projectId, long versionId) {
Permission perms = permissionService.getProjectPermissions(currentUser.get().map(UsersTable::getId).orElse(-10L), projectId);
ProjectVersionsTable pvt = versionDao.get().getProjectVersion(projectId, "", versionString);
ProjectVersionsTable pvt = versionDao.get().getProjectVersion(projectId, "", versionId);
if (!perms.has(Permission.SeeHidden) && !perms.has(Permission.IsProjectMember) && pvt.getVisibility() != Visibility.PUBLIC) {
return null;
}
return pvt;
}
public ProjectVersionsTable getVersion(String author, String slug, String versionString) {
public ProjectVersionsTable getVersion(String author, String slug, long versionId) {
ProjectsTable projectsTable = projectDao.get().getBySlug(author, slug);
return getVersion(projectsTable.getId(), versionString);
return getVersion(projectsTable.getId(), versionId);
}
public void update(ProjectVersionsTable projectVersionsTable) {

View File

@ -19,8 +19,8 @@ public class VersionApiService {
this.apiVersionsDao = apiVersionsDao;
}
public Version getVersion(String author, String slug, String versionString, boolean canSeeHidden, Long userId) {
return apiVersionsDao.get().getVersion(author, slug, versionString, canSeeHidden, userId);
public Version getVersion(String author, String slug, long versionId, boolean canSeeHidden, Long userId) {
return apiVersionsDao.get().getVersion(author, slug, versionId, canSeeHidden, userId);
}
public List<Version> getVersionList(String author, String slug, List<String> tags, boolean canSeeHidden, Long limit, long offset, Long userId) {
@ -32,8 +32,8 @@ public class VersionApiService {
return count == null ? 0 : count;
}
public Map<String, VersionStatsDay> getVersionStats(String author, String slug, String versionString, LocalDate fromDate, LocalDate toDate) {
return apiVersionsDao.get().versionStats(author, slug, versionString, fromDate, toDate);
public Map<String, VersionStatsDay> getVersionStats(String author, String slug, long versionId, LocalDate fromDate, LocalDate toDate) {
return apiVersionsDao.get().versionStats(author, slug, versionId, fromDate, toDate);
}
}

View File

@ -147,8 +147,8 @@ public class ProjectFactory {
throw new HangarException("error.version.duplicate");
}
if (!hangarConfig.projects.getNameMatcher().test(pendingVersion.getVersionString())) {
throw new HangarException("error.project.invalidName");
if (!hangarConfig.projects.getVersionNameMatcher().test(pendingVersion.getVersionString())) {
throw new HangarException("error.project.version.invalidName");
}
ProjectVersionsTable version = projectVersionDao.get().insert(new ProjectVersionsTable(

View File

@ -62,6 +62,7 @@ public enum Routes {
PROJECTS_SHOW_NOTES("projects.showNotes", Paths.PROJECTS_SHOW_NOTES, of("author", "slug"), of()),
PROJECTS_SET_VISIBLE("projects.setVisible", Paths.PROJECTS_SET_VISIBLE, of("author", "slug", "visibility"), of()),
PROJECTS_REMOVE_MEMBER("projects.removeMember", Paths.PROJECTS_REMOVE_MEMBER, of("author", "slug"), of()),
VERSIONS_RESTORE("versions.restore", Paths.VERSIONS_RESTORE, of("author", "slug", "version"), of()),
VERSIONS_DOWNLOAD_RECOMMENDED_JAR("versions.downloadRecommendedJar", Paths.VERSIONS_DOWNLOAD_RECOMMENDED_JAR, of("author", "slug"), of("token")),
VERSIONS_SAVE_NEW_VERSION("versions.saveNewVersion", Paths.VERSIONS_SAVE_NEW_VERSION, of("author", "slug", "version"), of()),
@ -87,12 +88,14 @@ public enum Routes {
VERSIONS_DELETE("versions.delete", Paths.VERSIONS_DELETE, of("author", "slug", "version"), of()),
VERSIONS_SHOW_CREATOR_WITH_META("versions.showCreatorWithMeta", Paths.VERSIONS_SHOW_CREATOR_WITH_META, of("author", "slug", "version"), of()),
VERSIONS_CONFIRM_DOWNLOAD("versions.confirmDownload", Paths.VERSIONS_CONFIRM_DOWNLOAD, of("author", "slug", "version"), of("downloadType", "token", "dummy")),
PAGES_SHOW_PREVIEW("pages.showPreview", Paths.PAGES_SHOW_PREVIEW, of(), of()),
PAGES_BB_CONVERT("pages.bbConvert", Paths.PAGES_BB_CONVERT, of(), of()),
PAGES_SAVE("pages.save", Paths.PAGES_SAVE, of("author", "slug", "page"), of()),
PAGES_SHOW_EDITOR("pages.showEditor", Paths.PAGES_SHOW_EDITOR, of("author", "slug", "page"), of()),
PAGES_SHOW("pages.show", Paths.PAGES_SHOW, of("author", "slug", "page"), of()),
PAGES_DELETE("pages.delete", Paths.PAGES_DELETE, of("author", "slug", "page"), of()),
USERS_SHOW_AUTHORS("users.showAuthors", Paths.USERS_SHOW_AUTHORS, of(), of("sort", "page")),
USERS_SAVE_TAGLINE("users.saveTagline", Paths.USERS_SAVE_TAGLINE, of("user"), of()),
USERS_SIGN_UP("users.signUp", Paths.USERS_SIGN_UP, of(), of()),
@ -107,12 +110,14 @@ public enum Routes {
USERS_LOGOUT("users.logout", Paths.USERS_LOGOUT, of(), of()),
USERS_USER_SITEMAP("users.userSitemap", Paths.USERS_USER_SITEMAP, of("user"), of()),
USERS_EDIT_API_KEYS("users.editApiKeys", Paths.USERS_EDIT_API_KEYS, of("user"), of()),
ORG_UPDATE_MEMBERS("org.updateMembers", Paths.ORG_UPDATE_MEMBERS, of("organization"), of()),
ORG_UPDATE_AVATAR("org.updateAvatar", Paths.ORG_UPDATE_AVATAR, of("organization"), of()),
ORG_SET_INVITE_STATUS("org.setInviteStatus", Paths.ORG_SET_INVITE_STATUS, of("id", "status"), of()),
ORG_SHOW_CREATOR("org.showCreator", Paths.ORG_SHOW_CREATOR, of(), of()),
ORG_CREATE("org.create", Paths.ORG_CREATE, of(), of()),
ORG_REMOVE_MEMBER("org.removeMember", Paths.ORG_REMOVE_MEMBER, of("organization"), of()),
REVIEWS_ADD_MESSAGE("reviews.addMessage", Paths.REVIEWS_ADD_MESSAGE, of("author", "slug", "version"), of()),
REVIEWS_BACKLOG_TOGGLE("reviews.backlogToggle", Paths.REVIEWS_BACKLOG_TOGGLE, of("author", "slug", "version"), of()),
REVIEWS_SHOW_REVIEWS("reviews.showReviews", Paths.REVIEWS_SHOW_REVIEWS, of("author", "slug", "version"), of()),
@ -122,6 +127,7 @@ public enum Routes {
REVIEWS_CREATE_REVIEW("reviews.createReview", Paths.REVIEWS_CREATE_REVIEW, of("author", "slug", "version"), of()),
REVIEWS_TAKEOVER_REVIEW("reviews.takeoverReview", Paths.REVIEWS_TAKEOVER_REVIEW, of("author", "slug", "version"), of()),
REVIEWS_REOPEN_REVIEW("reviews.reopenReview", Paths.REVIEWS_REOPEN_REVIEW, of("author", "slug", "version"), of()),
CHANNELS_DELETE("channels.delete", Paths.CHANNELS_DELETE, of("author", "slug", "channel"), of()),
CHANNELS_SAVE("channels.save", Paths.CHANNELS_SAVE, of("author", "slug", "channel"), of()),
CHANNELS_SHOW_LIST("channels.showList", Paths.CHANNELS_SHOW_LIST, of("author", "slug"), of()),

View File

@ -2,6 +2,7 @@ package io.papermc.hangar.util;
import org.springframework.lang.Nullable;
import javax.validation.constraints.NotNull;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
@ -51,6 +52,16 @@ public class StringUtils {
return input;
}
public static <T extends Throwable> long getVersionId(@NotNull String versionString, T error) throws T {
int index = versionString.lastIndexOf('.');
try {
return Long.parseLong(versionString.substring(index + 1));
} catch (NumberFormatException ex) {
throw error;
}
}
private static final Pattern LAST_WHOLE_VERSION = Pattern.compile("((?<=,\\s)|^)[0-9.]{2,}(?=-\\d+$)");
private static final Pattern PREV_HAS_HYPHEN = Pattern.compile("(?<=\\d-)\\d+$");
private static final Pattern PREV_HAS_COMMA_OR_FIRST = Pattern.compile("((?<=,\\s)|^)[0-9.]+$");

View File

@ -0,0 +1 @@
DROP INDEX versions_project_id_version_string_idx;

View File

@ -0,0 +1,111 @@
DROP MATERIALIZED VIEW home_projects;
create materialized view home_projects as
WITH tags AS (
SELECT sq.version_id,
sq.project_id,
sq.version_string,
sq.tag_name,
sq.tag_version,
sq.tag_color
FROM (SELECT pv.id AS version_id,
pv.project_id,
pv.version_string,
pvt.name AS tag_name,
pvt.data AS tag_version,
pvt.platform_version,
pvt.color AS tag_color,
row_number()
OVER (PARTITION BY pv.project_id, pvt.platform_version ORDER BY pv.created_at DESC) AS row_num
FROM project_versions pv
JOIN (SELECT pvti.version_id,
pvti.name,
pvti.data,
CASE
WHEN pvti.name::text = 'Paper'::text THEN array_to_string(pvti.data, ', ')
WHEN pvti.name::text = 'Waterfall'::text THEN array_to_string(pvti.data, ', ')
WHEN pvti.name::text = 'Velocity'::text THEN array_to_string(pvti.data, ', ')
ELSE NULL::text
END AS platform_version,
pvti.color
FROM project_version_tags pvti
WHERE (pvti.name::text = ANY
(ARRAY ['Paper'::character varying, 'Waterfall'::character varying, 'Velocity'::character varying]::text[]))
AND pvti.data IS NOT NULL) pvt ON pv.id = pvt.version_id
WHERE pv.visibility = 0
AND (pvt.name::text = ANY
(ARRAY ['Paper'::character varying, 'Waterfall'::character varying, 'Velocity'::character varying]::text[]))
AND pvt.platform_version IS NOT NULL) sq
WHERE sq.row_num = 1
ORDER BY sq.platform_version DESC
)
SELECT p.id,
p.owner_name,
array_agg(DISTINCT pm.user_id) AS project_members,
p.slug,
p.visibility,
COALESCE(pva.views, 0::bigint) AS views,
COALESCE(pda.downloads, 0::bigint) AS downloads,
COALESCE(pvr.recent_views, 0::bigint) AS recent_views,
COALESCE(pdr.recent_downloads, 0::bigint) AS recent_downloads,
COALESCE(ps.stars, 0::bigint) AS stars,
COALESCE(pw.watchers, 0::bigint) AS watchers,
p.category,
p.description,
p.name,
p.created_at,
max(lv.created_at) AS last_updated,
to_jsonb(ARRAY(SELECT jsonb_build_object('version_string', tags.version_string || '.' || tags.version_id, 'tag_name', tags.tag_name,
'tag_version', tags.tag_version, 'tag_color',
tags.tag_color) AS jsonb_build_object
FROM tags
WHERE tags.project_id = p.id
LIMIT 5)) AS promoted_versions,
((setweight((to_tsvector('english'::regconfig, p.name::text) ||
to_tsvector('english'::regconfig, regexp_replace(p.name::text, '([a-z])([A-Z]+)'::text,
'\1_\2'::text, 'g'::text))), 'A'::"char") ||
setweight(to_tsvector('english'::regconfig, p.description::text), 'B'::"char")) ||
setweight(to_tsvector('english'::regconfig, array_to_string(p.keywords, ' '::text)), 'C'::"char")) || setweight(
to_tsvector('english'::regconfig, p.owner_name::text) || to_tsvector('english'::regconfig,
regexp_replace(
p.owner_name::text,
'([a-z])([A-Z]+)'::text,
'\1_\2'::text,
'g'::text)),
'D'::"char") AS search_words
FROM projects p
LEFT JOIN project_versions lv ON p.id = lv.project_id
JOIN project_members_all pm ON p.id = pm.id
LEFT JOIN (SELECT p_1.id,
COUNT(ps_1.user_id) AS stars
FROM projects p_1
LEFT JOIN project_stars ps_1 ON p_1.id = ps_1.project_id
GROUP BY p_1.id) ps ON p.id = ps.id
LEFT JOIN (SELECT p_1.id,
count(pw_1.user_id) AS watchers
FROM projects p_1
LEFT JOIN project_watchers pw_1 ON p_1.id = pw_1.project_id
GROUP BY p_1.id) pw ON p.id = pw.id
LEFT JOIN (SELECT pv.project_id,
sum(pv.views) AS views
FROM project_views pv
GROUP BY pv.project_id) pva ON p.id = pva.project_id
LEFT JOIN (SELECT pv.project_id,
sum(pv.downloads) AS downloads
FROM project_versions_downloads pv
GROUP BY pv.project_id) pda ON p.id = pda.project_id
LEFT JOIN (SELECT pv.project_id,
sum(pv.views) AS recent_views
FROM project_views pv
WHERE pv.day >= (CURRENT_DATE - '30 days'::interval)
AND pv.day <= CURRENT_DATE
GROUP BY pv.project_id) pvr ON p.id = pvr.project_id
LEFT JOIN (SELECT pv.project_id,
sum(pv.downloads) AS recent_downloads
FROM project_versions_downloads pv
WHERE pv.day >= (CURRENT_DATE - '30 days'::interval)
AND pv.day <= CURRENT_DATE
GROUP BY pv.project_id) pdr ON p.id = pdr.project_id
GROUP BY p.id, ps.stars, pw.watchers, pva.views, pda.downloads, pvr.recent_views, pdr.recent_downloads;
alter materialized view home_projects owner to hangar;

View File

@ -240,6 +240,7 @@ error.project.invalidCategory = Invalid category
error.project.nameExists = A project with that name already exists
error.project.slugExists = A project with that slug already exists
error.project.invalidName = Project name is invalid. Must be between 3 and 25 characters, and be alphanumeric + hyphen and underscore.
error.project.version.invalidName = Version name is invalid. Must be alphanumeric + hyphen, underscore, and period.
error.project.maxKeywords = Too many keywords! Max is {0}
org.create = New Organization

View File

@ -7,7 +7,7 @@
<div class="row">
<div class="col-md-12">
<h1><@spring.message "version.log.visibility.title" />
<a href="${Routes.VERSIONS_SHOW.getRouteUrl(project.ownerName, project.project.slug, version.versionString)}">
<a href="${Routes.VERSIONS_SHOW.getRouteUrl(project.ownerName, project.project.slug, version.versionStringUrl)}">
${project.ownerName}/${project.project.slug}/versions/${version.versionString}
</a>
</h1>

View File

@ -48,7 +48,7 @@
</div>
<div class="col-12 col-sm-6">
<form action="${Routes.VERSIONS_CONFIRM_DOWNLOAD.getRouteUrl(project.ownerName, project.slug, target.versionString, downloadType.ordinal() + "", "", "dummy")}" method="post" id="form-download">
<form action="${Routes.VERSIONS_CONFIRM_DOWNLOAD.getRouteUrl(project.ownerName, project.slug, target.versionStringUrl, downloadType.ordinal() + "", "", "dummy")}" method="post" id="form-download">
<@csrf.formField />
<button type="submit" form="form-download" class="btn btn-danger float-right-sm">

View File

@ -72,7 +72,7 @@
<div>
<#if !v.recommended && sp.perms(Permission.EditVersion) && v.v.visibility != Visibility.SOFTDELETE>
<@form.form method="POST" action=Routes.VERSIONS_SET_RECOMMENDED.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionString) class="d-inline">
<@form.form method="POST" action=Routes.VERSIONS_SET_RECOMMENDED.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionStringUrl) class="d-inline">
<@csrf.formField />
<button type="submit" class="btn btn-info">
<i class="fas fa-gem"></i> Set recommended
@ -82,9 +82,9 @@
<#if headerData.globalPerm(Permission.Reviewer)>
<#if v.v.reviewState.checked>
<a href="${Routes.REVIEWS_SHOW_REVIEWS.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionString)}" class="btn btn-info"><@spring.message "review.log" /></a>
<a href="${Routes.REVIEWS_SHOW_REVIEWS.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionStringUrl)}" class="btn btn-info"><@spring.message "review.log" /></a>
<#else>
<a href="${Routes.REVIEWS_SHOW_REVIEWS.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionString)}" class="btn btn-success">
<a href="${Routes.REVIEWS_SHOW_REVIEWS.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionStringUrl)}" class="btn btn-success">
<i class="fas fa-play"></i> <@spring.message "review.start" />
</a>
</#if>
@ -111,7 +111,7 @@
</#if>
<div class="btn-group btn-download">
<a href="${Routes.VERSIONS_DOWNLOAD.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionString, "", "")}"
<a href="${Routes.VERSIONS_DOWNLOAD.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionStringUrl, "", "")}"
title="<@spring.message "project.download.recommend" />" data-tooltip-toggle
data-placement="bottom" class="btn btn-primary">
<i class="fas fa-download"></i>
@ -125,8 +125,8 @@
<span class="sr-only">Toggle Dropdown</span>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="${Routes.VERSIONS_DOWNLOAD.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionString, "", "")}"><@spring.message "general.download" /></a>
<a href="#" class="copy-url dropdown-item" data-clipboard-text="${config.baseUrl}${Routes.VERSIONS_DOWNLOAD.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionString, "", "")}">Copy URL</a>
<a class="dropdown-item" href="${Routes.VERSIONS_DOWNLOAD.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionStringUrl, "", "")}"><@spring.message "general.download" /></a>
<a href="#" class="copy-url dropdown-item" data-clipboard-text="${config.baseUrl}${Routes.VERSIONS_DOWNLOAD.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionStringUrl, "", "")}">Copy URL</a>
</div>
</div>
@ -230,7 +230,7 @@
<span aria-hidden="true">&times;</span>
</button>
</div>
<@form.form method="POST" action=Routes.VERSIONS_SOFT_DELETE.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionString)>
<@form.form method="POST" action=Routes.VERSIONS_SOFT_DELETE.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionStringUrl)>
<div class="modal-body">
<@spring.message "version.delete.info" />
<textarea name="comment" class="textarea-delete-comment form-control" rows="3"></textarea>
@ -261,7 +261,7 @@
<span aria-hidden="true">&times;</span>
</button>
</div>
<@form.form method="POST" action=Routes.VERSIONS_RESTORE.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionString)>
<@form.form method="POST" action=Routes.VERSIONS_RESTORE.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionStringUrl)>
<div class="modal-body">
<textarea name="comment" class="textarea-delete-comment form-control" rows="3"></textarea>
</div>
@ -289,7 +289,7 @@
<span aria-hidden="true">&times;</span>
</button>
</div>
<@form.form method="POST" action=Routes.VERSIONS_DELETE.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionString)>
<@form.form method="POST" action=Routes.VERSIONS_DELETE.getRouteUrl(v.p.project.ownerName, v.p.project.slug, v.v.versionStringUrl)>
<div class="modal-body">
<textarea name="comment" class="textarea-delete-comment form-control" rows="3"></textarea>
</div>

View File

@ -32,7 +32,7 @@
<td>Review approved</td>
<td>${utils.prettifyDateTime(activity.endedAt!OffsetDateTime.MIN)}</td>
<td>for:
<a href="${Routes.REVIEWS_SHOW_REVIEWS.getRouteUrl(activity.getProject().getOwner(), activity.getProject().getSlug(), activity.versionString)}" title="Go to reviews...">
<a href="${Routes.REVIEWS_SHOW_REVIEWS.getRouteUrl(activity.getProject().getOwner(), activity.getProject().getSlug(), activity.versionStringUrl)}" title="Go to reviews...">
${activity.getProject().getOwner()} / ${activity.getProject().getSlug()} / ${activity.versionString}
</a>
</td>

View File

@ -110,7 +110,7 @@
<div class="card-body list-group list-group-health">
<#list missingFileProjects as missingFileProject>
<div class="list-group-item">
<a href="${Routes.VERSIONS_SHOW.getRouteUrl(missingFileProject.owner, missingFileProject.name, missingFileProject.getVersion().getVersionString())}">
<a href="${Routes.VERSIONS_SHOW.getRouteUrl(missingFileProject.owner, missingFileProject.name, missingFileProject.getVersion().getVersionStringUrl())}">
<strong>${missingFileProject.displayText}</strong>
</a>
</div>

View File

@ -99,7 +99,7 @@
</td>
<#elseif action.actionContext.class.simpleName == "VersionContext">
<td>
<a <#if action.project.owner?? && action.project.slug?? && action.project.versionString??>href="${Routes.VERSIONS_SHOW.getRouteUrl(action.project.owner!"Unknown", action.project.slug!"Unknown", action.version.versionString!"Unknown")}"</#if>>${action.project.owner!"Unknown"}/${action.project.slug!"Unknown"}/${action.version.versionString!"Unknown"}</a>
<a <#if action.project.owner?? && action.project.slug?? && action.project.versionString??>href="${Routes.VERSIONS_SHOW.getRouteUrl(action.project.owner!"Unknown", action.project.slug!"Unknown", action.version.versionStringUrl!"Unknown")}"</#if>>${action.project.owner!"Unknown"}/${action.project.slug!"Unknown"}/${action.version.versionString!"Unknown"}</a>
<small class="filter-project">(<a <#if action.project.slug??>href="${Routes.SHOW_LOG.getRouteUrl(page?string, userFilter, action.project.slug, versionFilter, pageFilter, actionFilter, subjectFilter)}"</#if>>${action.project.slug!"Unknown"}</a>)</small>
<small class="filter-version">(<a <#if action.version.versionString??>href="${Routes.SHOW_LOG.getRouteUrl(page?string, userFilter, projectFilter, action.version.versionString, pageFilter, actionFilter, subjectFilter)}"</#if>>${action.version.versionString!"Unknown"}</a>)</small>
</td>

View File

@ -47,7 +47,7 @@
<#list underReview as entry>
<tr <#if entry.unfinished && headerData.isCurrentUser(entry.reviewerId)>class="warning"</#if>>
<td>
<a href="${Routes.VERSIONS_SHOW.getRouteUrl(entry.author, entry.slug, entry.versionString)}">
<a href="${Routes.VERSIONS_SHOW.getRouteUrl(entry.author, entry.slug, entry.versionStringUrl)}">
${entry.namespace}
</a>
<br>
@ -135,7 +135,7 @@
<@userAvatar.userAvatar userName=entry.author avatarUrl=utils.avatarUrl(entry.author) clazz="user-avatar-xs"></@userAvatar.userAvatar>
</td>
<td>
<a href="${Routes.VERSIONS_SHOW.getRouteUrl(entry.author, entry.slug, entry.versionString)}">
<a href="${Routes.VERSIONS_SHOW.getRouteUrl(entry.author, entry.slug, entry.versionStringUrl)}">
${entry.namespace}
</a>
</td>

View File

@ -24,7 +24,7 @@
<div class="col-md-12 header-flags">
<div class="clearfix">
<h1 class="float-left">
<a class="btn btn-primary" href="${Routes.VERSIONS_SHOW.getRouteUrl(project.project.ownerName, project.project.slug, version.v.versionString)}">
<a class="btn btn-primary" href="${Routes.VERSIONS_SHOW.getRouteUrl(project.project.ownerName, project.project.slug, version.v.versionStringUrl)}">
<i class="fas fa-arrow-left"></i>
</a>
${project.project.name}
@ -42,7 +42,7 @@
<span class="btn-group-sm">
<a href="#" class="btn btn-info btn-skip-review"><#if version.v.reviewState != ReviewState.BACKLOG> Remove from queue <#else> Add to queue </#if></a>
<a href="${Routes.PROJECTS_SHOW.getRouteUrl(project.project.ownerName, project.project.slug)}" class="btn btn-info">Project Page</a>
<a href="${Routes.VERSIONS_DOWNLOAD_JAR.getRouteUrl(project.project.ownerName, project.project.slug, version.v.versionString, "")}" class="btn btn-info">Download File</a>
<a href="${Routes.VERSIONS_DOWNLOAD_JAR.getRouteUrl(project.project.ownerName, project.project.slug, version.v.versionStringUrl, "")}" class="btn btn-info">Download File</a>
</span>
<span class="btn-group-sm">
<#if mostRecentUnfinishedReview??>