Commit 1a3d1170 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 7797cff0 af1ff616
/* eslint-disable no-restricted-globals */
import { logger } from '@rails/actioncable';
// This is based on https://github.com/rails/rails/blob/5a477890c809d4a17dc0dede43c6b8cef81d8175/actioncable/app/javascript/action_cable/connection_monitor.js
// so that we can take advantage of the improved reconnection logic. We can remove this once we upgrade @rails/actioncable to a version that includes this.
// Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting
// revival reconnections if things go astray. Internal class, not intended for direct user manipulation.
const now = () => new Date().getTime();
const secondsSince = (time) => (now() - time) / 1000;
class ConnectionMonitor {
constructor(connection) {
this.visibilityDidChange = this.visibilityDidChange.bind(this);
this.connection = connection;
this.reconnectAttempts = 0;
}
start() {
if (!this.isRunning()) {
this.startedAt = now();
delete this.stoppedAt;
this.startPolling();
addEventListener('visibilitychange', this.visibilityDidChange);
logger.log(
`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`,
);
}
}
stop() {
if (this.isRunning()) {
this.stoppedAt = now();
this.stopPolling();
removeEventListener('visibilitychange', this.visibilityDidChange);
logger.log('ConnectionMonitor stopped');
}
}
isRunning() {
return this.startedAt && !this.stoppedAt;
}
recordPing() {
this.pingedAt = now();
}
recordConnect() {
this.reconnectAttempts = 0;
this.recordPing();
delete this.disconnectedAt;
logger.log('ConnectionMonitor recorded connect');
}
recordDisconnect() {
this.disconnectedAt = now();
logger.log('ConnectionMonitor recorded disconnect');
}
// Private
startPolling() {
this.stopPolling();
this.poll();
}
stopPolling() {
clearTimeout(this.pollTimeout);
}
poll() {
this.pollTimeout = setTimeout(() => {
this.reconnectIfStale();
this.poll();
}, this.getPollInterval());
}
getPollInterval() {
const { staleThreshold, reconnectionBackoffRate } = this.constructor;
const backoff = (1 + reconnectionBackoffRate) ** Math.min(this.reconnectAttempts, 10);
const jitterMax = this.reconnectAttempts === 0 ? 1.0 : reconnectionBackoffRate;
const jitter = jitterMax * Math.random();
return staleThreshold * 1000 * backoff * (1 + jitter);
}
reconnectIfStale() {
if (this.connectionIsStale()) {
logger.log(
`ConnectionMonitor detected stale connection. reconnectAttempts = ${
this.reconnectAttempts
}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${
this.constructor.staleThreshold
} s`,
);
this.reconnectAttempts += 1;
if (this.disconnectedRecently()) {
logger.log(
`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(
this.disconnectedAt,
)} s`,
);
} else {
logger.log('ConnectionMonitor reopening');
this.connection.reopen();
}
}
}
get refreshedAt() {
return this.pingedAt ? this.pingedAt : this.startedAt;
}
connectionIsStale() {
return secondsSince(this.refreshedAt) > this.constructor.staleThreshold;
}
disconnectedRecently() {
return (
this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold
);
}
visibilityDidChange() {
if (document.visibilityState === 'visible') {
setTimeout(() => {
if (this.connectionIsStale() || !this.connection.isOpen()) {
logger.log(
`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`,
);
this.connection.reopen();
}
}, 200);
}
}
}
ConnectionMonitor.staleThreshold = 6; // Server::Connections::BEAT_INTERVAL * 2 (missed two pings)
ConnectionMonitor.reconnectionBackoffRate = 0.15;
export default ConnectionMonitor;
import { createConsumer } from '@rails/actioncable';
import ConnectionMonitor from './actioncable_connection_monitor';
export default createConsumer();
const consumer = createConsumer();
if (consumer.connection) {
consumer.connection.monitor = new ConnectionMonitor(consumer.connection);
}
export default consumer;
......@@ -95,7 +95,12 @@ export default {
<p v-if="hasTags" class="build-detail-row" data-testid="job-tags">
<span class="font-weight-bold">{{ __('Tags:') }}</span>
<span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{ tag }}</span>
<span
v-for="(tag, i) in job.tags"
:key="i"
class="badge badge-pill badge-primary gl-badge sm"
>{{ tag }}</span
>
</p>
</div>
</template>
......@@ -23,17 +23,15 @@ function mountRemoveMemberModal() {
});
}
document.addEventListener('DOMContentLoaded', () => {
groupsSelect();
memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
initInviteMembersModal();
initInviteMembersTrigger();
groupsSelect();
memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
initInviteMembersModal();
initInviteMembersTrigger();
new Members(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new
});
new Members(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new
if (window.gon.features.vueProjectMembersList) {
const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
......
......@@ -94,8 +94,7 @@ class Projects::NotesController < Projects::ApplicationController
def create_rate_limit
key = :notes_create
return unless rate_limiter.throttled?(key, scope: [current_user])
return unless rate_limiter.throttled?(key, scope: [current_user], users_allowlist: rate_limit_users_allowlist)
rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user)
render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests
......@@ -104,4 +103,8 @@ class Projects::NotesController < Projects::ApplicationController
def rate_limiter
::Gitlab::ApplicationRateLimiter
end
def rate_limit_users_allowlist
Gitlab::CurrentSettings.current_application_settings.notes_create_limit_allowlist
end
end
......@@ -57,12 +57,18 @@ module Mutations
end
def verify_rate_limit!(current_user)
rate_limiter, key = ::Gitlab::ApplicationRateLimiter, :notes_create
return unless rate_limiter.throttled?(key, scope: [current_user])
return unless rate_limit_throttled?
raise Gitlab::Graphql::Errors::ResourceNotAvailable,
'This endpoint has been requested too many times. Try again later.'
end
def rate_limit_throttled?
rate_limiter = ::Gitlab::ApplicationRateLimiter
allowlist = Gitlab::CurrentSettings.current_application_settings.notes_create_limit_allowlist
rate_limiter.throttled?(:notes_create, scope: [current_user], users_allowlist: allowlist)
end
end
end
end
......
......@@ -329,6 +329,7 @@ module ApplicationSettingsHelper
:email_restrictions,
:issues_create_limit,
:notes_create_limit,
:notes_create_limit_allowlist_raw,
:raw_blob_request_limit,
:project_import_limit,
:project_export_limit,
......
......@@ -447,6 +447,10 @@ class ApplicationSetting < ApplicationRecord
validates :notes_create_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :notes_create_limit_allowlist,
length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
allow_nil: false
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
......
......@@ -93,7 +93,6 @@ module ApplicationSettingImplementation
import_sources: Settings.gitlab['import_sources'],
invisible_captcha_enabled: false,
issues_create_limit: 300,
notes_create_limit: 300,
local_markdown_version: 0,
login_recaptcha_protection_enabled: false,
max_artifacts_size: Settings.artifacts['max_size'],
......@@ -101,6 +100,8 @@ module ApplicationSettingImplementation
max_import_size: 0,
minimum_password_length: DEFAULT_MINIMUM_PASSWORD_LENGTH,
mirror_available: true,
notes_create_limit: 300,
notes_create_limit_allowlist: [],
notify_on_unknown_sign_in: true,
outbound_local_requests_whitelist: [],
password_authentication_enabled_for_git: true,
......@@ -270,6 +271,14 @@ module ApplicationSettingImplementation
self.protected_paths = strings_to_array(values)
end
def notes_create_limit_allowlist_raw
array_to_string(self.notes_create_limit_allowlist)
end
def notes_create_limit_allowlist_raw=(values)
self.notes_create_limit_allowlist = strings_to_array(values).map(&:downcase)
end
def asset_proxy_allowlist=(values)
values = strings_to_array(values) if values.is_a?(String)
......
......@@ -5,5 +5,8 @@
.form-group
= f.label :notes_create_limit, _('Max requests per minute per user'), class: 'label-bold'
= f.number_field :notes_create_limit, class: 'form-control gl-form-input'
.form-group
= f.label :notes_create_limit_allowlist, _('List of users to be excluded from the limit'), class: 'label-bold'
= f.text_area :notes_create_limit_allowlist_raw, placeholder: 'username1, username2', class: 'form-control gl-form-input', rows: 5
= f.submit _('Save changes'), class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
---
title: Add an allowlist to exclude users from the rate limit on notes creation
merge_request: 53866
author:
type: added
---
title: Apply new GitLab UI for badge in job page sidebar
merge_request: 53386
author: Yogi (@yo)
type: other
# frozen_string_literal: true
class AddNotesCreateLimitAllowlistToApplicationSettings < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :application_settings, :notes_create_limit_allowlist, :text, array: true, default: [], null: false
end
end
e1bd58eeaf63caf473680a8c4b7269cc63e7c0d6e8d4e71636608e10c9731c85
\ No newline at end of file
......@@ -9414,6 +9414,7 @@ CREATE TABLE application_settings (
asset_proxy_allowlist text,
keep_latest_artifact boolean DEFAULT true NOT NULL,
notes_create_limit integer DEFAULT 300 NOT NULL,
notes_create_limit_allowlist text[] DEFAULT '{}'::text[] NOT NULL,
CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)),
CONSTRAINT app_settings_registry_exp_policies_worker_capacity_positive CHECK ((container_registry_expiration_policies_worker_capacity >= 0)),
CONSTRAINT check_17d9558205 CHECK ((char_length((kroki_url)::text) <= 1024)),
......
......@@ -10,8 +10,9 @@ type: reference
This is the GitLab Support Team's collection of information regarding the GitLab Rails
console, for use while troubleshooting. It is listed here for transparency,
and it may be useful for users with experience with these tools. If you are currently
having an issue with GitLab, it is highly recommended that you check your
[support options](https://about.gitlab.com/support/) first, before attempting to use
having an issue with GitLab, it is highly recommended that you first check
our guide on [navigating our Rails console](navigating_gitlab_via_rails_console.md),
and your [support options](https://about.gitlab.com/support/), before attempting to use
this information.
WARNING:
......
......@@ -194,10 +194,7 @@ To do this:
## Optional enforcement of Personal Access Token expiry **(ULTIMATE SELF)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/214723) in GitLab Ultimate 13.1.
> - It is deployed behind a feature flag, disabled by default.
> - It is disabled on GitLab.com.
> - It is not recommended for production use.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-optional-enforcement-of-personal-access-token-expiry-feature). **(FREE SELF)**
> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/296881) in GitLab 13.9.
GitLab administrators can choose to prevent personal access tokens from expiring
automatically. The tokens are usable after the expiry date, unless they are revoked explicitly.
......@@ -208,23 +205,6 @@ To do this:
1. Expand the **Account and limit** section.
1. Uncheck the **Enforce personal access token expiration** checkbox.
### Enable or disable optional enforcement of Personal Access Token expiry Feature **(FREE SELF)**
Optional Enforcement of Personal Access Token Expiry is deployed behind a feature flag and is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md) can enable it for your instance from the [rails console](../../../administration/feature_flags.md#start-the-gitlab-rails-console).
To enable it:
```ruby
Feature.enable(:enforce_pat_expiration)
```
To disable it:
```ruby
Feature.disable(:enforce_pat_expiration)
```
## Disabling user profile name changes **(PREMIUM SELF)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/24605) in GitLab 12.7.
......
......@@ -47,8 +47,7 @@ module EE
end
def enforce_pat_expiration_feature_available?
License.feature_available?(:enforce_pat_expiration) &&
::Feature.enabled?(:enforce_pat_expiration, type: :licensed, default_enabled: false)
License.feature_available?(:enforce_personal_access_token_expiration)
end
end
......
......@@ -144,7 +144,7 @@ class License < ApplicationRecord
dast
dependency_scanning
devops_adoption
enforce_pat_expiration
enforce_personal_access_token_expiration
enforce_ssh_key_expiration
enterprise_templates
environment_alerts
......
---
title: Optional enforcement of PAT expiration (feature flag removed)
merge_request: 53660
author:
type: added
......@@ -12,7 +12,7 @@ RSpec.describe Profiles::PersonalAccessTokensController do
before do
sign_in(user)
stub_licensed_features(enforce_pat_expiration: licensed)
stub_licensed_features(enforce_personal_access_token_expiration: licensed)
stub_application_setting(enforce_pat_expiration: application_setting)
end
......
......@@ -156,7 +156,7 @@ RSpec.describe PersonalAccessTokensHelper do
describe '#enforce_pat_expiration_feature_available?' do
subject { helper.enforce_pat_expiration_feature_available? }
let(:feature) { :enforce_pat_expiration }
let(:feature) { :enforce_personal_access_token_expiration }
it_behaves_like 'feature availability'
end
......
......@@ -220,7 +220,7 @@ RSpec.describe PersonalAccessToken do
with_them do
before do
stub_licensed_features(enforce_pat_expiration: licensed)
stub_licensed_features(enforce_personal_access_token_expiration: licensed)
stub_application_setting(enforce_pat_expiration: application_setting)
end
......@@ -247,17 +247,14 @@ RSpec.describe PersonalAccessToken do
subject { described_class.enforce_pat_expiration_feature_available? }
where(:feature_flag, :licensed, :result) do
true | true | true
true | false | false
false | true | false
false | false | false
where(:licensed, :result) do
true | true
false | false
end
with_them do
before do
stub_feature_flags(enforce_pat_expiration: feature_flag)
stub_licensed_features(enforce_pat_expiration: licensed)
stub_licensed_features(enforce_personal_access_token_expiration: licensed)
end
it { expect(subject).to be result }
......
......@@ -65,7 +65,7 @@ RSpec.describe PersonalAccessTokens::RevokeInvalidTokens do
with_them do
before do
stub_licensed_features(enforce_pat_expiration: licensed)
stub_licensed_features(enforce_personal_access_token_expiration: licensed)
stub_application_setting(enforce_pat_expiration: application_setting)
it_behaves_like behavior
......
......@@ -3,8 +3,8 @@
module API
module Helpers
module RateLimiter
def check_rate_limit!(key, scope)
if rate_limiter.throttled?(key, scope: scope)
def check_rate_limit!(key, scope, users_allowlist = nil)
if rate_limiter.throttled?(key, scope: scope, users_allowlist: users_allowlist)
log_request(key)
render_exceeded_limit_error!
end
......
......@@ -73,7 +73,9 @@ module API
optional :created_at, type: String, desc: 'The creation date of the note'
end
post ":id/#{noteables_str}/:noteable_id/notes", feature_category: feature_category do
check_rate_limit! :notes_create, [current_user]
allowlist =
Gitlab::CurrentSettings.current_application_settings.notes_create_limit_allowlist
check_rate_limit! :notes_create, [current_user], allowlist
noteable = find_noteable(noteable_type, params[:noteable_id])
opts = {
......
......@@ -47,15 +47,17 @@ module Gitlab
# @option scope [Array<ActiveRecord>] Array of ActiveRecord models to scope throttling to a specific request (e.g. per user per project)
# @option threshold [Integer] Optional threshold value to override default one registered in `.rate_limits`
# @option interval [Integer] Optional interval value to override default one registered in `.rate_limits`
# @option users_allowlist [Array<String>] Optional list of usernames to excepted from the limit. This param will only be functional if Scope includes a current user.
#
# @return [Boolean] Whether or not a request should be throttled
def throttled?(key, scope: nil, interval: nil, threshold: nil)
def throttled?(key, **options)
return unless rate_limits[key]
threshold_value = threshold || threshold(key)
return if scoped_user_in_allowlist?(options)
threshold_value = options[:threshold] || threshold(key)
threshold_value > 0 &&
increment(key, scope, interval) > threshold_value
increment(key, options[:scope], options[:interval]) > threshold_value
end
# Increments the given cache key and increments the value by 1 with the
......@@ -141,6 +143,15 @@ module Gitlab
def application_settings
Gitlab::CurrentSettings.current_application_settings
end
def scoped_user_in_allowlist?(options)
return unless options[:users_allowlist].present?
scoped_user = [options[:scope]].flatten.find { |s| s.is_a?(User) }
return unless scoped_user
scoped_user.username.downcase.in?(options[:users_allowlist])
end
end
end
end
......@@ -17664,6 +17664,9 @@ msgstr ""
msgid "List of all merge commits"
msgstr ""
msgid "List of users to be excluded from the limit"
msgstr ""
msgid "List options"
msgstr ""
......
......@@ -730,11 +730,11 @@ RSpec.describe Projects::NotesController do
context 'when the endpoint receives requests above the limit' do
before do
stub_application_setting(notes_create_limit: 5)
stub_application_setting(notes_create_limit: 3)
end
it 'prevents from creating more notes', :request_store do
5.times { create! }
3.times { create! }
expect { create! }
.to change { Gitlab::GitalyClient.get_request_count }.by(0)
......@@ -760,7 +760,16 @@ RSpec.describe Projects::NotesController do
project.add_developer(user)
sign_in(user)
6.times { create! }
4.times { create! }
end
it 'allows user in allow-list to create notes, even if the case is different' do
user.update_attribute(:username, user.username.titleize)
stub_application_setting(notes_create_limit_allowlist: ["#{user.username.downcase}"])
3.times { create! }
create!
expect(response).to have_gitlab_http_status(:found)
end
end
end
......
import ConnectionMonitor from '~/actioncable_connection_monitor';
describe('ConnectionMonitor', () => {
let monitor;
beforeEach(() => {
monitor = new ConnectionMonitor({});
});
describe('#getPollInterval', () => {
beforeEach(() => {
Math.originalRandom = Math.random;
});
afterEach(() => {
Math.random = Math.originalRandom;
});
const { staleThreshold, reconnectionBackoffRate } = ConnectionMonitor;
const backoffFactor = 1 + reconnectionBackoffRate;
const ms = 1000;
it('uses exponential backoff', () => {
Math.random = () => 0;
monitor.reconnectAttempts = 0;
expect(monitor.getPollInterval()).toEqual(staleThreshold * ms);
monitor.reconnectAttempts = 1;
expect(monitor.getPollInterval()).toEqual(staleThreshold * backoffFactor * ms);
monitor.reconnectAttempts = 2;
expect(monitor.getPollInterval()).toEqual(
staleThreshold * backoffFactor * backoffFactor * ms,
);
});
it('caps exponential backoff after some number of reconnection attempts', () => {
Math.random = () => 0;
monitor.reconnectAttempts = 42;
const cappedPollInterval = monitor.getPollInterval();
monitor.reconnectAttempts = 9001;
expect(monitor.getPollInterval()).toEqual(cappedPollInterval);
});
it('uses 100% jitter when 0 reconnection attempts', () => {
Math.random = () => 0;
expect(monitor.getPollInterval()).toEqual(staleThreshold * ms);
Math.random = () => 0.5;
expect(monitor.getPollInterval()).toEqual(staleThreshold * 1.5 * ms);
});
it('uses reconnectionBackoffRate for jitter when >0 reconnection attempts', () => {
monitor.reconnectAttempts = 1;
Math.random = () => 0.25;
expect(monitor.getPollInterval()).toEqual(
staleThreshold * backoffFactor * (1 + reconnectionBackoffRate * 0.25) * ms,
);
Math.random = () => 0.5;
expect(monitor.getPollInterval()).toEqual(
staleThreshold * backoffFactor * (1 + reconnectionBackoffRate * 0.5) * ms,
);
});
it('applies jitter after capped exponential backoff', () => {
monitor.reconnectAttempts = 9001;
Math.random = () => 0;
const withoutJitter = monitor.getPollInterval();
Math.random = () => 0.5;
const withJitter = monitor.getPollInterval();
expect(withJitter).toBeGreaterThan(withoutJitter);
});
});
});
......@@ -120,6 +120,15 @@ RSpec.describe ApplicationSetting do
it { is_expected.not_to allow_value(5.5).for(:notes_create_limit) }
it { is_expected.not_to allow_value(-2).for(:notes_create_limit) }
def many_usernames(num = 100)
Array.new(num) { |i| "username#{i}" }
end
it { is_expected.to allow_value(many_usernames(100)).for(:notes_create_limit_allowlist) }
it { is_expected.not_to allow_value(many_usernames(101)).for(:notes_create_limit_allowlist) }
it { is_expected.not_to allow_value(nil).for(:notes_create_limit_allowlist) }
it { is_expected.to allow_value([]).for(:notes_create_limit_allowlist) }
context 'help_page_documentation_base_url validations' do
it { is_expected.to allow_value(nil).for(:help_page_documentation_base_url) }
it { is_expected.to allow_value('https://docs.gitlab.com').for(:help_page_documentation_base_url) }
......
......@@ -74,4 +74,12 @@ RSpec.shared_examples 'a Note mutation when there are rate limit validation erro
it_behaves_like 'a Note mutation that does not create a Note'
it_behaves_like 'a mutation that returns top-level errors',
errors: ['This endpoint has been requested too many times. Try again later.']
context 'when the user is in the allowlist' do
before do
stub_application_setting(notes_create_limit_allowlist: ["#{current_user.username}"])
end
it_behaves_like 'a Note mutation that creates a Note'
end
end
......@@ -127,6 +127,12 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
end
describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes" do
let(:params) { { body: 'hi!' } }
subject do
post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: params
end
it "creates a new note" do
post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: { body: 'hi!' }
......@@ -277,15 +283,25 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
context 'when request exceeds the rate limit' do
before do
allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true)
stub_application_setting(notes_create_limit: 1)
allow(::Gitlab::ApplicationRateLimiter).to receive(:increment).and_return(2)
end
it 'prevents users from creating more notes' do
post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: { body: 'hi!' }
it 'prevents user from creating more notes' do
subject
expect(response).to have_gitlab_http_status(:too_many_requests)
expect(json_response['message']['error']).to eq('This endpoint has been requested too many times. Try again later.')
end
it 'allows user in allow-list to create notes' do
stub_application_setting(notes_create_limit_allowlist: ["#{user.username}"])
subject
expect(response).to have_gitlab_http_status(:created)
expect(json_response['body']).to eq('hi!')
expect(json_response['author']['username']).to eq(user.username)
end
end
end
......
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