Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for npm engine #913

Merged
merged 22 commits into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ gem 'lograge'
gem 'aws-sdk-s3', '~> 1'
gem 'semverse'
gem 'compact_index'
gem 'minitar'

# Misc
gem 'null_association'
Expand Down Expand Up @@ -140,4 +141,5 @@ group :test do
gem 'database_cleaner', '~> 2.0'
gem 'webmock', '~> 3.14.0'
gem 'elif', '~> 0.1.0'
gem 'memory_profiler'
end
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -299,11 +299,13 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.2)
memory_profiler (1.1.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2022.0105)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitar (1.0.2)
minitest (5.25.1)
msgpack (1.7.2)
multi_json (1.15.0)
Expand Down Expand Up @@ -551,6 +553,8 @@ DEPENDENCIES
kaminari (~> 1.2.0)
listen (>= 3.8.0)
lograge
memory_profiler
minitar
msgpack (~> 1.7)
nokogiri (~> 1.16.5)
null_association
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

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.tarballs).order_by_version
.where_assoc_exists(:manifest) # must exist
.preload(:manifest,
release: %i[product entitlements constraints],
)
authorize! artifacts,
to: :index?

# FIXME(ezekg) https://github.com/brianhempel/active_record_union/issues/35
last_modified = artifacts.maximum(:"#{artifacts.table_name}.updated_at")
latest = artifacts.first
metadata = artifacts.reduce(
name: package.key,
time: { created: package.created_at, modified: last_modified },
'dist-tags': { latest: latest.version },
versions: {},
) do |metadata, artifact|
package_json = artifact.manifest.as_package_json

# TODO(ezekg) implement signatures?
checksums = case [artifact.checksum_encoding, artifact.checksum_algorithm]
in [:base64, :sha256 | :sha384 | :sha512 => algorithm]
# see: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
{ integrity: "#{algorithm}-#{artifact.checksum}" }
in [:hex, :sha1]
{ shasum: artifact.checksum }
else
{}
end

metadata[:time][artifact.version] = artifact.created_at.iso8601(3)
metadata[:'dist-tags'][artifact.tag] = artifact.version if artifact.tag?
metadata[:versions][artifact.version] = package_json.merge(
dist: {
tarball: vanity_v1_account_release_artifact_url(current_account, artifact, filename: artifact.filename, host: request.host),
ezekg marked this conversation as resolved.
Show resolved Hide resolved
**checksums,
},
)

metadata
end

# for etag support
return unless
stale?(metadata, last_modified:, cache_control: { max_age: 1.day, private: true })

render json: metadata
ezekg marked this conversation as resolved.
Show resolved Hide resolved
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
11 changes: 10 additions & 1 deletion app/controllers/api/v1/release_engines/pypi/simple_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ def index
packages = authorized_scope(apply_scopes(current_account.release_packages.pypi))
authorize! packages

# for etag support
return unless
stale?(packages, cache_control: { max_age: 1.day, private: true })

render 'api/v1/release_engines/pypi/simple/index',
layout: 'layouts/simple',
locals: {
Expand All @@ -24,10 +28,15 @@ def index
def show
authorize! package

artifacts = package.artifacts.order_by_version.preload(release: %i[product entitlements constraints])
artifacts = authorized_scope(package.artifacts).order_by_version.preload(release: %i[product entitlements constraints])
authorize! artifacts,
to: :index?

# FIXME(ezekg) https://github.com/brianhempel/active_record_union/issues/35
last_modified = artifacts.maximum(:"#{artifacts.table_name}.updated_at")
return unless
stale?(artifacts, last_modified:, cache_control: { max_age: 1.day, private: true })
Copy link
Member Author

@ezekg ezekg Nov 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rip out the PyPI etag stuff into a separate PR.


render 'api/v1/release_engines/pypi/simple/show',
layout: 'layouts/simple',
locals: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,22 @@ def to_versions(artifacts)
return [] unless artifacts.present?

artifacts.map do |artifact|
gemspec = artifact.manifest.as_gemspec
gemspec = artifact.manifest.as_gemspec
checksum = case [artifact.checksum_encoding, artifact.checksum_algorithm]
in [:hex | :base64, :sha256]
artifact.checksum
else
nil
end

dependencies = gemspec.dependencies.map do |dependency|
CompactIndex::Dependency.new(dependency.name.to_s, dependency.requirement.to_s)
end

CompactIndex::GemVersion.new(
gemspec.version.to_s,
gemspec.platform.to_s,
artifact.checksum,
checksum,
nil, # will be calculated via versions file
dependencies,
gemspec.required_ruby_version.to_s,
Expand Down
16 changes: 10 additions & 6 deletions app/helpers/checksum_helper.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
# frozen_string_literal: true

module ChecksumHelper
def checksum_for(artifact, delimiter: '-')
case artifact.checksum&.size
when 64
"sha256#{delimiter}#{artifact.checksum}"
when 128
"sha512#{delimiter}#{artifact.checksum}"
# see: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
# https://peps.python.org/pep-0503/
def checksum_for(artifact, format: :sri)
case [artifact.checksum_encoding, artifact.checksum_algorithm]
in [:hex, :md5 | :sha1 | :sha224 | :sha256 | :sha384 | :sha512 => algorithm] if format == :pep
"#{algorithm}=#{artifact.checksum}"
in [:base64, :sha256 | :sha384 | :sha512 => algorithm] if format == :sri
"#{algorithm}-#{artifact.checksum}"
in [*] if format.nil?
artifact.checksum
else
nil
end
Expand Down
55 changes: 49 additions & 6 deletions app/models/release_artifact.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,6 @@ class ReleaseArtifact < ApplicationRecord
in: STATUSES,
}

delegate :version, :semver, :channel,
:licensed?, :open?, :closed?,
to: :release

scope :order_by_version, -> (order = :desc) {
sql = case order
in :desc
Expand Down Expand Up @@ -354,6 +350,7 @@ class ReleaseArtifact < ApplicationRecord
end
}

scope :for_filetypes, -> *filetypes { joins(:filetype).where(filetype: { key: filetypes }) }
scope :for_filetype, -> filetype {
case filetype.presence
when ReleaseFiletype,
Expand Down Expand Up @@ -453,9 +450,16 @@ 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_filetype(:gem) }
scope :tarballs, -> { for_filetypes(:tgz, :tar, :'tar.gz') }

delegate :version, :semver, :channel, :tag,
:tag?, :licensed?, :open?, :closed?,
allow_nil: true,
to: :release

def key = "artifacts/#{account_id}/#{release_id}/#{filename}"
def key_for(path) = "artifacts/#{account_id}/#{release_id}/#{path}"
def key = key_for(filename)

def presigner = Aws::S3::Presigner.new(client:)

Expand Down Expand Up @@ -563,6 +567,45 @@ def downloadable?
uploaded? && !release.yanked?
end

def checksum_encoding
case checksum
in HEX_RE then :hex
in BASE64_RE then :base64
else nil
end
rescue Encoding::CompatibilityError # invalid encoding
nil
end

def checksum_bytes
case checksum_encoding
in :base64 then Base64.decode64(checksum)
in :hex then [checksum].pack('H*')
else nil
end
rescue ArgumentError # invalid base64
nil
end

def checksum_bytesize
case checksum_bytes
in String then checksum_bytes.size
else nil
end
end

def checksum_algorithm
case checksum_bytesize
in 16 then :md5
in 20 then :sha1
in 28 then :sha224
in 32 then :sha256
in 48 then :sha384
in 64 then :sha512
else nil
end
end

private

def validate_associated_records_for_filetype
Expand Down
2 changes: 2 additions & 0 deletions app/models/release_engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class ReleaseEngine < ApplicationRecord
tauri
raw
rubygems
npm
]

has_many :packages,
Expand Down Expand Up @@ -110,6 +111,7 @@ def pypi? = key == 'pypi'
def tauri? = key == 'tauri'
def raw? = key == 'raw'
def rubygems? = key == 'rubygems'
def npm? = key == 'npm'

##
# deconstruct allows pattern pattern matching like:
Expand Down
3 changes: 2 additions & 1 deletion app/models/release_manifest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion app/models/release_package.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ class ReleasePackage < ApplicationRecord
scope :tauri, -> { for_engine_key('tauri') }
scope :raw, -> { for_engine_key('raw') }
scope :rubygems, -> { for_engine_key('rubygems') }
scope :npm, -> { for_engine_key('npm') }

delegate :pypi?, :tauri?, :raw?, :rubygems?,
delegate :pypi?, :tauri?, :raw?, :rubygems?, :npm?,
to: :engine,
allow_nil: true

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
- artifacts.each do |artifact|
-# NOTE(ezekg) Even though it's not covered in PEP 503, pip expects the URL path to be a
-# filename, not a UUID. Paths without an extension are ignored.
- url = vanity_v1_account_release_artifact_url(account, artifact, filename: artifact.filename, anchor: checksum_for(artifact, delimiter: '='), host: request.host)
- url = vanity_v1_account_release_artifact_url(account, artifact, filename: artifact.filename, anchor: checksum_for(artifact, format: :pep), host: request.host)

= link_to(artifact.filename, url, data: artifact.metadata)
%br
Loading