Commit bbbde827 authored by Jan Provaznik's avatar Jan Provaznik

Merge branch '10io-nuget-metadata-service' into 'master'

Nuget - metadata service endpoints

See merge request gitlab-org/gitlab!22524
parents 3ec099a8 16472b24
# frozen_string_literal: true
module Packages
module Nuget
class PackageFinder
MAX_PACKAGES_COUNT = 50
def initialize(project, package_name:, package_version: nil)
@project = project
@package_name = package_name
@package_version = package_version
end
def execute
packages.limit_recent(MAX_PACKAGES_COUNT)
end
private
def packages
result = @project.packages
.nuget
.with_name(@package_name)
result = result.with_version(@package_version) if @package_version.present?
result
end
end
end
end
......@@ -47,6 +47,7 @@ class Packages::Package < ApplicationRecord
end
scope :preload_files, -> { preload(:package_files) }
scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) }
scope :limit_recent, ->(limit) { order_created_desc.limit(limit) }
# Sorting
scope :order_created, -> { reorder('created_at ASC') }
......
# frozen_string_literal: true
module Packages
module Nuget
class PackageMetadataPresenter
include API::Helpers::Packages::Nuget::MetadataPresenterHelpers
def initialize(package)
@package = package
end
def json_url
json_url_for(@package)
end
def archive_url
archive_url_for(@package)
end
def catalog_entry
catalog_entry_for(@package)
end
end
end
end
# frozen_string_literal: true
module Packages
module Nuget
class PackagesMetadataPresenter
include API::Helpers::Packages::Nuget::MetadataPresenterHelpers
include Gitlab::Utils::StrongMemoize
attr_reader :packages
COUNT = 1.freeze
def initialize(packages)
@packages = packages
end
def count
COUNT
end
def items
[summary]
end
private
def summary
{
json_url: json_url,
lower_version: lower_version,
upper_version: upper_version,
packages_count: @packages.count,
packages: @packages.map { |pkg| metadata_for(pkg) }
}
end
def metadata_for(package)
{
json_url: json_url_for(package),
archive_url: archive_url_for(package),
catalog_entry: catalog_entry_for(package)
}
end
def json_url
json_url_for(@packages.first)
end
def lower_version
sorted_versions.first
end
def upper_version
sorted_versions.last
end
def sorted_versions
strong_memoize(:sorted_versions) do
versions = packages.map(&:version).compact
VersionSorter.sort(versions)
end
end
end
end
end
......@@ -56,13 +56,28 @@ module Packages
full_path = case service_type
when :download
"#{base_path}/download" # TODO NUGET API: replace with grape path helper when download endpoint is implemented
api_v4_projects_packages_nuget_download_package_name_package_version_package_filename_path(
{
id: project.id,
package_name: nil,
package_version: nil,
package_filename: nil
},
true
)
when :search
"#{base_path}/query" # TODO NUGET API: replace with grape path helper when query endpoint is implemented
"#{base_path}/query"
when :metadata
"#{base_path}/metadata" # TODO NUGET API: replace with grape path helper when metadata endpoint is implemented
api_v4_projects_packages_nuget_metadata_package_name_package_version_path(
{
id: project.id,
package_name: nil,
package_version: nil
},
true
)
when :publish
base_path # TODO NUGET API: replace with grape path helper when publish endpoint is implemented
base_path
end
expose_url(full_path)
......
# frozen_string_literal: true
module API
module Helpers
module Packages
module Nuget
module MetadataPresenterHelpers
include ::API::Helpers::RelatedResourcesHelpers
include ::API::Helpers::PackagesHelpers
BLANK_STRING = ''
EMPTY_ARRAY = [].freeze
private
def json_url_for(package)
path = api_v4_projects_packages_nuget_metadata_package_name_package_version_path(
{
id: package.project.id,
package_name: package.name,
package_version: package.version,
format: '.json'
},
true
)
expose_url(path)
end
def archive_url_for(package)
path = api_v4_projects_packages_nuget_download_package_name_package_version_package_filename_path(
{
id: package.project.id,
package_name: package.name,
package_version: package.version,
package_filename: package.package_files.last&.file_name
},
true
)
expose_url(path)
end
def catalog_entry_for(package)
{
json_url: json_url_for(package),
authors: BLANK_STRING,
dependencies: EMPTY_ARRAY,
package_name: package.name,
package_version: package.version,
archive_url: archive_url_for(package),
summary: BLANK_STRING
}
end
def base_path_for(package)
api_v4_projects_packages_nuget_path(id: package.project.id)
end
end
end
end
end
end
......@@ -84,7 +84,7 @@ module API
end
# https://docs.microsoft.com/en-us/nuget/api/package-publish-resource
desc 'The NuGet Package Content endpoint' do
desc 'The NuGet Package Publish endpoint' do
detail 'This feature was introduced in GitLab 12.6'
end
params do
......@@ -118,6 +118,64 @@ module API
put 'authorize' do
authorize_workhorse!(subject: authorized_user_project, has_length: false)
end
params do
requires :package_name, type: String, desc: 'The NuGet package name', regexp: API::NO_SLASH_URL_PART_REGEX
end
namespace '/metadata/*package_name' do
before do
authorize_read_package!(authorized_user_project)
end
# https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource
desc 'The NuGet Metadata Service - Package name level' do
detail 'This feature was introduced in GitLab 12.8'
end
get 'index', format: :json do
packages = ::Packages::Nuget::PackageFinder.new(authorized_user_project, package_name: params[:package_name])
.execute
not_found!('Packages') unless packages.exists?
present ::Packages::Nuget::PackagesMetadataPresenter.new(packages),
with: EE::API::Entities::Nuget::PackagesMetadata
end
desc 'The NuGet Metadata Service - Package name and version level' do
detail 'This feature was introduced in GitLab 12.8'
end
params do
requires :package_version, type: String, desc: 'The NuGet package version', regexp: API::NO_SLASH_URL_PART_REGEX
end
get '*package_version', format: :json do
package = ::Packages::Nuget::PackageFinder
.new(authorized_user_project, package_name: params[:package_name], package_version: params[:package_version])
.execute
.first
not_found!('Package') unless package
present ::Packages::Nuget::PackageMetadataPresenter.new(package),
with: EE::API::Entities::Nuget::PackageMetadata
end
end
# https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource
desc 'The NuGet Content Service' do
detail 'This feature was introduced in GitLab 12.8'
end
params do
requires :package_name, type: String, desc: 'The NuGet package name', regexp: API::NO_SLASH_URL_PART_REGEX
requires :package_version, type: String, desc: 'The NuGet package version', regexp: API::NO_SLASH_URL_PART_REGEX
end
namespace '/download/*package_name/*package_version' do
params do
requires :package_filename, type: String, desc: 'The NuGet package filename', regexp: API::NO_SLASH_URL_PART_REGEX
end
get '*package_filename' do
not_found!('package not found') # TODO NUGET API: not implemented yet.
end
end
end
end
end
......
......@@ -878,6 +878,35 @@ module EE
expose :version
expose :resources
end
class PackageMetadataCatalogEntry < Grape::Entity
expose :json_url, as: :@id
expose :authors
expose :dependencies, as: :dependencyGroups
expose :package_name, as: :id
expose :package_version, as: :version
expose :archive_url, as: :packageContent
expose :summary
end
class PackageMetadata < Grape::Entity
expose :json_url, as: :@id
expose :archive_url, as: :packageContent
expose :catalog_entry, as: :catalogEntry, using: EE::API::Entities::Nuget::PackageMetadataCatalogEntry
end
class PackagesMetadataItem < Grape::Entity
expose :json_url, as: :@id
expose :lower_version, as: :lower
expose :upper_version, as: :upper
expose :packages_count, as: :count
expose :packages, as: :items, using: EE::API::Entities::Nuget::PackageMetadata
end
class PackagesMetadata < Grape::Entity
expose :count
expose :items, using: EE::API::Entities::Nuget::PackagesMetadataItem
end
end
class NpmPackage < Grape::Entity
......
......@@ -38,7 +38,7 @@ FactoryBot.define do
factory :nuget_package do
sequence(:name) { |n| "NugetPackage#{n}"}
version { '1.0.0' }
sequence(:version) { |n| "1.0.#{n}" }
package_type { :nuget }
after :create do |package|
......
# frozen_string_literal: true
require 'spec_helper'
describe Packages::Nuget::PackageFinder do
let_it_be(:package1) { create(:nuget_package) }
let_it_be(:package2) { create(:nuget_package, name: package1.name, version: '2.0.0', project: package1.project) }
let_it_be(:project) { package1.project }
let(:package_name) { package1.name }
let(:package_version) { nil }
describe '#execute!' do
subject { described_class.new(project, package_name: package_name, package_version: package_version).execute }
it { is_expected.to match_array([package1, package2]) }
context 'with unknown package name' do
let(:package_name) { 'foobar' }
it { is_expected.to be_empty }
end
context 'with valid version' do
let(:package_version) { '2.0.0' }
it { is_expected.to match_array([package2]) }
end
context 'with unknown version' do
let(:package_version) { 'foobar' }
it { is_expected.to be_empty }
end
end
end
{
"type": "object",
"required": ["@id", "packageContent", "catalogEntry"],
"properties": {
"@id": { "type": "string" },
"packageContent": { "type": "string" },
"catalogEntry": {
"type": "object",
"required": ["@id", "authors", "dependencyGroups", "id", "packageContent", "summary", "version"],
"properties": {
"@id": { "type": "string" },
"authors": { "const": "" },
"dependencyGroups": { "const": [] },
"id": { "type": "string" },
"packageContent": { "type": "string" },
"summary": { "const": "" },
"version": { "type": "string" }
}
}
}
}
{
"type": "object",
"required": ["count", "items"],
"properties": {
"count": { "const": 0 },
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["lower", "upper", "count", "items"],
"properties": {
"lower": { "type": "string" },
"upper": { "type": "string" },
"count": { "type": "integer" },
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["@id", "packageContent", "catalogEntry"],
"properties": {
"@id": { "type": "string" },
"packageContent": { "type": "string" },
"catalogEntry": {
"type": "object",
"required": ["@id", "authors", "dependencyGroups", "id", "packageContent", "summary", "version"],
"properties": {
"@id": { "type": "string" },
"authors": { "const": "" },
"dependencyGroups": { "const": [] },
"id": { "type": "string" },
"packageContent": { "type": "string" },
"summary": { "const": "" },
"version": { "type": "string" }
}
}
}
}
}
}
}
}
}
}
......@@ -155,4 +155,14 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.to eq([package2, package3]) }
end
end
describe '.limit_recent' do
let!(:package1) { create(:nuget_package) }
let!(:package2) { create(:nuget_package) }
let!(:package3) { create(:nuget_package) }
subject { described_class.limit_recent(2) }
it { is_expected.to match_array([package3, package2]) }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Packages::Nuget::PackageMetadataPresenter do
let_it_be(:package) { create(:nuget_package) }
let_it_be(:presenter) { described_class.new(package) }
describe '#json_url' do
let_it_be(:expected_suffix) { "/api/v4/projects/#{package.project_id}/packages/nuget/metadata/#{package.name}/#{package.version}.json" }
subject { presenter.json_url }
it { is_expected.to end_with(expected_suffix) }
end
describe '#archive_url' do
let_it_be(:expected_suffix) { "/api/v4/projects/#{package.project_id}/packages/nuget/download/#{package.name}/#{package.version}/#{package.package_files.last.file_name}" }
subject { presenter.archive_url }
it { is_expected.to end_with(expected_suffix) }
end
describe '#catalog_entry' do
subject { presenter.catalog_entry }
it 'returns an entry structure' do
entry = subject
expect(entry).to be_a Hash
%i[json_url archive_url].each { |field| expect(entry[field]).not_to be_blank }
%i[authors summary].each { |field| expect(entry[field]).to be_blank }
expect(entry[:dependencies]).to eq []
expect(entry[:package_name]).to eq package.name
expect(entry[:package_version]).to eq package.version
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Packages::Nuget::PackagesMetadataPresenter do
let_it_be(:packages) { create_list(:nuget_package, 5, name: 'Dummy.Package') }
let_it_be(:presenter) { described_class.new(packages) }
describe '#count' do
subject { presenter.count }
it {is_expected.to eq 1}
end
describe '#items' do
subject { presenter.items }
it 'returns an array' do
items = subject
expect(items).to be_a Array
expect(items.size).to eq 1
end
it 'returns a summary structure' do
item = subject.first
expect(item).to be_a Hash
%i[json_url lower_version upper_version].each { |field| expect(item[field]).not_to be_blank }
expect(item[:packages_count]).to eq packages.count
expect(item[:packages]).to be_a Array
expect(item[:packages].size).to eq packages.count
end
it 'returns the catalog entries' do
item = subject.first
item[:packages].each do |pkg|
expect(pkg).to be_a Hash
%i[json_url archive_url catalog_entry].each { |field| expect(pkg[field]).not_to be_blank }
catalog_entry = pkg[:catalog_entry]
%i[json_url archive_url package_name package_version].each { |field| expect(catalog_entry[field]).not_to be_blank }
%i[authors summary].each { |field| expect(catalog_entry[field]).to be_blank }
expect(catalog_entry[:dependencies]).to eq []
end
end
end
end
......@@ -71,22 +71,10 @@ describe API::NugetPackages do
it_behaves_like 'rejects nuget access with invalid project id'
end
context 'with feature flag disabled' do
before do
stub_feature_flags(nuget_package_registry: { enabled: false, thing: project })
end
it_behaves_like 'rejects nuget packages access', :anonymous, :not_found
end
it_behaves_like 'rejects nuget packages access with feature flag disabled'
end
context 'with packages features disabled' do
before do
stub_licensed_features(packages: false)
end
it_behaves_like 'rejects nuget packages access', :anonymous, :forbidden
end
it_behaves_like 'rejects nuget packages access with packages features disabled'
end
describe 'PUT /api/v4/projects/:id/packages/nuget/authorize' do
......@@ -153,22 +141,10 @@ describe API::NugetPackages do
it_behaves_like 'rejects nuget access with invalid project id'
end
context 'with feature flag disabled' do
before do
stub_feature_flags(nuget_package_registry: { enabled: false, thing: project })
end
it_behaves_like 'rejects nuget packages access', :anonymous, :not_found
end
it_behaves_like 'rejects nuget packages access with feature flag disabled'
end
context 'with packages features disabled' do
before do
stub_licensed_features(packages: false)
end
it_behaves_like 'rejects nuget packages access', :anonymous, :forbidden
end
it_behaves_like 'rejects nuget packages access with packages features disabled'
end
describe 'PUT /api/v4/projects/:id/packages/nuget' do
......@@ -245,21 +221,141 @@ describe API::NugetPackages do
it_behaves_like 'rejects nuget access with invalid project id'
end
context 'with feature flag disabled' do
it_behaves_like 'rejects nuget packages access with feature flag disabled'
end
it_behaves_like 'rejects nuget packages access with packages features disabled'
end
describe 'GET /api/v4/projects/:id/packages/nuget/metadata/*package_name/index' do
let_it_be(:package_name) { 'Dummy.Package' }
let_it_be(:packages) { create_list(:nuget_package, 5, name: package_name, project: project) }
let_it_be(:url) { "/projects/#{project.id}/packages/nuget/metadata/#{package_name}/index.json" }
subject { get api(url) }
context 'with packages features enabled' do
before do
stub_licensed_features(packages: true)
end
context 'with feature flag enabled' do
before do
stub_feature_flags(nuget_package_registry: { enabled: false, thing: project })
stub_feature_flags(nuget_package_registry: { enabled: true, thing: project })
end
it_behaves_like 'rejects nuget packages access', :anonymous, :not_found
context 'with valid project' do
using RSpec::Parameterized::TableSyntax
where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do
'PUBLIC' | :developer | true | true | 'process nuget metadata request at package name level' | :success
'PUBLIC' | :guest | true | true | 'process nuget metadata request at package name level' | :success
'PUBLIC' | :developer | true | false | 'process nuget metadata request at package name level' | :success
'PUBLIC' | :guest | true | false | 'process nuget metadata request at package name level' | :success
'PUBLIC' | :developer | false | true | 'process nuget metadata request at package name level' | :success
'PUBLIC' | :guest | false | true | 'process nuget metadata request at package name level' | :success
'PUBLIC' | :developer | false | false | 'process nuget metadata request at package name level' | :success
'PUBLIC' | :guest | false | false | 'process nuget metadata request at package name level' | :success
'PUBLIC' | :anonymous | false | true | 'process nuget metadata request at package name level' | :success
'PRIVATE' | :developer | true | true | 'process nuget metadata request at package name level' | :success
'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden
'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found
'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found
'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized
end
with_them do
let(:token) { user_token ? personal_access_token.token : 'wrong' }
let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) }
subject { get api(url), headers: headers }
before do
project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false))
end
after do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
end
it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member]
end
end
end
it_behaves_like 'rejects nuget packages access with feature flag disabled'
end
context 'with packages features disabled' do
it_behaves_like 'rejects nuget packages access with packages features disabled'
end
describe 'GET /api/v4/projects/:id/packages/nuget/metadata/*package_name/:package_version' do
let_it_be(:package_name) { 'Dummy.Package' }
let_it_be(:package) { create(:nuget_package, name: 'Dummy.Package', project: project) }
let_it_be(:url) { "/projects/#{project.id}/packages/nuget/metadata/#{package_name}/#{package.version}.json" }
subject { get api(url) }
context 'with packages features enabled' do
before do
stub_licensed_features(packages: false)
stub_licensed_features(packages: true)
end
context 'with feature flag enabled' do
before do
stub_feature_flags(nuget_package_registry: { enabled: true, thing: project })
end
context 'with valid project' do
using RSpec::Parameterized::TableSyntax
where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do
'PUBLIC' | :developer | true | true | 'process nuget metadata request at package name and package version level' | :success
'PUBLIC' | :guest | true | true | 'process nuget metadata request at package name and package version level' | :success
'PUBLIC' | :developer | true | false | 'process nuget metadata request at package name and package version level' | :success
'PUBLIC' | :guest | true | false | 'process nuget metadata request at package name and package version level' | :success
'PUBLIC' | :developer | false | true | 'process nuget metadata request at package name and package version level' | :success
'PUBLIC' | :guest | false | true | 'process nuget metadata request at package name and package version level' | :success
'PUBLIC' | :developer | false | false | 'process nuget metadata request at package name and package version level' | :success
'PUBLIC' | :guest | false | false | 'process nuget metadata request at package name and package version level' | :success
'PUBLIC' | :anonymous | false | true | 'process nuget metadata request at package name and package version level' | :success
'PRIVATE' | :developer | true | true | 'process nuget metadata request at package name and package version level' | :success
'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden
'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found
'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found
'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized
end
with_them do
let(:token) { user_token ? personal_access_token.token : 'wrong' }
let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) }
subject { get api(url), headers: headers }
before do
project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false))
end
after do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
end
it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member]
end
end
end
it_behaves_like 'rejects nuget packages access', :anonymous, :forbidden
it_behaves_like 'rejects nuget packages access with feature flag disabled'
end
it_behaves_like 'rejects nuget packages access with packages features disabled'
end
end
......@@ -18,6 +18,26 @@ RSpec.shared_examples 'rejects nuget packages access' do |user_type, status, add
end
end
RSpec.shared_examples 'rejects nuget packages access with packages features disabled' do
context 'with packages features disabled' do
before do
stub_licensed_features(packages: false)
end
it_behaves_like 'rejects nuget packages access', :anonymous, :forbidden
end
end
RSpec.shared_examples 'rejects nuget packages access with feature flag disabled' do
context 'with feature flag disabled' do
before do
stub_feature_flags(nuget_package_registry: { enabled: false, thing: project })
end
it_behaves_like 'rejects nuget packages access', :anonymous, :not_found
end
end
RSpec.shared_examples 'process nuget service index request' do |user_type, status, add_member = true|
context "for user type #{user_type}" do
before do
......@@ -47,6 +67,64 @@ RSpec.shared_examples 'process nuget service index request' do |user_type, statu
end
end
RSpec.shared_examples 'process nuget metadata request at package name level' do |user_type, status, add_member = true|
context "for user type #{user_type}" do
before do
project.send("add_#{user_type}", user) if add_member && user_type != :anonymous
end
it_behaves_like 'returning response status', status
it 'returns a valid json response' do
subject
expect(response.content_type.to_s).to eq('application/json')
expect(json_response).to be_a(Hash)
end
it 'returns a valid nuget packages metadata json' do
subject
expect(json_response).to match_schema('public_api/v4/packages/nuget/packages_metadata', dir: 'ee')
end
context 'with invalid format' do
let(:url) { "/projects/#{project.id}/packages/nuget/metadata/#{package_name}/index.xls" }
it_behaves_like 'rejects nuget packages access', :anonymous, :not_found
end
end
end
RSpec.shared_examples 'process nuget metadata request at package name and package version level' do |user_type, status, add_member = true|
context "for user type #{user_type}" do
before do
project.send("add_#{user_type}", user) if add_member && user_type != :anonymous
end
it_behaves_like 'returning response status', status
it 'returns a valid json response' do
subject
expect(response.content_type.to_s).to eq('application/json')
expect(json_response).to be_a(Hash)
end
it 'returns a valid nuget package metadata json' do
subject
expect(json_response).to match_schema('public_api/v4/packages/nuget/package_metadata', dir: 'ee')
end
context 'with invalid format' do
let(:url) { "/projects/#{project.id}/packages/nuget/metadata/#{package_name}/#{package.version}.xls" }
it_behaves_like 'rejects nuget packages access', :anonymous, :not_found
end
end
end
RSpec.shared_examples 'process nuget workhorse authorization' do |user_type, status, add_member = true|
context "for user type #{user_type}" do
before do
......@@ -78,7 +156,7 @@ RSpec.shared_examples 'process nuget workhorse authorization' do |user_type, sta
end
RSpec.shared_examples 'process nuget upload' do |user_type, status, add_member = true|
shared_examples 'creates nuget package files' do
RSpec.shared_examples 'creates nuget package files' do
it 'creates package files' do
expect(::Packages::Nuget::ExtractionWorker).to receive(:perform_async).once
expect { subject }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment