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