1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345
|
# frozen_string_literal: true
# PyPI Package Manager Client API
#
# These API endpoints are not meant to be consumed directly by users. They are
# called by the PyPI package manager client when users run commands
# like `pip install` or `twine upload`.
module API
class PypiPackages < ::API::Base
helpers ::API::Helpers::PackagesManagerClientsHelpers
helpers ::API::Helpers::RelatedResourcesHelpers
helpers ::API::Helpers::Packages::BasicAuthHelpers
helpers ::API::Helpers::Packages::DependencyProxyHelpers
include ::API::Helpers::Packages::BasicAuthHelpers::Constants
feature_category :package_registry
urgency :low
default_format :json
rescue_from ArgumentError do |e|
render_api_error!(e.message, 400)
end
rescue_from ActiveRecord::RecordInvalid do |e|
render_api_error!(e.message, 400)
end
before do
require_packages_enabled!
end
helpers do
params :package_download do
requires :file_identifier, type: String, desc: 'The PyPi package file identifier', file_path: true, documentation: { example: 'my.pypi.package-0.0.1.tar.gz' }
requires :sha256, type: String, desc: 'The PyPi package sha256 check sum', documentation: { example: '5y57017232013c8ac80647f4ca153k3726f6cba62d055cd747844ed95b3c65ff' }
end
params :package_name do
requires :package_name, type: String, file_path: true, desc: 'The PyPi package name', documentation: { example: 'my.pypi.package' }
end
def present_simple_index(group_or_project)
authorize_read_package!(group_or_project)
packages = Packages::Pypi::PackagesFinder.new(current_user, group_or_project).execute
presenter = ::Packages::Pypi::SimpleIndexPresenter.new(packages, group_or_project)
present_html(presenter.body)
end
def present_simple_package(group_or_project)
authorize_read_package!(group_or_project)
track_simple_event(group_or_project, 'list_package')
packages = Packages::Pypi::PackagesFinder.new(current_user, group_or_project, { package_name: params[:package_name] }).execute
empty_packages = packages.empty?
redirect_registry_request(
forward_to_registry: empty_packages,
package_type: :pypi,
target: group_or_project,
package_name: params[:package_name]
) do
not_found!('Package') if empty_packages
presenter = ::Packages::Pypi::SimplePackageVersionsPresenter.new(packages, group_or_project)
present_html(presenter.body)
end
end
def track_simple_event(group_or_project, event_name)
if group_or_project.is_a?(Project)
project = group_or_project
namespace = group_or_project.namespace
else
project = nil
namespace = group_or_project
end
track_package_event(event_name, :pypi, project: project, namespace: namespace)
end
def present_html(content)
# Adjusts grape output format
# to be HTML
content_type 'text/html; charset=utf-8'
env['api.format'] = :binary
body content
end
def ensure_group!
find_group(params[:id]) || not_found!
find_authorized_group!
end
def project!(action: :read_package)
find_project(params[:id]) || not_found!
authorized_user_project(action: action)
end
def validate_fips!
unprocessable_entity! if declared_params[:sha256_digest].blank?
true
end
end
params do
requires :id, types: [Integer, String], desc: 'The ID or full path of the group.'
end
resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
after_validation do
ensure_group!
end
namespace ':id/-/packages/pypi' do
desc 'Download a package file from a group' do
detail 'This feature was introduced in GitLab 13.12'
success code: 200
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not Found' }
]
tags %w[pypi_packages]
end
params do
use :package_download
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'files/:sha256/*file_identifier' do
group = find_authorized_group!
authorize_read_package!(group)
filename = "#{params[:file_identifier]}.#{params[:format]}"
package = Packages::Pypi::PackageFinder.new(current_user, group, { filename: filename, sha256: params[:sha256] }).execute
package_file = ::Packages::PackageFileFinder.new(package, filename, with_file_name_like: false).execute
track_package_event('pull_package', :pypi, namespace: group, project: package.project)
present_package_file!(package_file, supports_direct_download: true)
end
desc 'The PyPi Simple Group Index Endpoint' do
detail 'This feature was introduced in GitLab 15.1'
success code: 200
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not Found' }
]
tags %w[pypi_packages]
end
# An API entry point but returns an HTML file instead of JSON.
# PyPi simple API returns a list of packages as a simple HTML file.
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'simple', format: :txt do
present_simple_index(find_authorized_group!)
end
desc 'The PyPi Simple Group Package Endpoint' do
detail 'This feature was introduced in GitLab 12.10'
success code: 200
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not Found' }
]
tags %w[pypi_packages]
end
params do
use :package_name
end
# An API entry point but returns an HTML file instead of JSON.
# PyPi simple API returns the package descriptor as a simple HTML file.
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'simple/*package_name', format: :txt do
present_simple_package(find_authorized_group!)
end
end
end
params do
requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project'
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
namespace ':id/packages/pypi' do
desc 'The PyPi package download endpoint' do
detail 'This feature was introduced in GitLab 12.10'
success code: 200
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not Found' }
]
tags %w[pypi_packages]
end
params do
use :package_download
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'files/:sha256/*file_identifier' do
project = project!
filename = "#{params[:file_identifier]}.#{params[:format]}"
package = Packages::Pypi::PackageFinder.new(current_user, project, { filename: filename, sha256: params[:sha256] }).execute
package_file = ::Packages::PackageFileFinder.new(package, filename, with_file_name_like: false).execute
track_package_event('pull_package', :pypi, project: project, namespace: project.namespace)
present_package_file!(package_file, supports_direct_download: true)
end
desc 'The PyPi Simple Project Index Endpoint' do
detail 'This feature was introduced in GitLab 15.1'
success code: 200
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not Found' }
]
tags %w[pypi_packages]
end
# An API entry point but returns an HTML file instead of JSON.
# PyPi simple API returns a list of packages as a simple HTML file.
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'simple', format: :txt do
present_simple_index(project!)
end
desc 'The PyPi Simple Project Package Endpoint' do
detail 'This feature was introduced in GitLab 12.10'
success code: 200
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not Found' }
]
tags %w[pypi_packages]
end
params do
use :package_name
end
# An API entry point but returns an HTML file instead of JSON.
# PyPi simple API returns the package descriptor as a simple HTML file.
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'simple/*package_name', format: :txt do
present_simple_package(project!)
end
desc 'The PyPi Package upload endpoint' do
detail 'This feature was introduced in GitLab 12.10'
success code: 201
failure [
{ code: 400, message: 'Bad Request' },
{ code: 401, message: 'Unauthorized' },
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not Found' },
{ code: 422, message: 'Unprocessable Entity' }
]
tags %w[pypi_packages]
end
params do
requires :content, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)', documentation: { type: 'file' }
requires :name, type: String, documentation: { example: 'my.pypi.package' }
requires :version, type: String, documentation: { example: '1.3.7' }
optional :requires_python, type: String, documentation: { example: '>=3.7' }
optional :md5_digest, type: String, documentation: { example: '900150983cd24fb0d6963f7d28e17f72' }
optional :sha256_digest, type: String, regexp: Gitlab::Regex.sha256_regex, documentation: { example: 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' }
optional :metadata_version, type: String, documentation: { example: '2.3' }
optional :author_email, type: String, documentation: { example: 'cschultz@example.com, snoopy@peanuts.com' }
optional :description, type: String
optional :description_content_type, type: String,
documentation: { example: 'text/markdown; charset=UTF-8; variant=GFM' }
optional :summary, type: String, documentation: { example: 'A module for collecting votes from beagles.' }
optional :keywords, type: String, documentation: { example: 'dog,puppy,voting,election' }
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
post do
project = project!(action: :read_project)
authorize_upload!(project)
if project.actual_limits.exceeded?(:pypi_max_file_size, params[:content].size)
bad_request!('File is too large')
end
track_package_event('push_package', :pypi, project: project, namespace: project.namespace)
validate_fips! if Gitlab::FIPS.enabled?
service_response = ::Packages::Pypi::CreatePackageService
.new(project, current_user, declared_params.merge(build: current_authenticated_job))
.execute
if service_response.error? && service_response.reason == Packages::Pypi::CreatePackageService::ERROR_RESPONSE_PACKAGE_PROTECTED.reason
forbidden!(service_response.message)
end
bad_request!(service_response.message) if service_response.error?
created!
rescue ObjectStorage::RemoteStoreError => e
Gitlab::ErrorTracking.track_exception(e, extra: { file_name: params[:name], project_id: authorized_user_project.id })
forbidden!
end
desc 'Authorize the PyPi package upload from workhorse' do
detail 'This feature was introduced in GitLab 12.10'
success code: 200
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not Found' }
]
tags %w[pypi_packages]
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
post 'authorize' do
project = project!(action: :read_project)
authorize_workhorse!(
subject: project,
has_length: false,
maximum_size: project.actual_limits.pypi_max_file_size
)
end
end
end
end
end
|