Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-shell
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
nexedi
gitlab-shell
Commits
6fd4f680
Commit
6fd4f680
authored
Aug 20, 2018
by
Ash McKenzie
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Custom Action support
parent
1ff35618
Changes
11
Hide whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
564 additions
and
17 deletions
+564
-17
lib/action.rb
lib/action.rb
+4
-0
lib/action/custom.rb
lib/action/custom.rb
+127
-0
lib/gitlab_access_status.rb
lib/gitlab_access_status.rb
+14
-3
lib/gitlab_net.rb
lib/gitlab_net.rb
+3
-3
lib/gitlab_shell.rb
lib/gitlab_shell.rb
+27
-11
spec/action/custom_spec.rb
spec/action/custom_spec.rb
+136
-0
spec/gitlab_shell_spec.rb
spec/gitlab_shell_spec.rb
+23
-0
spec/vcr_cassettes/custom-action-not-ok-json.yml
spec/vcr_cassettes/custom-action-not-ok-json.yml
+40
-0
spec/vcr_cassettes/custom-action-not-ok-not-json.yml
spec/vcr_cassettes/custom-action-not-ok-not-json.yml
+40
-0
spec/vcr_cassettes/custom-action-ok-not-json.yml
spec/vcr_cassettes/custom-action-ok-not-json.yml
+51
-0
spec/vcr_cassettes/custom-action-ok.yml
spec/vcr_cassettes/custom-action-ok.yml
+99
-0
No files found.
lib/action.rb
0 → 100644
View file @
6fd4f680
require_relative
'action/custom'
module
Action
end
lib/action/custom.rb
0 → 100644
View file @
6fd4f680
require
'base64'
require_relative
'../http_helper'
module
Action
class
Custom
include
HTTPHelper
class
BaseError
<
StandardError
;
end
class
MissingPayloadError
<
BaseError
;
end
class
MissingAPIEndpointsError
<
BaseError
;
end
class
MissingDataError
<
BaseError
;
end
class
UnsuccessfulError
<
BaseError
;
end
NO_MESSAGE_TEXT
=
'No message'
.
freeze
DEFAULT_HEADERS
=
{
'Content-Type'
=>
'application/json'
}.
freeze
def
initialize
(
gl_id
,
payload
)
@gl_id
=
gl_id
@payload
=
payload
end
def
execute
validate!
result
=
process_api_endpoints
if
result
&&
HTTP_SUCCESS_CODES
.
include?
(
result
.
code
)
result
else
raise_unsuccessful!
(
result
)
end
end
private
attr_reader
:gl_id
,
:payload
def
process_api_endpoints
output
=
''
resp
=
nil
data_with_gl_id
=
data
.
merge
(
'gl_id'
=>
gl_id
)
api_endpoints
.
each
do
|
endpoint
|
url
=
"
#{
base_url
}#{
endpoint
}
"
json
=
{
'data'
=>
data_with_gl_id
,
'output'
=>
output
}
resp
=
post
(
url
,
{},
headers:
DEFAULT_HEADERS
,
options:
{
json:
json
})
return
resp
unless
HTTP_SUCCESS_CODES
.
include?
(
resp
.
code
)
begin
body
=
JSON
.
parse
(
resp
.
body
)
rescue
JSON
::
ParserError
raise
UnsuccessfulError
,
'Response was not valid JSON'
end
print_flush
(
body
[
'result'
])
# In the context of the git push sequence of events, it's necessary to read
# stdin in order to capture output to pass onto subsequent commands
output
=
read_stdin
end
resp
end
def
base_url
config
.
gitlab_url
end
def
data
@data
||=
payload
[
'data'
]
end
def
api_endpoints
data
[
'api_endpoints'
]
end
def
config
@config
||=
GitlabConfig
.
new
end
def
api
@api
||=
GitlabNet
.
new
end
def
read_stdin
Base64
.
encode64
(
$stdin
.
read
)
end
def
print_flush
(
str
)
return
false
unless
str
print
(
Base64
.
decode64
(
str
))
STDOUT
.
flush
end
def
validate!
validate_payload!
validate_data!
validate_api_endpoints!
end
def
validate_payload!
raise
MissingPayloadError
if
!
payload
.
is_a?
(
Hash
)
||
payload
.
empty?
end
def
validate_data!
raise
MissingDataError
unless
data
.
is_a?
(
Hash
)
end
def
validate_api_endpoints!
raise
MissingAPIEndpointsError
if
!
api_endpoints
.
is_a?
(
Array
)
||
api_endpoints
.
empty?
end
def
raise_unsuccessful!
(
result
)
message
=
begin
body
=
JSON
.
parse
(
result
.
body
)
body
[
'message'
]
||
Base64
.
decode64
(
body
[
'result'
])
||
NO_MESSAGE_TEXT
rescue
JSON
::
ParserError
NO_MESSAGE_TEXT
end
raise
UnsuccessfulError
,
"
#{
message
}
(
#{
result
.
code
}
)"
end
end
end
lib/gitlab_access_status.rb
View file @
6fd4f680
require
'json'
require_relative
'http_codes'
class
GitAccessStatus
attr_reader
:message
,
:gl_repository
,
:gl_id
,
:gl_username
,
:gitaly
,
:git_protocol
,
:git_config_option
s
include
HTTPCode
s
def
initialize
(
status
,
status_code
,
message
,
gl_repository:
nil
,
gl_id:
nil
,
gl_username:
nil
,
gitaly:
nil
,
git_protocol:
nil
,
git_config_options:
nil
)
attr_reader
:message
,
:gl_repository
,
:gl_id
,
:gl_username
,
:gitaly
,
:git_protocol
,
:git_config_options
,
:payload
def
initialize
(
status
,
status_code
,
message
,
gl_repository:
nil
,
gl_id:
nil
,
gl_username:
nil
,
gitaly:
nil
,
git_protocol:
nil
,
git_config_options:
nil
,
payload:
nil
)
@status
=
status
@status_code
=
status_code
@message
=
message
...
...
@@ -13,6 +18,7 @@ class GitAccessStatus
@git_config_options
=
git_config_options
@gitaly
=
gitaly
@git_protocol
=
git_protocol
@payload
=
payload
end
def
self
.
create_from_json
(
json
,
status_code
)
...
...
@@ -25,10 +31,15 @@ class GitAccessStatus
gl_username:
values
[
"gl_username"
],
git_config_options:
values
[
"git_config_options"
],
gitaly:
values
[
"gitaly"
],
git_protocol:
values
[
"git_protocol"
])
git_protocol:
values
[
"git_protocol"
],
payload:
values
[
"payload"
])
end
def
allowed?
@status
end
def
custom_action?
@status_code
==
HTTP_MULTIPLE_CHOICES
end
end
lib/gitlab_net.rb
View file @
6fd4f680
...
...
@@ -33,9 +33,9 @@ class GitlabNet # rubocop:disable Metrics/ClassLength
url
=
"
#{
internal_api_endpoint
}
/allowed"
resp
=
post
(
url
,
params
)
case
resp
.
code
.
to_s
when
HTTP_SUCCESS
,
HTTP_UNAUTHORIZED
,
HTTP_NOT_FOUND
GitAccessStatus
.
create_from_json
(
resp
.
body
)
case
resp
.
code
when
HTTP_SUCCESS
,
HTTP_
MULTIPLE_CHOICES
,
HTTP_
UNAUTHORIZED
,
HTTP_NOT_FOUND
GitAccessStatus
.
create_from_json
(
resp
.
body
,
resp
.
code
)
else
GitAccessStatus
.
new
(
false
,
resp
.
code
,
'API is not accessible'
)
end
...
...
lib/gitlab_shell.rb
View file @
6fd4f680
...
...
@@ -5,6 +5,7 @@ require 'pathname'
require_relative
'gitlab_net'
require_relative
'gitlab_metrics'
require_relative
'action'
class
GitlabShell
# rubocop:disable Metrics/ClassLength
class
AccessDeniedError
<
StandardError
;
end
...
...
@@ -50,8 +51,17 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
args
=
Shellwords
.
shellwords
(
origin_cmd
)
args
=
parse_cmd
(
args
)
access_status
=
nil
if
GIT_COMMANDS
.
include?
(
args
.
first
)
GitlabMetrics
.
measure
(
'verify-access'
)
{
verify_access
}
access_status
=
GitlabMetrics
.
measure
(
'verify-access'
)
{
verify_access
}
@gl_repository
=
access_status
.
gl_repository
@git_protocol
=
ENV
[
'GIT_PROTOCOL'
]
@gitaly
=
access_status
.
gitaly
@username
=
access_status
.
gl_username
@git_config_options
=
access_status
.
git_config_options
@gl_id
=
access_status
.
gl_id
if
defined?
(
@who
)
elsif
!
defined?
(
@gl_id
)
# We're processing an API command like 2fa_recovery_codes, but
# don't have a @gl_id yet, that means we're in the "username"
...
...
@@ -60,6 +70,13 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
user
end
if
@command
==
GIT_RECEIVE_PACK_COMMAND
&&
access_status
.
custom_action?
# If the response from /api/v4/allowed is a HTTP 300, we need to perform
# a Custom Action and therefore should return and not call process_cmd()
#
return
process_custom_action
(
access_status
)
end
process_cmd
(
args
)
true
...
...
@@ -68,17 +85,19 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
false
rescue
AccessDeniedError
=>
ex
$logger
.
warn
(
'Access denied'
,
command:
origin_cmd
,
user:
log_username
)
$stderr
.
puts
"GitLab:
#{
ex
.
message
}
"
false
rescue
DisallowedCommandError
$logger
.
warn
(
'Denied disallowed command'
,
command:
origin_cmd
,
user:
log_username
)
$stderr
.
puts
"GitLab: Disallowed command"
false
rescue
InvalidRepositoryPathError
$stderr
.
puts
"GitLab: Invalid repository path"
false
rescue
Action
::
Custom
::
BaseError
=>
ex
$logger
.
warn
(
'Custom action error'
,
command:
origin_cmd
,
user:
log_username
)
$stderr
.
puts
"GitLab:
#{
ex
.
message
}
"
false
end
protected
...
...
@@ -123,14 +142,11 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
raise
AccessDeniedError
,
status
.
message
unless
status
.
allowed?
@gl_repository
=
status
.
gl_repository
@git_protocol
=
ENV
[
'GIT_PROTOCOL'
]
@gitaly
=
status
.
gitaly
@username
=
status
.
gl_username
@git_config_options
=
status
.
git_config_options
if
defined?
(
@who
)
@gl_id
=
status
.
gl_id
end
status
end
def
process_custom_action
(
access_status
)
Action
::
Custom
.
new
(
@gl_id
,
access_status
.
payload
).
execute
end
def
process_cmd
(
args
)
...
...
spec/action/custom_spec.rb
0 → 100644
View file @
6fd4f680
require_relative
'../spec_helper'
require_relative
'../../lib/action/custom'
describe
Action
::
Custom
do
let
(
:repo_name
)
{
'gitlab-ci.git'
}
let
(
:gl_id
)
{
'key-1'
}
let
(
:secret
)
{
"0a3938d9d95d807e94d937af3a4fbbea"
}
let
(
:base_url
)
{
'http://localhost:3000'
}
subject
{
described_class
.
new
(
gl_id
,
payload
)
}
describe
'#execute'
do
context
'with an empty payload'
do
let
(
:payload
)
{
{}
}
it
'raises a MissingPayloadError exception'
do
expect
{
subject
.
execute
}.
to
raise_error
(
Action
::
Custom
::
MissingPayloadError
)
end
end
context
'with api_endpoints defined'
do
before
do
allow
(
subject
).
to
receive
(
:base_url
).
and_return
(
base_url
)
allow
(
subject
).
to
receive
(
:secret_token
).
and_return
(
secret
)
allow
(
$stdin
).
to
receive
(
:read
).
and_return
(
''
)
end
context
'that are valid'
do
let
(
:payload
)
do
{
'action'
=>
'geo_proxy_to_primary'
,
'data'
=>
{
'api_endpoints'
=>
%w{/api/v4/fake/info_refs /api/v4/fake/push}
,
'gl_username'
=>
'user1'
,
'primary_repo'
=>
'http://localhost:3001/user1/repo1.git'
}
}
end
context
'and responds correctly'
do
it
'returns an instance of Net::HTTPCreated'
do
VCR
.
use_cassette
(
"custom-action-ok"
)
do
expect
(
subject
.
execute
).
to
be_instance_of
(
Net
::
HTTPCreated
)
end
end
end
context
'but responds incorrectly'
do
it
'raises an UnsuccessfulError exception'
do
VCR
.
use_cassette
(
"custom-action-ok-not-json"
)
do
expect
{
subject
.
execute
}.
to
raise_error
(
Action
::
Custom
::
UnsuccessfulError
,
'Response was not valid JSON'
)
end
end
end
end
context
'that are invalid'
do
context
'where api_endpoints gl_id is missing'
do
let
(
:payload
)
do
{
'action'
=>
'geo_proxy_to_primary'
,
'data'
=>
{
'gl_username'
=>
'user1'
,
'primary_repo'
=>
'http://localhost:3001/user1/repo1.git'
}
}
end
it
'raises a MissingAPIEndpointsError exception'
do
expect
{
subject
.
execute
}.
to
raise_error
(
Action
::
Custom
::
MissingAPIEndpointsError
)
end
end
context
'where api_endpoints are empty'
do
let
(
:payload
)
do
{
'action'
=>
'geo_proxy_to_primary'
,
'data'
=>
{
'api_endpoints'
=>
[],
'gl_username'
=>
'user1'
,
'primary_repo'
=>
'http://localhost:3001/user1/repo1.git'
}
}
end
it
'raises a MissingAPIEndpointsError exception'
do
expect
{
subject
.
execute
}.
to
raise_error
(
Action
::
Custom
::
MissingAPIEndpointsError
)
end
end
context
'where data gl_id is missing'
do
let
(
:payload
)
{
{
'api_endpoints'
=>
%w{/api/v4/fake/info_refs /api/v4/fake/push}
}
}
it
'raises a MissingDataError exception'
do
expect
{
subject
.
execute
}.
to
raise_error
(
Action
::
Custom
::
MissingDataError
)
end
end
context
'where API endpoints are bad'
do
let
(
:payload
)
do
{
'action'
=>
'geo_proxy_to_primary'
,
'data'
=>
{
'api_endpoints'
=>
%w{/api/v4/fake/info_refs_bad /api/v4/fake/push_bad}
,
'gl_username'
=>
'user1'
,
'primary_repo'
=>
'http://localhost:3001/user1/repo1.git'
}
}
end
context
'and response is JSON'
do
it
'raises an UnsuccessfulError exception'
do
VCR
.
use_cassette
(
"custom-action-not-ok-json"
)
do
expect
{
subject
.
execute
}.
to
raise_error
(
Action
::
Custom
::
UnsuccessfulError
,
'You cannot perform write operations on a read-only instance (403)'
)
end
end
end
context
'and response is not JSON'
do
it
'raises an UnsuccessfulError exception'
do
VCR
.
use_cassette
(
"custom-action-not-ok-not-json"
)
do
expect
{
subject
.
execute
}.
to
raise_error
(
Action
::
Custom
::
UnsuccessfulError
,
'No message (403)'
)
end
end
end
end
end
end
end
end
spec/gitlab_shell_spec.rb
View file @
6fd4f680
...
...
@@ -256,6 +256,29 @@ describe GitlabShell do
user_string
=
"user with id
#{
gl_id
}
"
expect
(
$logger
).
to
receive
(
:info
).
with
(
message
,
command:
"gitaly-receive-pack unix:gitaly.socket
#{
gitaly_message
}
"
,
user:
user_string
)
end
context
'with a custom action'
do
let
(
:fake_payload
)
{
{
'api_endpoints'
=>
[
'/fake/api/endpoint'
],
'data'
=>
{}
}
}
let
(
:custom_action_gitlab_access_status
)
do
GitAccessStatus
.
new
(
true
,
HTTPCodes
::
HTTP_MULTIPLE_CHOICES
,
'Multiple Choices'
,
payload:
fake_payload
)
end
let
(
:action_custom
)
{
double
(
Action
::
Custom
)
}
before
do
allow
(
api
).
to
receive
(
:check_access
).
and_return
(
custom_action_gitlab_access_status
)
end
it
"should not process the command"
do
expect
(
subject
).
to_not
receive
(
:process_cmd
).
with
(
%w(git-receive-pack gitlab-ci.git)
)
expect
(
Action
::
Custom
).
to
receive
(
:new
).
with
(
gl_id
,
fake_payload
).
and_return
(
action_custom
)
expect
(
action_custom
).
to
receive
(
:execute
)
end
end
end
context
'gitaly-receive-pack'
do
...
...
spec/vcr_cassettes/custom-action-not-ok-json.yml
0 → 100644
View file @
6fd4f680
---
http_interactions
:
-
request
:
method
:
post
uri
:
http://localhost:3000/api/v4/fake/info_refs_bad
body
:
encoding
:
UTF-8
string
:
'
{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_id":"key-11"},"output":"","secret_token":"0a3938d9d95d807e94d937af3a4fbbea\n"}'
headers
:
Content-Type
:
-
application/json
Accept-Encoding
:
-
gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept
:
-
"
*/*"
User-Agent
:
-
Ruby
Host
:
-
localhost
response
:
status
:
code
:
403
message
:
Forbidden
headers
:
Date
:
-
Fri, 20 Jul 2018 06:54:21 GMT
Connection
:
-
close
Content-Type
:
-
application/json
X-Request-Id
:
-
ea0644ac-e1ad-45f6-aa72-cc7910274318
X-Runtime
:
-
'
1.236672'
body
:
encoding
:
UTF-8
string
:
'
{"message":"You
cannot
perform
write
operations
on
a
read-only
instance"}'
http_version
:
recorded_at
:
Fri, 20 Jul 2018 06:54:21 GMT
recorded_with
:
VCR 2.4.0
spec/vcr_cassettes/custom-action-not-ok-not-json.yml
0 → 100644
View file @
6fd4f680
---
http_interactions
:
-
request
:
method
:
post
uri
:
http://localhost:3000/api/v4/fake/info_refs_bad
body
:
encoding
:
UTF-8
string
:
'
{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_id":"key-11"},"output":"","secret_token":"0a3938d9d95d807e94d937af3a4fbbea\n"}'
headers
:
Content-Type
:
-
application/json
Accept-Encoding
:
-
gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept
:
-
"
*/*"
User-Agent
:
-
Ruby
Host
:
-
localhost
response
:
status
:
code
:
403
message
:
Forbidden
headers
:
Date
:
-
Fri, 20 Jul 2018 06:54:21 GMT
Connection
:
-
close
Content-Type
:
-
application/json
X-Request-Id
:
-
ea0644ac-e1ad-45f6-aa72-cc7910274318
X-Runtime
:
-
'
1.236672'
body
:
encoding
:
UTF-8
string
:
'
""'
http_version
:
recorded_at
:
Fri, 20 Jul 2018 06:54:21 GMT
recorded_with
:
VCR 2.4.0
spec/vcr_cassettes/custom-action-ok-not-json.yml
0 → 100644
View file @
6fd4f680
---
http_interactions
:
-
request
:
method
:
post
uri
:
http://localhost:3000/api/v4/fake/info_refs
body
:
encoding
:
UTF-8
string
:
'
{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_id":"key-1"},"output":"","secret_token":"0a3938d9d95d807e94d937af3a4fbbea"}'
headers
:
Content-Type
:
-
application/json
Accept-Encoding
:
-
gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept
:
-
"
*/*"
User-Agent
:
-
Ruby
Host
:
-
localhost
response
:
status
:
code
:
200
message
:
OK
headers
:
Date
:
-
Fri, 20 Jul 2018 06:18:58 GMT
Connection
:
-
close
X-Frame-Options
:
-
SAMEORIGIN
X-Content-Type-Options
:
-
nosniff
Content-Type
:
-
application/json
Content-Length
:
-
'
172'
Vary
:
-
Origin
Etag
:
-
W/"7d01e1e3dbcbe7cca9607461352f8244"
Cache-Control
:
-
max-age=0, private, must-revalidate
X-Request-Id
:
-
03afa234-b6be-49ab-9392-4aa35c5dee25
X-Runtime
:
-
'
1.436040'
body
:
encoding
:
UTF-8
string
:
'
""'
http_version
:
recorded_at
:
Fri, 20 Jul 2018 06:18:58 GMT
spec/vcr_cassettes/custom-action-ok.yml
0 → 100644
View file @
6fd4f680
---
http_interactions
:
-
request
:
method
:
post
uri
:
http://localhost:3000/api/v4/fake/info_refs
body
:
encoding
:
UTF-8
string
:
'
{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_id":"key-1"},"output":"","secret_token":"0a3938d9d95d807e94d937af3a4fbbea"}'
headers
:
Content-Type
:
-
application/json
Accept-Encoding
:
-
gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept
:
-
"
*/*"
User-Agent
:
-
Ruby
Host
:
-
localhost
response
:
status
:
code
:
200
message
:
OK
headers
:
Date
:
-
Fri, 20 Jul 2018 06:18:58 GMT
Connection
:
-
close
X-Frame-Options
:
-
SAMEORIGIN
X-Content-Type-Options
:
-
nosniff
Content-Type
:
-
application/json
Content-Length
:
-
'
172'
Vary
:
-
Origin
Etag
:
-
W/"7d01e1e3dbcbe7cca9607461352f8244"
Cache-Control
:
-
max-age=0, private, must-revalidate
X-Request-Id
:
-
03afa234-b6be-49ab-9392-4aa35c5dee25
X-Runtime
:
-
'
1.436040'
body
:
encoding
:
UTF-8
string
:
'
{"result":"info_refs-result"}'
http_version
:
recorded_at
:
Fri, 20 Jul 2018 06:18:58 GMT
-
request
:
method
:
post
uri
:
http://localhost:3000/api/v4/fake/push
body
:
encoding
:
UTF-8
string
:
'
{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_id":"key-1"},"output":"info_refs-result","secret_token":"0a3938d9d95d807e94d937af3a4fbbea"}'
headers
:
Content-Type
:
-
application/json
Accept-Encoding
:
-
gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept
:
-
"
*/*"
User-Agent
:
-
Ruby
Host
:
-
localhost
response
:
status
:
code
:
201
message
:
Created
headers
:
Date
:
-
Fri, 20 Jul 2018 06:19:08 GMT
Connection
:
-
close
X-Frame-Options
:
-
SAMEORIGIN
X-Content-Type-Options
:
-
nosniff
Content-Type
:
-
application/json
Content-Length
:
-
'
13'
Vary
:
-
Origin
Cache-Control
:
-
no-cache
X-Request-Id
:
-
0c6894ac-7f8e-4cdb-871f-4cb64d3731ca
X-Runtime
:
-
'
0.786754'
body
:
encoding
:
UTF-8
string
:
'
{"result":"push-result"}'
http_version
:
recorded_at
:
Fri, 20 Jul 2018 06:19:08 GMT
recorded_with
:
VCR 2.4.0
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment