1# frozen_string_literal: true 2 3# PyPI Package Manager Client API 4# 5# These API endpoints are not meant to be consumed directly by users. They are 6# called by the PyPI package manager client when users run commands 7# like `pip install` or `twine upload`. 8module API 9 class PypiPackages < ::API::Base 10 helpers ::API::Helpers::PackagesManagerClientsHelpers 11 helpers ::API::Helpers::RelatedResourcesHelpers 12 helpers ::API::Helpers::Packages::BasicAuthHelpers 13 helpers ::API::Helpers::Packages::DependencyProxyHelpers 14 include ::API::Helpers::Packages::BasicAuthHelpers::Constants 15 16 feature_category :package_registry 17 18 default_format :json 19 20 rescue_from ArgumentError do |e| 21 render_api_error!(e.message, 400) 22 end 23 24 rescue_from ActiveRecord::RecordInvalid do |e| 25 render_api_error!(e.message, 400) 26 end 27 28 before do 29 require_packages_enabled! 30 end 31 32 helpers do 33 params :package_download do 34 requires :file_identifier, type: String, desc: 'The PyPi package file identifier', file_path: true 35 requires :sha256, type: String, desc: 'The PyPi package sha256 check sum' 36 end 37 38 params :package_name do 39 requires :package_name, type: String, file_path: true, desc: 'The PyPi package name' 40 end 41 end 42 43 params do 44 requires :id, type: String, desc: 'The ID of a group' 45 end 46 resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do 47 after_validation do 48 unauthorized_user_group! 49 end 50 51 namespace ':id/-/packages/pypi' do 52 params do 53 use :package_download 54 end 55 56 route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth 57 get 'files/:sha256/*file_identifier' do 58 group = unauthorized_user_group! 59 60 filename = "#{params[:file_identifier]}.#{params[:format]}" 61 package = Packages::Pypi::PackageFinder.new(current_user, group, { filename: filename, sha256: params[:sha256] }).execute 62 package_file = ::Packages::PackageFileFinder.new(package, filename, with_file_name_like: false).execute 63 64 track_package_event('pull_package', :pypi) 65 66 present_carrierwave_file!(package_file.file, supports_direct_download: true) 67 end 68 69 desc 'The PyPi Simple Endpoint' do 70 detail 'This feature was introduced in GitLab 12.10' 71 end 72 73 params do 74 use :package_name 75 end 76 77 # An Api entry point but returns an HTML file instead of JSON. 78 # PyPi simple API returns the package descriptor as a simple HTML file. 79 route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth 80 get 'simple/*package_name', format: :txt do 81 group = find_authorized_group! 82 authorize_read_package!(group) 83 84 track_package_event('list_package', :pypi) 85 86 packages = Packages::Pypi::PackagesFinder.new(current_user, group, { package_name: params[:package_name] }).execute 87 empty_packages = packages.empty? 88 89 redirect_registry_request(empty_packages, :pypi, package_name: params[:package_name]) do 90 not_found!('Package') if empty_packages 91 presenter = ::Packages::Pypi::PackagePresenter.new(packages, group) 92 93 # Adjusts grape output format 94 # to be HTML 95 content_type "text/html; charset=utf-8" 96 env['api.format'] = :binary 97 98 body presenter.body 99 end 100 end 101 end 102 end 103 104 params do 105 requires :id, type: String, desc: 'The ID of a project' 106 end 107 108 resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do 109 before do 110 unauthorized_user_project! 111 end 112 113 namespace ':id/packages/pypi' do 114 desc 'The PyPi package download endpoint' do 115 detail 'This feature was introduced in GitLab 12.10' 116 end 117 118 params do 119 use :package_download 120 end 121 122 route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth 123 get 'files/:sha256/*file_identifier' do 124 project = unauthorized_user_project! 125 126 filename = "#{params[:file_identifier]}.#{params[:format]}" 127 package = Packages::Pypi::PackageFinder.new(current_user, project, { filename: filename, sha256: params[:sha256] }).execute 128 package_file = ::Packages::PackageFileFinder.new(package, filename, with_file_name_like: false).execute 129 130 track_package_event('pull_package', :pypi, project: project, namespace: project.namespace) 131 132 present_carrierwave_file!(package_file.file, supports_direct_download: true) 133 end 134 135 desc 'The PyPi Simple Endpoint' do 136 detail 'This feature was introduced in GitLab 12.10' 137 end 138 139 params do 140 use :package_name 141 end 142 143 # An Api entry point but returns an HTML file instead of JSON. 144 # PyPi simple API returns the package descriptor as a simple HTML file. 145 route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth 146 get 'simple/*package_name', format: :txt do 147 authorize_read_package!(authorized_user_project) 148 149 track_package_event('list_package', :pypi, project: authorized_user_project, namespace: authorized_user_project.namespace) 150 151 packages = Packages::Pypi::PackagesFinder.new(current_user, authorized_user_project, { package_name: params[:package_name] }).execute 152 empty_packages = packages.empty? 153 154 redirect_registry_request(empty_packages, :pypi, package_name: params[:package_name]) do 155 not_found!('Package') if empty_packages 156 presenter = ::Packages::Pypi::PackagePresenter.new(packages, authorized_user_project) 157 158 # Adjusts grape output format 159 # to be HTML 160 content_type "text/html; charset=utf-8" 161 env['api.format'] = :binary 162 163 body presenter.body 164 end 165 end 166 167 desc 'The PyPi Package upload endpoint' do 168 detail 'This feature was introduced in GitLab 12.10' 169 end 170 171 params do 172 requires :content, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)' 173 requires :requires_python, type: String 174 requires :name, type: String 175 requires :version, type: String 176 optional :md5_digest, type: String 177 optional :sha256_digest, type: String 178 end 179 180 route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth 181 post do 182 authorize_upload!(authorized_user_project) 183 bad_request!('File is too large') if authorized_user_project.actual_limits.exceeded?(:pypi_max_file_size, params[:content].size) 184 185 track_package_event('push_package', :pypi, project: authorized_user_project, user: current_user, namespace: authorized_user_project.namespace) 186 187 ::Packages::Pypi::CreatePackageService 188 .new(authorized_user_project, current_user, declared_params.merge(build: current_authenticated_job)) 189 .execute 190 191 created! 192 rescue ObjectStorage::RemoteStoreError => e 193 Gitlab::ErrorTracking.track_exception(e, extra: { file_name: params[:name], project_id: authorized_user_project.id }) 194 195 forbidden! 196 end 197 198 route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth 199 post 'authorize' do 200 authorize_workhorse!( 201 subject: authorized_user_project, 202 has_length: false, 203 maximum_size: authorized_user_project.actual_limits.pypi_max_file_size 204 ) 205 end 206 end 207 end 208 end 209end 210