diff --git a/app/controllers/api/v1/release_engines/npm/package_metadata_controller.rb b/app/controllers/api/v1/release_engines/npm/package_metadata_controller.rb new file mode 100644 index 0000000000..056be175a3 --- /dev/null +++ b/app/controllers/api/v1/release_engines/npm/package_metadata_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'compact_index' + +module Api::V1::ReleaseEngines + class Npm::PackageMetadataController < Api::V1::BaseController + before_action :scope_to_current_account! + before_action :require_active_subscription! + before_action :authenticate_with_token + before_action :set_package + + def show + authorize! package, + to: :show? + + artifacts = authorized_scope(package.artifacts.npm_package_tgz.order_by_version) + .where_assoc_exists(:manifest) # must exist + .preload(:manifest, + release: %i[product entitlements constraints], + ) + authorize! artifacts, + to: :index? + + latest = artifacts.first + metadata = artifacts.reduce(name: package.name, time: {}, 'dist-tags': { latest: latest.release.version }, versions: {}) do |metadata, artifact| + package_json = artifact.manifest.as_package_json + release = artifact.release + + metadata[:time][release.version] = artifact.created_at.iso8601 + metadata[:'dist-tags'][release.tag] = release.version if release.tag? + metadata[:versions][release.version] = package_json.merge( + dist: { + tarball: vanity_v1_account_release_artifact_url(current_account, artifact, filename: artifact.filename, host: request.host), + }, + ) + + metadata + end + + render json: metadata + end + + private + + attr_reader :package + + def set_package + scoped_packages = authorized_scope(current_account.release_packages.npm) + .where_assoc_exists( + %i[releases artifacts manifest], # must exist + ) + + Current.resource = @package = FindByAliasService.call( + scoped_packages, + id: params[:package], + aliases: :key, + ) + end + end +end diff --git a/app/models/release_artifact.rb b/app/models/release_artifact.rb index 542eb86b7e..9a7c17a636 100644 --- a/app/models/release_artifact.rb +++ b/app/models/release_artifact.rb @@ -453,7 +453,8 @@ class ReleaseArtifact < ApplicationRecord scope :yanked, -> { joins(:release).where(releases: { status: 'YANKED' }) } scope :unyanked, -> { joins(:release).where.not(releases: { status: 'YANKED' }) } - scope :gems, -> { for_filetype(:gem) } + scope :gems, -> { for_engine(:rubygems).for_filetype(:gem) } + scope :npm_package_tgz, -> { for_engine(:npm).for_filetype(:tgz) } def key_for(path) = "artifacts/#{account_id}/#{release_id}/#{path}" def key = key_for(filename) diff --git a/app/models/release_manifest.rb b/app/models/release_manifest.rb index f88c1f3ad0..c41e93e0e4 100644 --- a/app/models/release_manifest.rb +++ b/app/models/release_manifest.rb @@ -47,5 +47,6 @@ class ReleaseManifest < ApplicationRecord end end - def as_gemspec = Gem::Specification.from_yaml(content) + def as_gemspec = Gem::Specification.from_yaml(content) + def as_package_json = JSON.parse(content) end diff --git a/app/workers/process_docker_image_worker.rb b/app/workers/process_docker_image_worker.rb index 62ea19bccd..4646983fcf 100644 --- a/app/workers/process_docker_image_worker.rb +++ b/app/workers/process_docker_image_worker.rb @@ -26,10 +26,10 @@ def perform(artifact_id) .body # unpack the package tarball - io = gunzip(tgz) + tar = gunzip(tgz) - unpack io do |tar| - tar.each do |entry| + unpack tar do |archive| + archive.each do |entry| case entry.name in 'manifest.json' raise ImageNotAcceptableError, 'manifest must be a manifest.json file' unless @@ -67,7 +67,7 @@ def perform(artifact_id) end # not sure why GzipReader#open doesn't take an io? - io.close + tar.close artifact.update!(status: 'UPLOADED') diff --git a/app/workers/process_npm_package_worker.rb b/app/workers/process_npm_package_worker.rb index ea6fc488cf..5449b43363 100644 --- a/app/workers/process_npm_package_worker.rb +++ b/app/workers/process_npm_package_worker.rb @@ -26,11 +26,11 @@ def perform(artifact_id) .body # unpack the package tarball - io = gunzip(tgz) + tar = gunzip(tgz) - unpack io do |tar| + unpack tar do |archive| # NOTE(ezekg) npm prefixes everything in the archive with package/ - entry = tar.find { _1.name in 'package/package.json' } + entry = archive.find { _1.name in 'package/package.json' } raise PackageNotAcceptableError, 'manifest at package/package.json must exist' if entry.nil? @@ -54,7 +54,7 @@ def perform(artifact_id) end # not sure why GzipReader#open doesn't take an io? - io.close + tar.close artifact.update!(status: 'UPLOADED') diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index 89eccd865e..6ef26151f0 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +Mime::Type.register 'application/vnd.npm.install-v1+json', :npm Mime::Type.register 'application/octet-stream', :binary Mime::Type.register 'application/vnd.api+json', :jsonapi, %W[ application/vnd.keygen+json diff --git a/config/routes.rb b/config/routes.rb index 50a1f603e5..c7ae5954b5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -46,6 +46,7 @@ end concern :pypi do + # see: https://peps.python.org/pep-0503/ scope module: :pypi, constraints: MimeTypeConstraint.new(:html, raise_on_no_match: true), defaults: { format: :html } do get 'simple/', to: 'simple#index', as: :pypi_simple_packages, trailing_slash: true get 'simple/:package/', to: 'simple#show', as: :pypi_simple_package, trailing_slash: true @@ -53,6 +54,7 @@ end concern :tauri do + # see: https://v2.tauri.app/plugin/updater/#dynamic-update-server scope module: :tauri, constraints: MimeTypeConstraint.new(:binary, :json, raise_on_no_match: true), defaults: { format: :json } do get ':package', to: 'upgrades#show' end @@ -89,6 +91,15 @@ end end + concern :npm do + # see: https://github.com/npm/registry/blob/ae49abf1bac0ec1a3f3f1fceea1cca6fe2dc00e1/docs/responses/package-metadata.md + scope module: :npm, constraints: MimeTypeConstraint.new(:json, :npm, raise_on_no_match: true), defaults: { format: :json } do + get ':package', to: 'package_metadata#show', as: :npm_package_metadata, constraints: { + package: /.*/ + } + end + end + concern :v1 do get :ping, to: 'health#general_ping' @@ -465,6 +476,9 @@ scope :rubygems do concerns :rubygems end + scope :npm do + concerns :npm + end end end @@ -601,6 +615,17 @@ concerns :rubygems end end + + scope module: 'api/v1/release_engines', constraints: { subdomain: 'npm.pkg' } do + case + when Keygen.multiplayer? + scope ':account_id', as: :account do + concerns :npm + end + when Keygen.singleplayer? + concerns :npm + end + end end %w[500 503].each do |code|