Commit b9bce284 authored by Fernando Arias's avatar Fernando Arias Committed by Frédéric Caplette

Corpus management validation logic

* Clientside and server side validations
parent 87734e88
......@@ -10,6 +10,7 @@ export default {
directives: {
GlModalDirective,
},
inject: ['canReadCorpus', 'canDestroyCorpus'],
props: {
corpus: {
type: Object,
......@@ -43,12 +44,22 @@ export default {
</script>
<template>
<span>
<gl-button class="gl-mr-2" icon="download" :href="downloadPath" />
<gl-button
v-if="canReadCorpus"
class="gl-mr-2"
icon="download"
:href="downloadPath"
:aria-label="__('Download')"
data-testid="download-corpus"
/>
<gl-button
v-if="canDestroyCorpus"
v-gl-modal-directive="directiveName"
icon="remove"
category="secondary"
variant="danger"
:aria-label="__('Delete')"
data-testid="destroy-corpus"
/>
<gl-modal
......
......@@ -27,7 +27,7 @@ export default {
return this.corpus.package.pipelines.nodes[0]?.ref;
},
latestJob() {
return `${this.jobURL} (${this.ref})`;
return `${this.jobUrl} (${this.ref})`;
},
name() {
return this.corpus.package.name;
......
<script>
import { GlLoadingIcon, GlLink, GlKeysetPagination } from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
import CorpusTable from 'ee/security_configuration/corpus_management/components/corpus_table.vue';
import CorpusUpload from 'ee/security_configuration/corpus_management/components/corpus_upload.vue';
import { s__, __ } from '~/locale';
......@@ -78,7 +79,8 @@ export default {
beforeCursor: null,
firstPageSize: this.$options.pageSize,
};
this.$apollo.queries.states.refetch();
this.$apollo.queries.states.setOptions({ fetchPolicy: fetchPolicies.NETWORK_ONLY });
this.$apollo.queries.states.setOptions({ fetchPolicy: fetchPolicies.CACHE_FIRST });
},
onDelete(id) {
return this.$apollo
......
......@@ -6,6 +6,8 @@ import addCorpusMutation from '../graphql/mutations/add_corpus.mutation.graphql'
import resetCorpus from '../graphql/mutations/reset_corpus.mutation.graphql';
import uploadCorpus from '../graphql/mutations/upload_corpus.mutation.graphql';
import getUploadState from '../graphql/queries/get_upload_state.query.graphql';
import uploadError from '../graphql/mutations/upload_error.mutation.graphql';
import { I18N, MAX_FILE_SIZE } from '../constants';
import CorpusUploadForm from './corpus_upload_form.vue';
export default {
......@@ -23,7 +25,7 @@ export default {
newUpload: s__('CorpusManagement|New upload'),
newCorpus: s__('CorpusMnagement|New corpus'),
},
inject: ['projectFullPath'],
inject: ['projectFullPath', 'canUploadCorpus'],
apollo: {
states: {
query: getUploadState,
......@@ -93,10 +95,17 @@ export default {
});
},
beginFileUpload({ name, files }) {
if (files[0].size >= MAX_FILE_SIZE) {
this.$apollo.mutate({
mutation: uploadError,
variables: { projectPath: this.projectFullPath, error: I18N.fileTooLarge },
});
} else {
this.$apollo.mutate({
mutation: uploadCorpus,
variables: { name, projectPath: this.projectFullPath, files },
});
}
},
},
};
......@@ -114,7 +123,9 @@ export default {
</div>
<gl-button
v-if="canUploadCorpus"
v-gl-modal-directive="$options.modal.modalId"
data-testid="new-corpus"
class="gl-mr-5 gl-ml-auto"
variant="confirm"
>
......
......@@ -28,10 +28,9 @@ export default {
},
i18n: {
corpusName: s__('CorpusManagement|Corpus name'),
corpusFile: s__('CorpusManagement|Corpus file'),
uploadButtonText: __('Choose File...'),
uploadMessage: s__(
'CorpusManagement|New corpus needs to be a upload in *.zip format. Maximum 5GB',
),
uploadMessage: s__('CorpusManagement|Corpus files must be in *.zip format. Maximum 5 GB'),
},
data() {
return {
......@@ -44,6 +43,15 @@ export default {
hasAttachment() {
return Boolean(this.attachmentName);
},
hasValidName() {
return !this.nameError;
},
hasValidFile() {
return !this.fileError;
},
isShowingUploadText() {
return this.hasValidFile && !this.isUploaded;
},
isShowingAttachmentName() {
return this.hasAttachment && !this.isLoading;
},
......@@ -51,11 +59,14 @@ export default {
return !this.isUploaded && !this.isUploading;
},
isUploading() {
return this.states?.uploadState.isUploading;
return this.states?.uploadState?.isUploading;
},
isUploaded() {
return this.progress === 100;
},
isUploadButtonEnabled() {
return !this.corpusName;
},
showUploadButton() {
return this.hasAttachment && !this.isUploading && !this.isUploaded;
},
......@@ -63,11 +74,17 @@ export default {
return !this.isUploaded;
},
progress() {
return this.states?.uploadState.progress;
return this.states?.uploadState?.progress;
},
progressText() {
return sprintf(__('Attaching File - %{progress}'), { progress: `${this.progress}%` });
},
nameError() {
return this.states?.uploadState?.errors.name;
},
fileError() {
return this.states?.uploadState?.errors.file;
},
},
beforeDestroy() {
this.resetAttachment();
......@@ -81,6 +98,7 @@ export default {
this.$refs.fileUpload.value = null;
this.attachmentName = '';
this.files = [];
this.$emit('resetCorpus');
},
cancelUpload() {
this.$emit('resetCorpus');
......@@ -94,6 +112,7 @@ export default {
onFileUploadChange(e) {
this.attachmentName = e.target.files[0].name;
this.files = e.target.files;
this.$emit('resetCorpus');
},
},
VALID_CORPUS_MIMETYPE,
......@@ -101,13 +120,21 @@ export default {
</script>
<template>
<gl-form>
<gl-form-group :label="$options.i18n.corpusName" label-size="sm" label-for="corpus-name">
<gl-form-group
:label="$options.i18n.corpusName"
label-size="sm"
label-for="corpus-name"
data-testid="corpus-name-group"
:invalid-feedback="nameError"
:state="hasValidName"
>
<gl-form-input-group>
<gl-form-input
id="corpus-name"
ref="input"
v-model="corpusName"
data-testid="corpus-name"
:state="hasValidName"
/>
<gl-button
......@@ -124,7 +151,14 @@ export default {
</gl-form-input-group>
</gl-form-group>
<gl-form-group :label="$options.i18n.corpusName" label-size="sm" label-for="corpus-file">
<gl-form-group
:label="$options.i18n.corpusFile"
label-size="sm"
label-for="corpus-file"
data-testid="corpus-file-group"
:invalid-feedback="fileError"
:state="hasValidFile"
>
<gl-button
v-if="showFilePickerButton"
data-testid="upload-attachment-button"
......@@ -155,17 +189,23 @@ export default {
/>
</gl-form-group>
<span>{{ $options.i18n.uploadMessage }}</span>
<span v-if="isShowingUploadText" class="gl-text-gray-500">{{
$options.i18n.uploadMessage
}}</span>
<gl-form-group>
<gl-button
v-if="showUploadButton"
data-testid="upload-corpus"
class="gl-mt-2"
variant="success"
:disabled="isUploadButtonEnabled"
category="primary"
variant="confirm"
@click="beginFileUpload"
>
{{ __('Upload file') }}
</gl-button>
</gl-form-group>
<div v-if="isUploading" data-testid="upload-status" class="gl-mt-2">
<gl-loading-icon inline size="sm" />
......
import { s__ } from '~/locale';
export const MAX_LIST_COUNT = 25;
export const MAX_FILE_SIZE = 5e9;
export const VALID_CORPUS_MIMETYPE = {
mimetype: 'application/zip',
};
export const I18N = {
fileTooLarge: s__('CorpusManagement|File too large, Maximum 5 GB'),
invalidName: s__(
'CorpusManagement|Filename can contain only lowercase letters (a-z), uppercase letter (A-Z), numbers (0-9), dots (.), hyphens (-), or underscores (_).',
),
};
export const ERROR_RESPONSE = {
packageNameInvalid: 'package_name is invalid',
/* eslint-disable-next-line @gitlab/require-i18n-strings */
notFound: '404 Not Found',
};
......@@ -23,7 +23,7 @@ export default () => {
});
const {
dataset: { projectFullPath },
dataset: { projectFullPath, canUploadCorpus, canReadCorpus, canDestroyCorpus },
} = el;
let {
......@@ -36,6 +36,9 @@ export default () => {
const provide = {
projectFullPath,
corpusHelpPath,
canUploadCorpus: Boolean(canUploadCorpus),
canReadCorpus: Boolean(canReadCorpus),
canDestroyCorpus: Boolean(canDestroyCorpus),
};
return new Vue({
......
......@@ -3,4 +3,8 @@ fragment UploadState on UploadState {
progress
cancelSource
uploadedPackageId
errors {
name
file
}
}
mutation uploadError($projectPath: ID!, $error: String!) {
uploadError(projectPath: $projectPath, error: $error) @client
}
......@@ -34,6 +34,8 @@ query getCorpuses(
id
createdAt
ref
path
updatedAt
}
}
}
......
......@@ -6,7 +6,9 @@ import { TYPE_PACKAGES_PACKAGE } from '~/graphql_shared/constants';
import getUploadState from '../queries/get_upload_state.query.graphql';
import updateProgress from '../mutations/update_progress.mutation.graphql';
import uploadComplete from '../mutations/upload_complete.mutation.graphql';
import uploadError from '../mutations/upload_error.mutation.graphql';
import corpusCreate from '../mutations/corpus_create.mutation.graphql';
import { parseNameError, parseFileError } from './utils';
export default {
Query: {
......@@ -16,6 +18,12 @@ export default {
progress: 0,
cancelSource: null,
uploadedPackageId: null,
errors: {
name: '',
file: '',
/* eslint-disable-next-line @gitlab/require-i18n-strings */
__typename: 'Errors',
},
__typename: 'UploadState',
};
},
......@@ -64,12 +72,14 @@ export default {
const { uploadState } = draftState;
uploadState.isUploading = true;
uploadState.cancelSource = source;
uploadState.errors.name = '';
uploadState.errors.file = '';
});
cache.writeQuery({ query: getUploadState, data: targetData, variables: { projectPath } });
publishPackage(
{ projectPath, name, version: 0, fileName: `${name}.zip`, files },
{ projectPath, name, version: '1.0.0', fileName: `artifacts.zip`, files },
{ status: 'hidden', select: 'package_file' },
{ onUploadProgress, cancelToken: source.token },
)
......@@ -79,10 +89,31 @@ export default {
variables: { projectPath, packageId: data.package_id },
});
})
.catch(() => {
/* TODO: Error handling */
.catch((e) => {
const { error } = e.response?.data;
client.mutate({
mutation: uploadError,
variables: { projectPath, error },
});
});
},
uploadError: (_, { projectPath, error }, { cache }) => {
const sourceData = cache.readQuery({
query: getUploadState,
variables: { projectPath },
});
const data = produce(sourceData, (draftState) => {
const { uploadState } = draftState;
uploadState.isUploading = false;
uploadState.progress = 0;
uploadState.cancelSource = null;
uploadState.errors.name = parseNameError(error);
uploadState.errors.file = parseFileError(error);
});
cache.writeQuery({ query: getUploadState, data, variables: { projectPath } });
},
uploadComplete: (_, { projectPath, packageId }, { cache }) => {
const sourceData = cache.readQuery({
query: getUploadState,
......@@ -94,6 +125,8 @@ export default {
uploadState.isUploading = false;
uploadState.cancelSource = null;
uploadState.uploadedPackageId = packageId;
uploadState.errors.name = '';
uploadState.errors.file = '';
});
cache.writeQuery({ query: getUploadState, data, variables: { projectPath } });
......@@ -108,6 +141,8 @@ export default {
const { uploadState } = draftState;
uploadState.isUploading = true;
uploadState.progress = progress;
uploadState.errors.name = '';
uploadState.errors.file = '';
});
cache.writeQuery({ query: getUploadState, data, variables: { projectPath } });
......@@ -125,6 +160,8 @@ export default {
uploadState.isUploading = false;
uploadState.progress = 0;
uploadState.cancelToken = null;
uploadState.errors.name = '';
uploadState.errors.file = '';
});
cache.writeQuery({ query: getUploadState, data, variables: { projectPath } });
......
import { I18N, ERROR_RESPONSE } from '../../constants';
export const parseNameError = (error) => {
if (error === ERROR_RESPONSE.packageNameInvalid || error === ERROR_RESPONSE.notFound) {
return I18N.invalidName;
}
return '';
};
export const parseFileError = (error) => {
if (error === I18N.fileTooLarge) {
return I18N.fileTooLarge;
}
return '';
};
......@@ -2,4 +2,7 @@
- breadcrumb_title s_('CorpusManagement|Fuzz testing corpus management')
- page_title s_('CorpusManagement|Fuzz testing corpus management')
.js-corpus-management{ data: { project_full_path: @project.full_path } }
.js-corpus-management{ data: {project_full_path: @project.full_path,
can_upload_corpus: can?(current_user, :create_package, @project).to_s,
can_read_corpus: can?(current_user, :read_package, @project).to_s,
can_destroy_corpus: can?(current_user, :destroy_package, @project).to_s } }
......@@ -16,6 +16,7 @@ exports[`Corpus Upload component renders header 1`] = `
buttontextclasses=""
category="primary"
class="gl-mr-5 gl-ml-auto"
data-testid="new-corpus"
icon=""
role="button"
size="medium"
......
......@@ -7,12 +7,19 @@ import { corpuses } from '../../mock_data';
describe('Action buttons', () => {
let wrapper;
const findCorpusDownloadButton = () => wrapper.find('[data-testid="download-corpus"]');
const findCorpusDestroyButton = () => wrapper.find('[data-testid="destroy-corpus"]');
const createComponentFactory = (mountFn = shallowMount) => (options = {}) => {
const defaultProps = {
corpus: corpuses[0],
};
wrapper = mountFn(Actions, {
propsData: defaultProps,
provide: {
canReadCorpus: true,
canDestroyCorpus: true,
},
...options,
});
};
......@@ -23,7 +30,7 @@ describe('Action buttons', () => {
wrapper.destroy();
});
describe('corpus management', () => {
describe('corpus management with read and destroy enabled', () => {
it('renders the action buttons', () => {
createComponent();
expect(wrapper.findAllComponents(GlButton)).toHaveLength(2);
......@@ -42,4 +49,45 @@ describe('Action buttons', () => {
});
});
});
describe('corpus management with read disabled', () => {
it('renders the destroy button only', () => {
createComponent({
provide: {
canReadCorpus: false,
canDestroyCorpus: true,
},
});
expect(wrapper.findAllComponents(GlButton)).toHaveLength(1);
expect(findCorpusDownloadButton().exists()).toBe(false);
expect(findCorpusDestroyButton().exists()).toBe(true);
});
describe('delete confirmation modal', () => {
beforeEach(() => {
createComponent({ stubs: { GlModal } });
});
it('calls the deleteCorpus method', async () => {
wrapper.findComponent(GlModal).vm.$emit('primary');
await nextTick();
expect(wrapper.emitted().delete).toBeTruthy();
});
});
});
describe('corpus management with destroy disabled', () => {
it('renders the download button only', () => {
createComponent({
provide: {
canReadCorpus: true,
canDestroyCorpus: false,
},
});
expect(wrapper.findAllComponents(GlButton)).toHaveLength(1);
expect(findCorpusDownloadButton().exists()).toBe(true);
expect(findCorpusDestroyButton().exists()).toBe(false);
});
});
});
import { mount } from '@vue/test-utils';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import CorpusUploadForm from 'ee/security_configuration/corpus_management/components/corpus_upload_form.vue';
import { I18N } from 'ee/security_configuration/corpus_management/constants';
const TEST_PROJECT_FULL_PATH = '/namespace/project';
describe('Corpus upload modal', () => {
let wrapper;
const findCorpusName = () => wrapper.find('[data-testid="corpus-name"]');
const findUploadAttachment = () => wrapper.find('[data-testid="upload-attachment-button"]');
const findUploadCorpus = () => wrapper.find('[data-testid="upload-corpus"]');
const findUploadStatus = () => wrapper.find('[data-testid="upload-status"]');
const findCorpusName = () => wrapper.findByTestId('corpus-name');
const findUploadAttachment = () => wrapper.findByTestId('upload-attachment-button');
const findUploadCorpus = () => wrapper.findByTestId('upload-corpus');
const findUploadStatus = () => wrapper.findByTestId('upload-status');
const findFileInput = () => wrapper.findComponent({ ref: 'fileUpload' });
const findCancelButton = () => wrapper.find('[data-testid="cancel-upload"]');
const findCancelButton = () => wrapper.findByTestId('cancel-upload');
const findNameErrorMsg = () => wrapper.findByText(I18N.invalidName);
const findFileErrorMsg = () => wrapper.findByText(I18N.fileTooLarge);
const createComponent = (propsData, options = {}) => {
wrapper = mount(CorpusUploadForm, {
wrapper = mountExtended(CorpusUploadForm, {
propsData,
provide: {
projectFullPath: TEST_PROJECT_FULL_PATH,
......@@ -44,6 +47,10 @@ describe('Corpus upload modal', () => {
uploadState: {
isUploading: false,
progress: 0,
errors: {
name: '',
file: '',
},
},
},
};
......@@ -95,6 +102,10 @@ describe('Corpus upload modal', () => {
uploadState: {
isUploading: false,
progress: 0,
errors: {
name: '',
file: '',
},
},
},
};
......@@ -146,6 +157,10 @@ describe('Corpus upload modal', () => {
uploadState: {
isUploading: true,
progress: 25,
errors: {
name: '',
file: '',
},
},
},
};
......@@ -197,6 +212,10 @@ describe('Corpus upload modal', () => {
uploadState: {
isUploading: false,
progress: 100,
errors: {
name: '',
file: '',
},
},
},
};
......@@ -220,5 +239,159 @@ describe('Corpus upload modal', () => {
expect(findUploadStatus().exists()).toBe(false);
});
});
describe('error states', () => {
describe('invalid corpus name', () => {
const attachmentName = 'corpus.zip';
const corpusName = 'User entered name';
beforeEach(() => {
const data = () => {
return {
attachmentName,
corpusName,
files: [attachmentName],
};
};
const props = {
states: {
uploadState: {
isUploading: false,
progress: 0,
errors: {
name: I18N.invalidName,
file: '',
},
},
},
};
createComponent(props, { data });
});
it('shows name field', () => {
expect(findCorpusName().element.value).toBe(corpusName);
});
it('shows the choose file button', () => {
expect(findUploadAttachment().exists()).toBe(true);
});
it('shows the upload corpus button', () => {
expect(findUploadCorpus().exists()).toBe(true);
});
it('does not show the upload progress', () => {
expect(findUploadStatus().exists()).toBe(false);
});
it('shows corpus name invalid', () => {
expect(findNameErrorMsg().exists()).toBe(true);
});
});
describe('file too large', () => {
const attachmentName = 'corpus.zip';
const corpusName = 'User entered name';
beforeEach(() => {
const data = () => {
return {
attachmentName,
corpusName,
files: [attachmentName],
};
};
const props = {
states: {
uploadState: {
isUploading: false,
progress: 0,
errors: {
name: '',
file: I18N.fileTooLarge,
},
},
},
};
createComponent(props, { data });
});
it('shows name field', () => {
expect(findCorpusName().element.value).toBe(corpusName);
});
it('shows the choose file button', () => {
expect(findUploadAttachment().exists()).toBe(true);
});
it('shows the upload corpus button', () => {
expect(findUploadCorpus().exists()).toBe(true);
});
it('does not show the upload progress', () => {
expect(findUploadStatus().exists()).toBe(false);
});
it('shows corpus size too large', () => {
expect(findFileErrorMsg().exists()).toBe(true);
});
});
describe('blank corpus name', () => {
const attachmentName = 'corpus.zip';
const corpusName = '';
beforeEach(() => {
const data = () => {
return {
attachmentName,
corpusName,
files: [attachmentName],
};
};
const props = {
states: {
uploadState: {
isUploading: false,
progress: 0,
errors: {
name: '',
file: '',
},
},
},
};
createComponent(props, { data });
});
it('shows name field', () => {
expect(findCorpusName().element.value).toBe(corpusName);
});
it('shows the choose file button', () => {
expect(findUploadAttachment().exists()).toBe(true);
});
it('shows the upload corpus button as disabled', () => {
expect(findUploadCorpus().exists()).toBe(true);
expect(findUploadCorpus().attributes('disabled')).toBe('disabled');
});
it('does not show the upload progress', () => {
expect(findUploadStatus().exists()).toBe(false);
});
it('does not show name format and file error messages', () => {
expect(findFileErrorMsg().exists()).toBe(false);
expect(findNameErrorMsg().exists()).toBe(false);
});
});
});
});
});
import { GlButton, GlModal } from '@gitlab/ui';
import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import CorpusUpload from 'ee/security_configuration/corpus_management/components/corpus_upload.vue';
import CorpusUploadForm from 'ee/security_configuration/corpus_management/components/corpus_upload_form.vue';
......@@ -10,6 +10,7 @@ describe('Corpus Upload', () => {
const findModal = () => wrapper.findComponent(GlModal);
const findCorpusUploadForm = () => wrapper.findComponent(CorpusUploadForm);
const findNewCorpusButton = () => wrapper.find('[data-testid="new-corpus"]');
const createComponentFactory = (mountFn = shallowMount) => (options = {}) => {
const defaultProps = { totalSize: 4e8 };
......@@ -24,6 +25,7 @@ describe('Corpus Upload', () => {
},
provide: {
projectFullPath: TEST_PROJECT_FULL_PATH,
canUploadCorpus: true,
},
...options,
});
......@@ -38,7 +40,7 @@ describe('Corpus Upload', () => {
describe('component', () => {
it('renders header', () => {
createComponent();
expect(wrapper.findComponent(GlButton).exists()).toBe(true);
expect(findNewCorpusButton().exists()).toBe(true);
expect(wrapper.element).toMatchSnapshot();
});
......@@ -79,5 +81,18 @@ describe('Corpus Upload', () => {
expect(wrapper.vm.beginFileUpload).toHaveBeenCalled();
});
});
describe('with new uploading disabled', () => {
it('does not render the upload button', () => {
createComponent({
provide: {
projectFullPath: TEST_PROJECT_FULL_PATH,
canUploadCorpus: false,
},
});
expect(findNewCorpusButton().exists()).toBe(false);
});
});
});
});
......@@ -40,6 +40,11 @@ describe('EE - CorpusManagement', () => {
progress: 0,
cancelSource: null,
uploadedPackageId: null,
errors: {
name: '',
file: '',
__typename: 'Errors',
},
__typename: 'UploadState',
};
},
......
......@@ -24,6 +24,8 @@ describe('Corpus table', () => {
propsData: defaultProps,
provide: {
projectFullPath: TEST_PROJECT_FULL_PATH,
canReadCorpus: true,
canDestroyCorpus: true,
},
...options,
});
......
......@@ -5,6 +5,7 @@ const pipelines = {
ref: 'farias-gl/go-fuzzing-example',
path: 'gitlab-examples/security/security-reports/-/jobs/1107103952',
createdAt: new Date(2020, 4, 3).toString(),
updatedAt: new Date(2020, 4, 5).toString(),
},
],
};
......
......@@ -9811,12 +9811,24 @@ msgstr ""
msgid "CorpusManagement|Corpus are used in fuzz testing as mutation source to Improve future testing."
msgstr ""
msgid "CorpusManagement|Corpus file"
msgstr ""
msgid "CorpusManagement|Corpus files must be in *.zip format. Maximum 5 GB"
msgstr ""
msgid "CorpusManagement|Corpus name"
msgstr ""
msgid "CorpusManagement|Currently, there are no uploaded or generated corpuses."
msgstr ""
msgid "CorpusManagement|File too large, Maximum 5 GB"
msgstr ""
msgid "CorpusManagement|Filename can contain only lowercase letters (a-z), uppercase letter (A-Z), numbers (0-9), dots (.), hyphens (-), or underscores (_)."
msgstr ""
msgid "CorpusManagement|Fuzz testing corpus management"
msgstr ""
......@@ -9829,9 +9841,6 @@ msgstr ""
msgid "CorpusManagement|Latest Job:"
msgstr ""
msgid "CorpusManagement|New corpus needs to be a upload in *.zip format. Maximum 5GB"
msgstr ""
msgid "CorpusManagement|New upload"
msgstr ""
......
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