From 824b5e93805c15164019568b91b9ca1535440732 Mon Sep 17 00:00:00 2001 From: Walker Crouse Date: Mon, 27 Feb 2017 17:37:19 -0500 Subject: [PATCH] Rework how downloads are handled Signed-off-by: Walker Crouse --- app/controllers/project/Versions.scala | 358 +++++++++--------- app/db/impl/schema.scala | 3 +- app/db/impl/table/ModelKeys.scala | 1 + app/models/project/DownloadWarning.scala | 8 + app/views/projects/versions/list.scala.html | 10 +- .../versions/unsafeDownload.scala.html | 10 +- app/views/projects/versions/view.scala.html | 11 +- app/views/projects/view.scala.html | 11 +- conf/evolutions/default/70.sql | 7 + conf/messages | 8 +- conf/routes | 22 +- 11 files changed, 233 insertions(+), 216 deletions(-) create mode 100644 conf/evolutions/default/70.sql diff --git a/app/controllers/project/Versions.scala b/app/controllers/project/Versions.scala index b4f190df7..5197ba13b 100755 --- a/app/controllers/project/Versions.scala +++ b/app/controllers/project/Versions.scala @@ -1,7 +1,6 @@ package controllers.project import java.io.InputStream -import java.net.{InetAddress, URI} import java.nio.file.Files._ import java.nio.file.{Files, StandardCopyOption} import java.sql.Timestamp @@ -25,14 +24,11 @@ import ore.{OreConfig, OreEnv, StatTracker} import play.api.Logger import play.api.i18n.MessagesApi import play.api.mvc.Result -import play.filters.csrf.CSRFCheck +import play.filters.csrf.CSRF import security.spauth.SingleSignOnConsumer import util.StringUtils._ -import views.html.helper.CSRF import views.html.projects.{versions => views} -import scala.util.Try - /** * Controller for handling Version related actions. */ @@ -40,7 +36,6 @@ class Versions @Inject()(stats: StatTracker, forms: OreForms, factory: ProjectFactory, forums: OreDiscourseApi, - csrfCheck: CSRFCheck, implicit override val sso: SingleSignOnConsumer, implicit override val messagesApi: MessagesApi, implicit override val env: OreEnv, @@ -353,6 +348,69 @@ class Versions @Inject()(stats: StatTracker, } } + /** + * Sends the specified Project Version to the client. + * + * @param author Project owner + * @param slug Project slug + * @param versionString Version string + * @return Sent file + */ + def download(author: String, slug: String, versionString: String, token: Option[String]) = { + ProjectAction(author, slug) { implicit request => + implicit val project = request.project + withVersion(versionString) { version => + sendVersion(project, version, token) + } + } + } + + private def sendVersion(project: Project, + version: Version, + token: Option[String]) + (implicit req: ProjectRequest[_]): Result = { + if (!checkConfirmation(project, version, token)) + Redirect(self.showDownloadConfirm(project.ownerName, project.slug, version.name, Some(UploadedFile.id))) + else + _sendVersion(project, version) + } + + private def checkConfirmation(project: Project, + version: Version, + token: Option[String]) + (implicit req: ProjectRequest[_]): Boolean = { + if (version.isReviewed) + return true + // check for confirmation + req.cookies.get(DownloadWarning.COOKIE).map(_.value).orElse(token) match { + case None => + // unconfirmed + false + case Some(tkn) => + this.warnings.find { warn => + (warn.token === tkn) && + (warn.versionId === version.id.get) && + (warn.address === InetString(StatTracker.remoteAddress)) && + warn.isConfirmed + } map { warn => + if (warn.hasExpired) { + warn.remove() + false + } else + true + } getOrElse { + false + } + } + } + + private def _sendVersion(project: Project, version: Version)(implicit req: ProjectRequest[_]): Result = { + this.stats.versionDownloaded(version) { implicit request => + Ok.sendFile(this.fileManager.getProjectDir(project.ownerName, project.name) + .resolve(version.fileName).toFile) + } + } + /** * Displays a confirmation view for downloading unreviewed versions. The * client is issued a unique token that will be checked once downloading to @@ -362,27 +420,26 @@ class Versions @Inject()(stats: StatTracker, * @param author Project author * @param slug Project slug * @param target Target version - * @param origin Origin URL * @return Confirmation view */ def showDownloadConfirm(author: String, slug: String, target: String, - origin: Option[String], - downloadType: Option[Int]) = csrfCheck { + downloadType: Option[Int]) = { ProjectAction(author, slug) { implicit request => val dlType = downloadType.flatMap(i => DownloadTypes.values.find(_.id == i)).getOrElse(DownloadTypes.UploadedFile) implicit val project = request.project withVersion(target) { version => - if (version.isReviewed || (origin.isDefined && !isValidRedirect(origin.get))) + if (version.isReviewed) Redirect(ShowProject(author, slug)) else { val userAgent = request.headers.get("User-Agent") - var cliClient: Boolean = false + var curl: Boolean = false + var wget: Boolean = false if (userAgent.isDefined) { val ua = userAgent.get.toLowerCase - if (ua.startsWith("wget/") || ua.startsWith("curl/")) - cliClient = true + curl = ua.startsWith("curl/") + wget = ua.startsWith("wget/") } // generate a unique "warning" object to ensure the user has landed @@ -399,121 +456,77 @@ class Versions @Inject()(stats: StatTracker, versionId = version.id.get, address = InetString(StatTracker.remoteAddress))) - if (!cliClient) { - Ok(views.unsafeDownload(project, version, origin, dlType)).withCookies(warning.cookie) + if (wget) { + Ok(this.messagesApi("version.download.confirm.wget")) + .withHeaders("Content-Disposition" -> "inline; filename=\"README.txt\"") + } else if (curl) { + Ok(this.messagesApi("version.download.confirm.body.plain", + self.confirmDownload(author, slug, target, Some(dlType.id), token).absoluteURL(), + CSRF.getToken.get.value) + "\n") + .withHeaders("Content-Disposition" -> "inline; filename=\"README.txt\"") } else { - MultiStatus(this.messagesApi( - "version.download.confirm.body.plain", - self.downloadUnsafely(author, slug, target, origin, downloadType, Some(token)).absoluteURL())) + Ok(views.unsafeDownload(project, version, dlType, token)).withCookies(warning.cookie) } } } } } - /** - * Verifies that the client has landed on a confirmation page for the - * target version and sends the file. - * - * @param author Project author - * @param slug Project slug - * @param target Target version - * @param origin Origin URL - * @return Unreviewed file - */ - def downloadUnsafely(author: String, - slug: String, - target: String, - origin: Option[String], - downloadType: Option[Int], - token: Option[String]) = { + def confirmDownload(author: String, slug: String, target: String, downloadType: Option[Int], token: String) = { ProjectAction(author, slug) { implicit request => - val dlType = downloadType.flatMap(i => DownloadTypes.values.find(_.id == i)).getOrElse(UploadedFile) implicit val project = request.project - withVersion(target)(sendUnsafely(project, _, origin, dlType, token)) - } - } - - private def sendUnsafely(project: Project, - version: Version, - origin: Option[String], - downloadType: DownloadType, - token: Option[String])(implicit request: ProjectRequest[_]): Result = { - val author = project.ownerName - val slug = project.slug - val target = version.name - if (version.isReviewed || (origin.isDefined && !isValidRedirect(origin.get))) - Redirect(ShowProject(author, slug)) - else if (token.isEmpty && request.cookies.get(DownloadWarning.COOKIE).isEmpty) - Redirect(CSRF(self.showDownloadConfirm(author, slug, target, origin, Some(downloadType.id)))) - else { - val tokenValue = token.orElse(request.cookies.get(DownloadWarning.COOKIE).map(_.value)).get - // find unexpired warning of token - val warning = this.warnings.find(_.token === tokenValue).flatMap { warning => - if (warning.hasExpired) { - warning.remove() - None - } else - Some(warning) - } - - // verify the user has landed on a confirmation for this version before - warning match { - case None => - Redirect(CSRF(self.showDownloadConfirm(author, slug, target, origin, Some(downloadType.id)))) - case Some(warn) => - // make sure the client is downloading from the same address as - // they confirmed from - val address = InetString(StatTracker.remoteAddress) - val addrMatch = InetAddress.getByName(warn.address.address) - .equals(InetAddress.getByName(address.address)) - if (warn.versionId != version.id.get || !addrMatch || warn.download.isDefined) { - warn.remove() - Redirect(CSRF(self.showDownloadConfirm(author, slug, target, origin, Some(downloadType.id)))) - } else { - // create a record of this download - val downloads = this.service.access[UnsafeDownload](classOf[UnsafeDownload]) - val userId = this.users.current.flatMap(_.id) - val download = downloads.add(UnsafeDownload( - userId = userId, - address = address, - downloadType = downloadType)) - warn.download = download - downloadType match { - case UploadedFile => - sendVersion(project, version, confirmed = true) - case JarFile => - sendJar(project, version, confirmed = true) - case SignatureFile => - // Note: Shouldn't get here in the first place since sig files - // don't need confirmation, but added as a failsafe. - sendSignatureFile(version) - case _ => - throw new Exception("unknown download type: " + downloadType) + withVersion(target) { version => + if (version.isReviewed) + Redirect(ShowProject(author, slug)) + else { + val addr = InetString(StatTracker.remoteAddress) + val dlType = downloadType + .flatMap(i => DownloadTypes.values.find(_.id == i)) + .getOrElse(DownloadTypes.UploadedFile) + // find warning + this.warnings.find { warn => + (warn.address === addr) && + (warn.token === token) && + (warn.versionId === version.id.get) && + !warn.isConfirmed && + (warn.downloadId === -1) + } map { warn => + if (warn.hasExpired) { + // warning has expired + warn.remove() + Redirect(ShowProject(author, slug)) + } else { + // warning confirmed and redirect to download + warn.setConfirmed() + // create record of download + val downloads = this.service.access[UnsafeDownload](classOf[UnsafeDownload]) + val userId = this.users.current.flatMap(_.id) + val download = downloads.add(UnsafeDownload( + userId = userId, + address = addr, + downloadType = dlType)) + warn.download = download + dlType match { + case UploadedFile => + Redirect(self.download(author, slug, target, Some(token))) + case JarFile => + Redirect(self.downloadJar(author, slug, target, Some(token))) + case SignatureFile => + // Note: Shouldn't get here in the first place since sig files + // don't need confirmation, but added as a failsafe. + Redirect(self.downloadSignature(author, slug, target)) + case _ => + throw new Exception("unknown download type: " + downloadType) + } } + } getOrElse { + Redirect(ShowProject(author, slug)) } + } } } } - private def isValidRedirect(url: String) - = Try(!new URI(url).isAbsolute).toOption.getOrElse(false) && url.startsWith("/") && !url.startsWith("//") - - /** - * Sends the specified Project Version to the client. - * - * @param author Project owner - * @param slug Project slug - * @param versionString Version string - * @return Sent file - */ - def download(author: String, slug: String, versionString: String) = ProjectAction(author, slug) { implicit request => - implicit val project = request.project - withVersion(versionString) { version => - sendVersion(project, version, confirmed = false) - } - } - /** * Sends the specified project's current recommended version to the client. * @@ -521,22 +534,11 @@ class Versions @Inject()(stats: StatTracker, * @param slug Project slug * @return Sent file */ - def downloadRecommended(author: String, slug: String) = ProjectAction(author, slug) { implicit request => - val project = request.project - val rv = project.recommendedVersion - sendVersion(project, rv, confirmed = false) - } - - private def sendVersion(project: Project, - version: Version, - confirmed: Boolean)(implicit req: ProjectRequest[_]): Result = { - if (!confirmed && !version.isReviewed) - Redirect(CSRF(self.showDownloadConfirm( - project.ownerName, project.slug, version.name, None, Some(UploadedFile.id)))) - else { - this.stats.versionDownloaded(version) { implicit request => - Ok.sendFile(this.fileManager.getProjectDir(project.ownerName, project.name).resolve(version.fileName).toFile) - } + def downloadRecommended(author: String, slug: String, token: Option[String]) = { + ProjectAction(author, slug) { implicit request => + val project = request.project + val rv = project.recommendedVersion + sendVersion(project, rv, token) } } @@ -549,10 +551,45 @@ class Versions @Inject()(stats: StatTracker, * @param versionString Version name * @return Sent file */ - def downloadJar(author: String, slug: String, versionString: String) = { + def downloadJar(author: String, slug: String, versionString: String, token: Option[String]) = { ProjectAction(author, slug) { implicit request => implicit val project = request.project - withVersion(versionString)(version => sendJar(project, version, confirmed = false)) + withVersion(versionString)(version => sendJar(project, version, token)) + } + } + + private def sendJar(project: Project, + version: Version, + token: Option[String]) + (implicit request: ProjectRequest[_]): Result = { + if (!checkConfirmation(project, version, token)) + Redirect(self.showDownloadConfirm(project.ownerName, project.slug, version.name, Some(JarFile.id))) + else { + val fileName = version.fileName + val path = this.fileManager.getProjectDir(project.ownerName, project.name).resolve(fileName) + this.stats.versionDownloaded(version) { implicit request => + if (fileName.endsWith(".jar")) + Ok.sendFile(path.toFile) + else { + val pluginFile = new PluginFile(path, signaturePath = null, project.owner.user) + val jarName = fileName.substring(0, fileName.lastIndexOf('.')) + ".jar" + val jarPath = this.fileManager.env.tmp.resolve(project.ownerName).resolve(jarName) + var jarIn: InputStream = null + try { + jarIn = pluginFile.newJarStream + copy(jarIn, jarPath, StandardCopyOption.REPLACE_EXISTING) + } catch { + case e: Exception => + Logger.error("an error occurred while trying to send a plugin", e) + } finally { + if (jarIn != null) + jarIn.close() + else + Logger.error("could not obtain input stream for download request") + } + Ok.sendFile(jarPath.toFile, onClose = () => Files.delete(jarPath)) + } + } } } @@ -564,9 +601,11 @@ class Versions @Inject()(stats: StatTracker, * @param slug Project slug * @return Sent file */ - def downloadRecommendedJar(author: String, slug: String) = ProjectAction(author, slug) { implicit request => - val project = request.project - sendJar(project, project.recommendedVersion, confirmed = false) + def downloadRecommendedJar(author: String, slug: String, token: Option[String]) = { + ProjectAction(author, slug) { implicit request => + val project = request.project + sendJar(project, project.recommendedVersion, token) + } } /** @@ -577,9 +616,11 @@ class Versions @Inject()(stats: StatTracker, * @param versionString Version name * @return Sent file */ - def downloadJarById(pluginId: String, versionString: String) = ProjectAction(pluginId) { implicit request => - implicit val project = request.project - withVersion(versionString)(version => sendJar(project, version, confirmed = false)) + def downloadJarById(pluginId: String, versionString: String, token: Option[String]) = { + ProjectAction(pluginId) { implicit request => + implicit val project = request.project + withVersion(versionString)(version => sendJar(project, version, token)) + } } /** @@ -589,9 +630,11 @@ class Versions @Inject()(stats: StatTracker, * @param pluginId Project unique plugin ID * @return Sent file */ - def downloadRecommendedJarById(pluginId: String) = ProjectAction(pluginId) { implicit request => - val project = request.project - sendJar(project, project.recommendedVersion, confirmed = false) + def downloadRecommendedJarById(pluginId: String, token: Option[String]) = { + ProjectAction(pluginId) { implicit request => + val project = request.project + sendJar(project, project.recommendedVersion, token) + } } /** @@ -642,41 +685,6 @@ class Versions @Inject()(stats: StatTracker, sendSignatureFile(request.project.recommendedVersion) } - private def sendJar(project: Project, - version: Version, - confirmed: Boolean)(implicit request: ProjectRequest[_]): Result = { - if (!confirmed && !version.isReviewed) - Redirect(CSRF(self.showDownloadConfirm(project.ownerName, project.slug, version.name, None, Some(JarFile.id)))) - else { - val fileName = version.fileName - val path = this.fileManager.getProjectDir(project.ownerName, project.name).resolve(fileName) - this.stats.versionDownloaded(version) { implicit request => - if (fileName.endsWith(".jar")) - Ok.sendFile(path.toFile) - else { - val pluginFile = new PluginFile(path, signaturePath = null, project.owner.user) - val jarName = fileName.substring(0, fileName.lastIndexOf('.')) + ".jar" - val jarPath = this.fileManager.env.tmp.resolve(project.ownerName).resolve(jarName) - - var jarIn: InputStream = null - try { - jarIn = pluginFile.newJarStream - copy(jarIn, jarPath, StandardCopyOption.REPLACE_EXISTING) - } catch { - case e: Exception => - Logger.error("an error occurred while trying to send a plugin", e) - } finally { - if (jarIn != null) - jarIn.close() - else - Logger.error("could not obtain input stream for download request") - } - Ok.sendFile(jarPath.toFile, onClose = () => Files.delete(jarPath)) - } - } - } - } - private def sendSignatureFile(version: Version): Result = { val project = version.project val path = this.fileManager.getProjectDir(project.ownerName, project.name).resolve(version.signatureFileName) diff --git a/app/db/impl/schema.scala b/app/db/impl/schema.scala index 89119198e..a8063dfb4 100755 --- a/app/db/impl/schema.scala +++ b/app/db/impl/schema.scala @@ -144,8 +144,9 @@ class DownloadWarningsTable(tag: Tag) extends ModelTable[DownloadWarning](tag, " def versionId = column[Int]("version_id") def address = column[InetString]("address") def downloadId = column[Int]("download_id") + def isConfirmed = column[Boolean]("is_confirmed") - override def * = (id.?, createdAt.?, expiration, token, versionId, address, + override def * = (id.?, createdAt.?, expiration, token, versionId, address, isConfirmed, downloadId) <> ((DownloadWarning.apply _).tupled, DownloadWarning.unapply) } diff --git a/app/db/impl/table/ModelKeys.scala b/app/db/impl/table/ModelKeys.scala index 4875438c6..7d4fb46b6 100755 --- a/app/db/impl/table/ModelKeys.scala +++ b/app/db/impl/table/ModelKeys.scala @@ -67,6 +67,7 @@ object ModelKeys { // DownloadWarning val DownloadId = new IntKey[DownloadWarning](_.downloadId, _.downloadId) + val IsConfirmed = new BooleanKey[DownloadWarning](_.isConfirmed, _.isConfirmed) // Channel val Color = new MappedTypeKey[Channel, Color](_.color, _.color) diff --git a/app/models/project/DownloadWarning.scala b/app/models/project/DownloadWarning.scala index 93346a5a9..1a196e62e 100644 --- a/app/models/project/DownloadWarning.scala +++ b/app/models/project/DownloadWarning.scala @@ -29,11 +29,19 @@ case class DownloadWarning(override val id: Option[Int] = None, token: String, versionId: Int, address: InetString, + private var _isConfirmed: Boolean = false, private var _downloadId: Int = -1) extends OreModel(id, createdAt) with Expirable { override type M = DownloadWarning override type T = DownloadWarningsTable + def isConfirmed: Boolean = this._isConfirmed + + def setConfirmed(confirmed: Boolean = true) = Defined { + this._isConfirmed = confirmed + update(IsConfirmed) + } + /** * Returns the ID of the download this warning was for. * diff --git a/app/views/projects/versions/list.scala.html b/app/views/projects/versions/list.scala.html index de73de9c4..cff4cb1e3 100755 --- a/app/views/projects/versions/list.scala.html +++ b/app/views/projects/versions/list.scala.html @@ -79,16 +79,10 @@

Versions

@prettifyDate(version.createdAt.get) @version.humanFileSize - + -
diff --git a/app/views/projects/versions/unsafeDownload.scala.html b/app/views/projects/versions/unsafeDownload.scala.html index 10ddf9497..a4efaec85 100644 --- a/app/views/projects/versions/unsafeDownload.scala.html +++ b/app/views/projects/versions/unsafeDownload.scala.html @@ -7,8 +7,8 @@ @import views.html.helper.CSRF @(project: Project, target: Version, - origin: Option[String], - downloadType: DownloadType)(implicit messages: Messages, request: Request[_], service: ModelService, + downloadType: DownloadType, + token: String)(implicit messages: Messages, request: Request[_], service: ModelService, config: OreConfig, users: UserBase) @versionRoutes = @{ controllers.project.routes.Versions } @@ -26,15 +26,15 @@

@Html(messages("version.download.confirm.body")) - @messages("project.back") - diff --git a/app/views/projects/versions/view.scala.html b/app/views/projects/versions/view.scala.html index 2ef34ab1f..6621be4a4 100755 --- a/app/views/projects/versions/view.scala.html +++ b/app/views/projects/versions/view.scala.html @@ -108,14 +108,11 @@

@version.versionString

} } - - +
diff --git a/app/views/projects/view.scala.html b/app/views/projects/view.scala.html index de78f5f17..ce8ff0849 100755 --- a/app/views/projects/view.scala.html +++ b/app/views/projects/view.scala.html @@ -193,14 +193,11 @@

} - - + } diff --git a/conf/evolutions/default/70.sql b/conf/evolutions/default/70.sql new file mode 100644 index 000000000..633efd35b --- /dev/null +++ b/conf/evolutions/default/70.sql @@ -0,0 +1,7 @@ +# --- !Ups + +alter table project_version_download_warnings add column is_confirmed boolean not null default false; + +# --- !Downs + +alter table project_version_download_warnings drop column is_confirmed; diff --git a/conf/messages b/conf/messages index 56d8812c9..d185b8461 100755 --- a/conf/messages +++ b/conf/messages @@ -189,8 +189,12 @@ version.download.confirm.body.plain = \ This version has not been reviewed by our moderation staff and may not be safe for download.\n\ Disclaimer: We disclaim all responsibility for any harm to your server or system should you choose not to heed this \ warning.\n\ - Please use the following URL to acknowledge this disclaimer and continue to the download:\n\ - {0} + Please use the following curl to acknowledge this disclaimer and continue to the download:\n\ + curl -O -J -L -d -X "{0}&csrfToken={1}" + +version.download.confirm.wget = Sorry, but Ore does not support the use of wget. \ + Please use the following curl instead:\n\ + curl -O -J -L "" channel.name = Channel name channel.edit.title = Edit channel diff --git a/conf/routes b/conf/routes index 3cac18139..1dcbc4428 100755 --- a/conf/routes +++ b/conf/routes @@ -24,10 +24,10 @@ GET /api/projects/:pluginId @controllers GET /api/projects/:pluginId/versions @controllers.ApiController.listVersions(version = "v1", pluginId, channels: Option[String], limit: Option[Int], offset: Option[Int]) GET /api/projects/:pluginId/versions/:name @controllers.ApiController.showVersion(version = "v1", pluginId, name) -POST /api/projects/:pluginId/versions/recommended/download @controllers.project.Versions.downloadRecommendedJarById(pluginId) -POST /api/projects/:pluginId/versions/recommended/signature @controllers.project.Versions.downloadRecommendedSignatureById(pluginId) -POST /api/projects/:pluginId/versions/:name/download @controllers.project.Versions.downloadJarById(pluginId, name) -POST /api/projects/:pluginId/versions/:name/signature @controllers.project.Versions.downloadSignatureById(pluginId, name) +GET /api/projects/:pluginId/versions/recommended/download @controllers.project.Versions.downloadRecommendedJarById(pluginId, token: Option[String]) +GET /api/projects/:pluginId/versions/recommended/signature @controllers.project.Versions.downloadRecommendedSignatureById(pluginId) +GET /api/projects/:pluginId/versions/:name/download @controllers.project.Versions.downloadJarById(pluginId, name, token: Option[String]) +GET /api/projects/:pluginId/versions/:name/signature @controllers.project.Versions.downloadSignatureById(pluginId, name) GET /api/users @controllers.ApiController.listUsers(version = "v1", limit: Option[Int], offset: Option[Int]) GET /api/users/:user @controllers.ApiController.showUser(version = "v1", user) @@ -139,16 +139,16 @@ GET /:author/:slug/versions @controllers POST /:author/:slug/versions/:version/approve @controllers.project.Versions.approve(author, slug, version) POST /:author/:slug/versions/:version/delete @controllers.project.Versions.delete(author, slug, version) -GET /:author/:slug/versions/:version/confirm @controllers.project.Versions.showDownloadConfirm(author, slug, version, origin: Option[String], downloadType: Option[Int]) -POST /:author/:slug/versions/:version/unsafe @controllers.project.Versions.downloadUnsafely(author, slug, version, origin: Option[String], downloadType: Option[Int], token: Option[String]) +GET /:author/:slug/versions/:version/confirm @controllers.project.Versions.showDownloadConfirm(author, slug, version, downloadType: Option[Int]) +POST /:author/:slug/versions/:version/confirm @controllers.project.Versions.confirmDownload(author, slug, version, downloadType: Option[Int], token) -POST /:author/:slug/versions/recommended/download @controllers.project.Versions.downloadRecommended(author, slug) -POST /:author/:slug/versions/recommended/signature @controllers.project.Versions.downloadRecommendedSignature(author, slug) -POST /:author/:slug/versions/:version/download @controllers.project.Versions.download(author, slug, version) +GET /:author/:slug/versions/recommended/download @controllers.project.Versions.downloadRecommended(author, slug, token: Option[String]) +GET /:author/:slug/versions/recommended/signature @controllers.project.Versions.downloadRecommendedSignature(author, slug) +GET /:author/:slug/versions/:version/download @controllers.project.Versions.download(author, slug, version, token: Option[String]) GET /:author/:slug/versions/:version/signature @controllers.project.Versions.downloadSignature(author, slug, version) -POST /:author/:slug/versions/recommended/jar @controllers.project.Versions.downloadRecommendedJar(author, slug) -POST /:author/:slug/versions/:version/jar @controllers.project.Versions.downloadJar(author, slug, version) +GET /:author/:slug/versions/recommended/jar @controllers.project.Versions.downloadRecommendedJar(author, slug, token: Option[String]) +GET /:author/:slug/versions/:version/jar @controllers.project.Versions.downloadJar(author, slug, version, token: Option[String]) GET /:author/:slug/versions/new @controllers.project.Versions.showCreator(author, slug) POST /:author/:slug/versions/new/upload @controllers.project.Versions.upload(author, slug)