# frozen_string_literal: true # NuGet Package Manager Client API # # These API endpoints are not meant to be consumed directly by users. They are # called by the NuGet package manager client when users run commands # like `nuget install` or `nuget push`. # # This is the project level API. module API class NugetProjectPackages < ::API::Base helpers ::API::Helpers::PackagesHelpers helpers ::API::Helpers::Packages::BasicAuthHelpers helpers ::API::Helpers::Packages::Nuget include ::API::Helpers::Authentication feature_category :package_registry PACKAGE_FILENAME = 'package.nupkg' SYMBOL_PACKAGE_FILENAME = 'package.snupkg' API_KEY_HEADER = 'X-Nuget-Apikey' default_format :json rescue_from ArgumentError do |e| render_api_error!(e.message, 400) end after_validation do require_packages_enabled! end helpers do include ::Gitlab::Utils::StrongMemoize params :file_params do requires :package, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)', documentation: { type: 'file' } end def project_or_group authorized_user_project(action: :read_package) end def project_or_group_without_auth find_project(params[:id]).presence || not_found! end strong_memoize_attr :project_or_group_without_auth def symbol_server_enabled? project_or_group_without_auth.namespace.package_settings.nuget_symbol_server_enabled end def snowplow_gitlab_standard_context { project: project_or_group, namespace: project_or_group.namespace } end def snowplow_gitlab_standard_context_without_auth { project: project_or_group_without_auth, namespace: project_or_group_without_auth.namespace } end def authorize_nuget_upload project = project_or_group authorize_workhorse!( subject: project, has_length: false, maximum_size: project.actual_limits.nuget_max_file_size ) end def temp_file_name(symbol_package) return ::Packages::Nuget::TEMPORARY_SYMBOL_PACKAGE_NAME if symbol_package ::Packages::Nuget::TEMPORARY_PACKAGE_NAME end def file_name(symbol_package) return SYMBOL_PACKAGE_FILENAME if symbol_package PACKAGE_FILENAME end def upload_nuget_package_file(symbol_package: false) authorize_upload!(project_or_group) if project_or_group.actual_limits.exceeded?(:nuget_max_file_size, params[:package].size) bad_request!('File is too large') end file_params = params.merge( file: params[:package], file_name: file_name(symbol_package), build: current_authenticated_job ) if !symbol_package && extracted_metadata.success? # Create or update package on the fly if nuspec file is extracted successfully, # otherwise fallback to the background job create_or_update_package(file_params) elsif symbol_package || extracted_metadata.cause.nuspec_extraction_failed? create_temp_package_and_enqueue_worker(file_params, symbol_package) else render_api_error!(extracted_metadata.message, extracted_metadata.reason) end end def create_or_update_package(file_params) response = Packages::Nuget::CreateOrUpdatePackageService .new(project_or_group, current_user, file_params.merge(nuspec_file_content: extracted_metadata.payload)) .execute render_api_error!(response.message, response.reason) if response.error? end def create_temp_package_and_enqueue_worker(file_params, symbol_package) response = ::Packages::Nuget::CreateTemporaryPackageService.new( project: project_or_group, user: current_user, params: { package_params: declared_params.merge( build: current_authenticated_job, name: temp_file_name(symbol_package) ), package_file_params: file_params } ).execute render_api_error!(response.message, response.reason) if response.error? end def extracted_metadata if params['package.remote_url'].present? ::Packages::Nuget::ExtractRemoteMetadataFileService.new(params['package.remote_url']).execute else # file on disk Zip::InputStream.open(params[:package]) do |zip| ::Packages::Nuget::ExtractMetadataFileService.new(zip).execute end end rescue ::Packages::Nuget::ExtractMetadataFileService::ExtractionError => e ServiceResponse.error(message: e.message, reason: :bad_request) end strong_memoize_attr :extracted_metadata def publish_package(symbol_package: false) upload_nuget_package_file(symbol_package: symbol_package) track_package_event( symbol_package ? 'push_symbol_package' : 'push_package', :nuget, **track_package_event_attrs ) created! rescue ObjectStorage::RemoteStoreError => e Gitlab::ErrorTracking.track_exception( e, extra: { file_name: params[:file_name], project_id: project_or_group.id } ) forbidden! end def required_permission :read_package end def format_filename(package) return "#{params[:package_filename]}.#{params[:format]}" if package.version == params[:package_version] return unless package.normalized_nuget_version == params[:package_version] "#{params[:package_filename].sub(params[:package_version], package.version)}.#{params[:format]}" end def present_odata_entry project = find_project(params[:project_id]) not_found! unless project env['api.format'] = :binary content_type 'application/xml; charset=utf-8' odata_entry = ::Packages::Nuget::OdataPackageEntryService .new(project, declared_params) .execute .payload present odata_entry end def track_package_event_attrs attrs = { category: 'API::NugetPackages', project: project_or_group, namespace: project_or_group.namespace } attrs[:feed] = 'v2' if request.path.include?('nuget/v2') attrs end end params do requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project', regexp: ::API::Concerns::Packages::Nuget::PrivateEndpoints::POSITIVE_INTEGER_REGEX end resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do namespace ':id/packages' do namespace '/nuget' do include ::API::Concerns::Packages::Nuget::PublicEndpoints end authenticate_with do |accept| accept.token_types( :personal_access_token_with_username, :deploy_token_with_username, :job_token_with_username ).sent_through(:http_basic_auth) end namespace '/nuget' do include ::API::Concerns::Packages::Nuget::PrivateEndpoints # https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource params do requires :package_name, type: String, desc: 'The NuGet package name', regexp: API::NO_SLASH_URL_PART_REGEX, documentation: { example: 'mynugetpkg.1.3.0.17.nupkg' } end namespace '/download/*package_name' do after_validation do authorize_read_package!(project_or_group) end desc 'The NuGet Content Service - index request' do detail 'This feature was introduced in GitLab 12.8' success code: 200, model: ::API::Entities::Nuget::PackagesVersions failure [ { code: 401, message: 'Unauthorized' }, { code: 403, message: 'Forbidden' }, { code: 404, message: 'Not Found' } ] tags %w[nuget_packages] end get 'index', format: :json, urgency: :low do present ::Packages::Nuget::PackagesVersionsPresenter.new(find_packages), with: ::API::Entities::Nuget::PackagesVersions end desc 'The NuGet Content Service - content request' do detail 'This feature was introduced in GitLab 12.8' success code: 200 failure [ { code: 401, message: 'Unauthorized' }, { code: 403, message: 'Forbidden' }, { code: 404, message: 'Not Found' } ] tags %w[nuget_packages] end params do requires :package_version, type: String, desc: 'The NuGet package version', regexp: API::NO_SLASH_URL_PART_REGEX, documentation: { example: '1.3.0.17' } requires :package_filename, type: String, desc: 'The NuGet package filename', regexp: API::NO_SLASH_URL_PART_REGEX, documentation: { example: 'mynugetpkg.1.3.0.17.nupkg' } end get '*package_version/*package_filename', format: [:nupkg, :snupkg], urgency: :low do package = find_package filename = format_filename(package) package_file = ::Packages::PackageFileFinder.new(package, filename, with_file_name_like: true) .execute not_found!('Package') unless package_file track_package_event( params[:format] == 'snupkg' ? 'pull_symbol_package' : 'pull_package', :nuget, **track_package_event_attrs ) # nuget and dotnet don't support 302 Moved status codes, supports_direct_download has to be set to false present_package_file!(package_file, supports_direct_download: false) end end end # To support an additional authentication option for publish/delete endpoints, # we redefine the `authenticate_with` method by combining the previous # authentication option with the new one. authenticate_with do |accept| accept.token_types( :personal_access_token_with_username, :deploy_token_with_username, :job_token_with_username ).sent_through(:http_basic_auth) accept.token_types(:personal_access_token, :deploy_token, :job_token) .sent_through(http_header: API_KEY_HEADER) end namespace '/nuget' do # https://docs.microsoft.com/en-us/nuget/api/package-publish-resource desc 'The NuGet V3 Feed Package Publish endpoint' do detail 'This feature was introduced in GitLab 12.6' success code: 201 failure [ { code: 400, message: 'Bad Request' }, { code: 401, message: 'Unauthorized' }, { code: 403, message: 'Forbidden' }, { code: 404, message: 'Not Found' } ] tags %w[nuget_packages] end params do use :file_params end put urgency: :low do publish_package end desc 'The NuGet Package Authorize endpoint' do detail 'This feature was introduced in GitLab 14.1' success code: 200 failure [ { code: 401, message: 'Unauthorized' }, { code: 403, message: 'Forbidden' }, { code: 404, message: 'Not Found' } ] tags %w[nuget_packages] end put 'authorize', urgency: :low do authorize_nuget_upload end # https://docs.microsoft.com/en-us/nuget/api/symbol-package-publish-resource desc 'The NuGet Symbol Package Publish endpoint' do detail 'This feature was introduced in GitLab 14.1' success code: 201 failure [ { code: 400, message: 'Bad Request' }, { code: 401, message: 'Unauthorized' }, { code: 403, message: 'Forbidden' }, { code: 404, message: 'Not Found' } ] tags %w[nuget_packages] end params do use :file_params end put 'symbolpackage', urgency: :low do publish_package(symbol_package: true) end desc 'The NuGet Symbol Package Authorize endpoint' do detail 'This feature was introduced in GitLab 14.1' success code: 200 failure [ { code: 401, message: 'Unauthorized' }, { code: 403, message: 'Forbidden' }, { code: 404, message: 'Not Found' } ] tags %w[nuget_packages] end put 'symbolpackage/authorize', urgency: :low do authorize_nuget_upload end desc 'The NuGet Package Delete endpoint' do detail 'This feature was introduced in GitLab 16.5' success code: 204 failure [ { code: 401, message: 'Unauthorized' }, { code: 403, message: 'Forbidden' }, { code: 404, message: 'Not Found' } ] tags %w[nuget_packages] end params do requires :package_name, type: String, allow_blank: false, desc: 'The NuGet package name', regexp: Gitlab::Regex.nuget_package_name_regex, documentation: { example: 'mynugetpkg' } requires :package_version, type: String, allow_blank: false, desc: 'The NuGet package version', regexp: Gitlab::Regex.nuget_version_regex, documentation: { example: '1.0.1' } end delete '*package_name/*package_version', format: false, urgency: :low do authorize_destroy_package!(project_or_group) destroy_conditionally!(find_package) do |package| ::Packages::MarkPackageForDestructionService.new(container: package, current_user: current_user).execute track_package_event( 'delete_package', :nuget, category: 'API::NugetPackages', project: package.project, namespace: package.project.namespace ) end end namespace '/v2' do desc 'The NuGet V2 Feed Package Publish endpoint' do detail 'This feature was introduced in GitLab 16.2' success code: 201 failure [ { code: 400, message: 'Bad Request' }, { code: 401, message: 'Unauthorized' }, { code: 403, message: 'Forbidden' }, { code: 404, message: 'Not Found' } ] tags %w[nuget_packages] end params do use :file_params end put do publish_package end desc 'The NuGet V2 Feed Package Authorize endpoint' do detail 'This feature was introduced in GitLab 16.2' success code: 200 failure [ { code: 401, message: 'Unauthorized' }, { code: 403, message: 'Forbidden' }, { code: 404, message: 'Not Found' } ] tags %w[nuget_packages] end put 'authorize', urgency: :low do authorize_nuget_upload end end end end end params do requires :project_id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project', regexp: ::API::Concerns::Packages::Nuget::PrivateEndpoints::POSITIVE_INTEGER_REGEX end resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do namespace ':project_id/packages/nuget/v2' do # https://joelverhagen.github.io/NuGetUndocs/?http#endpoint-find-packages-by-id desc 'The NuGet V2 Feed Find Packages by ID endpoint' do detail 'This feature was introduced in GitLab 16.4' success code: 200 failure [ { code: 404, message: 'Not Found' }, { code: 400, message: 'Bad Request' } ] tags %w[nuget_packages] end params do requires :id, as: :package_name, type: String, allow_blank: false, coerce_with: ->(val) { val.delete("'") }, desc: 'The NuGet package name', regexp: Gitlab::Regex.nuget_package_name_regex, documentation: { example: 'mynugetpkg' } end get 'FindPackagesById\(\)', urgency: :low do present_odata_entry end # https://joelverhagen.github.io/NuGetUndocs/?http#endpoint-enumerate-packages desc 'The NuGet V2 Feed Enumerate Packages endpoint' do detail 'This feature was introduced in GitLab 16.4' success code: 200 failure [ { code: 404, message: 'Not Found' }, { code: 400, message: 'Bad Request' } ] tags %w[nuget_packages] end params do requires :$filter, as: :package_name, type: String, allow_blank: false, coerce_with: ->(val) { val.match(/tolower\(Id\) eq '(.+?)'/)&.captures&.first }, desc: 'The NuGet package name', regexp: Gitlab::Regex.nuget_package_name_regex, documentation: { example: 'mynugetpkg' } end get 'Packages\(\)', urgency: :low do present_odata_entry end # https://joelverhagen.github.io/NuGetUndocs/?http#endpoint-get-a-single-package desc 'The NuGet V2 Feed Single Package Metadata endpoint' do detail 'This feature was introduced in GitLab 16.4' success code: 200 failure [ { code: 404, message: 'Not Found' }, { code: 400, message: 'Bad Request' } ] tags %w[nuget_packages] end params do requires :package_name, type: String, allow_blank: false, desc: 'The NuGet package name', regexp: Gitlab::Regex.nuget_package_name_regex, documentation: { example: 'mynugetpkg' } requires :package_version, type: String, allow_blank: false, desc: 'The NuGet package version', regexp: Gitlab::Regex.nuget_version_regex, documentation: { example: '1.3.0.17' } end get 'Packages\(Id=\'*package_name\',Version=\'*package_version\'\)', urgency: :low do present_odata_entry end end end end end