Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
Azat Alimov
POD
Commits
8517736e
Commit
8517736e
authored
2 years ago
by
Szymon Zimnowoda
Browse files
Options
Download
Email Patches
Plain Diff
added resend email endpoint
parent
9114bdd4
Changes
10
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
docs/HTTP_API.md
+10
-0
docs/HTTP_API.md
libpod/src/database_utils.rs
+3
-2
libpod/src/database_utils.rs
libpod/src/db_model.rs
+0
-1
libpod/src/db_model.rs
libpod/src/error.rs
+1
-1
libpod/src/error.rs
libpod/src/user_account.rs
+82
-9
libpod/src/user_account.rs
pod/src/actix_api.rs
+7
-5
pod/src/actix_api.rs
pod/src/actix_endpoints.rs
+16
-2
pod/src/actix_endpoints.rs
pod/src/pod_handlers.rs
+11
-0
pod/src/pod_handlers.rs
pod/tests/test_create_account.rs
+174
-14
pod/tests/test_create_account.rs
pod/tests/test_pod_keys.rs
+1
-1
pod/tests/test_pod_keys.rs
with
305 additions
and
35 deletions
+305
-35
docs/HTTP_API.md
+
10
-
0
View file @
8517736e
...
...
@@ -151,6 +151,16 @@ Create the Database in the POD, note the `owner` and `database` key requirements
This is the first step of POD Account registration. A mail shall be send to
`login`
address with the
unique code that needs to be provided in
`v4/account/verify`
endpoint
### POST /v4/account/resend_mail
```
json
{
"login"
:
"valid email"
,
"password"
:
"string"
}
```
This endpoint allows to re-send a verification mail, valid login and password has to be provided.
New verification code is generated upon each call to that endpoint, but only last one is valid.
### POST /v4/account/verify
```
json
{
...
...
This diff is collapsed.
Click to expand it.
libpod/src/database_utils.rs
+
3
-
2
View file @
8517736e
...
...
@@ -7,9 +7,10 @@ use crate::{
database_pool
::{
get_db_connection
,
remove_db
,
InitDb
},
db_model
::
ItemBase
,
error
::
Result
,
file_api
,
forbidden
,
internal_error
,
file_api
,
internal_error
,
plugin_auth_crypto
::
DatabaseKey
,
schema
::{
Schema
,
SchemaPropertyType
},
unauthorized
,
};
use
lazy_static
::
lazy_static
;
use
rusqlite
::
types
::
ValueRef
;
...
...
@@ -360,7 +361,7 @@ pub fn check_owner(possible_owner: &str) -> Result<()> {
possible_owner
,
hex
::
encode
(
possible_hash
)
);
Err
(
forbidden
!
{
Err
(
unauthorized
!
{
"Unexpected owner"
,
})
}
...
...
This diff is collapsed.
Click to expand it.
libpod/src/db_model.rs
+
0
-
1
View file @
8517736e
...
...
@@ -108,7 +108,6 @@ pub struct Oauth2Flow {
pub
enum
RegisterState
{
VerifyEmailSent
,
RegistrationComplete
,
EnforcePasswordReset
,
}
pub
const
POD_ACCOUNT
:
&
str
=
"PodUserAccount"
;
...
...
This diff is collapsed.
Click to expand it.
libpod/src/error.rs
+
1
-
1
View file @
8517736e
...
...
@@ -49,7 +49,7 @@ macro_rules! bad_request {
/// Shorthand for creating errors caused unauthorized access
#[macro_export]
macro_rules!
forbidden
{
macro_rules!
unauthorized
{
(
$
(
$arg:tt
)
*
)
=>
{
$crate
::
error
::
Error
{
context
:
None
,
...
...
This diff is collapsed.
Click to expand it.
libpod/src/user_account.rs
+
82
-
9
View file @
8517736e
...
...
@@ -11,7 +11,7 @@ use secrecy::{ExposeSecret, Secret, SecretString};
use
serde_json
::
json
;
use
sha2
::{
Digest
,
Sha256
};
use
tokio
::
join
;
use
tracing
::{
debug
,
info
,
instrument
};
use
tracing
::{
debug
,
error
,
info
,
instrument
};
use
crate
::
api_model
::{
AuthKey
,
ClientAuth
,
CreateItem
,
PodCredentials
,
PodOwner
,
RecoverRequest
,
RegisterResponse
,
...
...
@@ -29,9 +29,9 @@ use crate::error::{ErrorContext, Result};
use
crate
::
plugin_auth_crypto
::
auth_to_database_key
;
use
crate
::{
bad_request
,
database_api
,
database_utils
,
internal_api
,
internal_error
,
shared_state
,
unauthorized
,
};
#[instrument(fields(login=
%
body
.
login),
skip_all)]
pub
async
fn
register
(
cli
:
&
CliOptions
,
init_db
:
&
InitDb
,
...
...
@@ -39,10 +39,19 @@ pub async fn register(
)
->
Result
<
RegisterResponse
>
{
let
mut
conn
=
shared_state
::
db_connection
(
init_db
)
.await
?
;
if
let
Some
(
acc
)
=
get_account_from_db
(
&
mut
conn
,
&
body
.login
)
.await
?
{
E
rr
(
bad_request
!
(
"
Account
already exist
s
, stat
us
{:?}"
,
e
rr
or
!
(
"
Trying to register on
already exist
ing account
, stat
e:
{:?}"
,
acc
.item.state
))
);
match
acc
.item.state
{
RegisterState
::
VerifyEmailSent
=>
Err
(
bad_request!
(
"This email is already registered. Please verify the email using the code sent to your inbox."
),
),
RegisterState
::
RegistrationComplete
=>
Err
(
bad_request!
(
"This email is already registered. Please log in."
)),
}
}
else
{
let
mut
rnd
=
rand
::
thread_rng
();
...
...
@@ -152,7 +161,62 @@ pub async fn verify(init_db: &InitDb, body: &RegisterVerifyAccountReq) -> Result
acc
.item.state
=
RegisterState
::
RegistrationComplete
;
update_account_in_db
(
&
mut
conn
,
&
acc
)
.await
}
else
{
Err
(
bad_request!
(
"Invalid token provided"
))
debug!
(
"Invalid token provided '{}' vs '{}'"
,
body
.code
.expose_secret
(),
acc
.item.code
);
Err
(
unauthorized!
(
"Invalid token provided"
))
}
}
#[instrument(fields(login=
%
body
.
login),
skip_all)]
pub
async
fn
resend_verification_mail
(
cli
:
&
CliOptions
,
init_db
:
&
InitDb
,
body
:
&
UserAccountCredentials
,
)
->
Result
<
()
>
{
let
mut
conn
=
shared_state
::
db_connection
(
init_db
)
.await
?
;
if
let
Some
(
mut
acc
)
=
get_account_from_db
(
&
mut
conn
,
&
body
.login
)
.await
?
{
if
acc
.item.state
!=
RegisterState
::
VerifyEmailSent
{
return
Err
(
bad_request!
(
"Account is in state {:?}"
,
acc
.item.state
));
}
validate_password
(
body
.password
.clone
(),
acc
.item.password_hash
.clone
())
.await
?
;
let
mut
rnd
=
rand
::
thread_rng
();
let
code
:
Vec
<
u32
>
=
(
0
..
6
)
.map
(|
_
|
rnd
.gen_range
(
0
..
10
))
.collect
();
let
code_str
=
format!
(
"{}{}{}{}{}{}"
,
code
[
0
],
code
[
1
],
code
[
2
],
code
[
3
],
code
[
4
],
code
[
5
]
);
let
sending_mail
=
Instant
::
now
();
let
message
=
create_verification_mail_body
(
&
body
.login
,
"Verify your account in Memri"
,
cli
,
&
code
.try_into
()
.unwrap
(),
)
?
;
send_email
(
message
,
cli
)
.await
?
;
debug!
(
"RE-Sending mail took {}ms"
,
sending_mail
.elapsed
()
.as_millis
()
);
// TODO: there is obvious race condition with parallel requests for the same
// account - ensure 1 request at a time happens
// there is race condition with /verify too
// create a lock in a hash map, while trying to lock and fail -> return 409
// when verification completes, remove the lock from the hashmap
acc
.item.code
=
code_str
;
update_account_in_db
(
&
mut
conn
,
&
acc
)
.await
?
;
Ok
(())
}
else
{
Err
(
bad_request!
(
"No such account {}"
,
body
.login
))
}
}
...
...
@@ -166,11 +230,15 @@ pub async fn get_pod_keys(
let
mut
conn
=
shared_state
::
db_connection
(
init_db
)
.await
?
;
let
Some
(
acc
)
=
get_account_from_db
(
&
mut
conn
,
&
body
.login
)
.await
?
else
{
return
Err
(
bad_request!
(
"
Account does not exist
"
));
return
Err
(
bad_request!
(
"
There is no account registered with this email. Please check your email or create an account.
"
));
};
if
acc
.item.state
!=
RegisterState
::
RegistrationComplete
{
return
Err
(
bad_request!
(
"Account is not verified"
));
error!
(
"Account is not yet fully registered, state {:?}"
,
acc
.item.state
);
return
Err
(
bad_request!
(
"This email is already registered. Please verify the email using the code sent to your inbox."
));
}
validate_password
(
body
.password
.clone
(),
acc
.item.password_hash
.clone
())
.await
?
;
...
...
@@ -229,7 +297,12 @@ async fn validate_password(password: Secret<String>, expected_hash: String) -> R
password
.expose_secret
()
.as_bytes
(),
&
PasswordHash
::
new
(
&
expected_hash
)
.expect
(
"Invalid PHC string format"
),
)
.map_err
(|
e
|
bad_request!
(
"Invalid password {e}"
))
?
;
.map_err
(|
e
|
match
e
{
argon2
::
password_hash
::
Error
::
Password
=>
{
unauthorized!
(
"Oops, wrong password! Please try again."
)
}
_
=>
internal_error!
(
"Error during password validation: {e}"
),
})
?
;
Ok
(())
})
.await
?
...
...
This diff is collapsed.
Click to expand it.
pod/src/actix_api.rs
+
7
-
5
View file @
8517736e
use
crate
::
actix_endpoints
::{
account
,
account_derive_pod_keys
,
account_open_example_pod
,
account_recover_pod_keys
,
account_register
,
account_verify
,
bulk
,
create_edge
,
create_item
,
delete_edge_by_source_target
,
delete_item
,
delete_pod
,
get_edges
,
get_file
,
get_item
,
get_logs
,
graphql
,
not_found
,
oauth1_access_token
,
oauth1_request_token
,
oauth2_access_token
,
oauth2_auth_url
,
oauth2_authorize
,
open_pod
,
plugin_api
,
plugin_api_call
,
plugin_attach
,
plugins_status
,
search
,
send_email
,
trace_filter
,
trigger_status
,
update_item
,
upload_file
,
upload_file_b
,
version
,
account_register
,
account_send_verification_mail
,
account_verify
,
bulk
,
create_edge
,
create_item
,
delete_edge_by_source_target
,
delete_item
,
delete_pod
,
get_edges
,
get_file
,
get_item
,
get_logs
,
graphql
,
not_found
,
oauth1_access_token
,
oauth1_request_token
,
oauth2_access_token
,
oauth2_auth_url
,
oauth2_authorize
,
open_pod
,
plugin_api
,
plugin_api_call
,
plugin_attach
,
plugins_status
,
search
,
send_email
,
trace_filter
,
trigger_status
,
update_item
,
upload_file
,
upload_file_b
,
version
,
};
use
actix_cors
::
Cors
;
...
...
@@ -127,6 +128,7 @@ async fn run_server_with_dependencies<S: 'static>(
.service
(
account_register
)
.service
(
account_recover_pod_keys
)
.service
(
account_verify
)
.service
(
account_send_verification_mail
)
.service
(
account_derive_pod_keys
)
.service
(
account_open_example_pod
),
);
...
...
This diff is collapsed.
Click to expand it.
pod/src/actix_endpoints.rs
+
16
-
2
View file @
8517736e
...
...
@@ -269,6 +269,20 @@ pub async fn account_verify(
respond_with_result
(
result
)
}
#[instrument(fields(uid=trace_uid()),
skip_all)]
#[post(
"/account/resend_mail"
)]
pub
async
fn
account_send_verification_mail
(
cli
:
web
::
Data
<
CliOptions
>
,
init_db
:
web
::
Data
<
InitDb
>
,
body
:
web
::
Bytes
,
)
->
actix_web
::
Result
<
impl
Responder
>
{
let
body
=
extract_json
(
&
body
)
?
;
let
result
=
pod_handlers
::
account_send_verification_mail
(
&
cli
,
&
init_db
.into_inner
(),
body
)
.await
;
let
result
=
result
.map
(|
result
|
web
::
Json
(
serde_json
::
json!
(
result
)));
respond_with_result
(
result
)
}
#[instrument(fields(uid=trace_uid()),
skip_all)]
#[post(
"/account/pod/derive_keys"
)]
pub
async
fn
account_derive_pod_keys
(
...
...
@@ -547,8 +561,8 @@ pub fn respond_with_result<T: Serialize>(result: Result<T>) -> actix_web::Result
|
libpod
::
error
::
ErrorType
::
BadRequest
(
_
)
=>
StatusCode
::
BAD_REQUEST
,
libpod
::
error
::
ErrorType
::
Any
{
code
,
msg
:
_
}
=>
*
code
,
libpod
::
error
::
ErrorType
::
Unauthorized
(
_
)
=>
StatusCode
::
FORBIDDEN
,
libpod
::
error
::
ErrorType
::
UnauthorizedDatabaseAccess
(
_
)
libpod
::
error
::
ErrorType
::
Unauthorized
(
_
)
|
libpod
::
error
::
ErrorType
::
UnauthorizedDatabaseAccess
(
_
)
|
libpod
::
error
::
ErrorType
::
AeadEncryption
(
_
)
=>
StatusCode
::
UNAUTHORIZED
,
libpod
::
error
::
ErrorType
::
Internal
(
_
)
=>
StatusCode
::
INTERNAL_SERVER_ERROR
,
...
...
This diff is collapsed.
Click to expand it.
pod/src/pod_handlers.rs
+
11
-
0
View file @
8517736e
...
...
@@ -263,6 +263,17 @@ pub async fn account_verify(init_db: &InitDb, body: RegisterVerifyAccountReq) ->
user_account
::
verify
(
init_db
,
&
body
)
.await
}
#[inline(always)]
pub
async
fn
account_send_verification_mail
(
cli
:
&
CliOptions
,
init_db
:
&
InitDb
,
body
:
UserAccountCredentials
,
)
->
Result
<
()
>
{
info!
(
"Resend verification mail for account {:?}"
,
body
.login
);
user_account
::
resend_verification_mail
(
cli
,
init_db
,
&
body
)
.await
}
#[inline(always)]
pub
async
fn
account_derive_pod_keys
(
init_db
:
&
InitDb
,
...
...
This diff is collapsed.
Click to expand it.
pod/tests/test_create_account.rs
+
174
-
14
View file @
8517736e
...
...
@@ -5,6 +5,7 @@ use libpod::api_model::{PodCredentials, RegisterResponse};
use
reqwest
::
StatusCode
;
use
secrecy
::
ExposeSecret
;
use
serde_json
::{
json
,
Value
};
use
sha2
::{
Digest
,
Sha256
};
use
std
::
ops
::{
Deref
,
DerefMut
};
use
test_context
::{
test_context
,
AsyncTestContext
};
...
...
@@ -12,13 +13,16 @@ use test_context::{test_context, AsyncTestContext};
#[tokio::test]
async
fn
test_account_creation_flow
(
ctx
:
&
mut
TestDataForCreateAccount
)
{
// Call register for fresh account
const
LOGIN
:
&
str
=
"anastasiia@example.com"
;
const
PASS
:
&
str
=
"bobx0x0"
;
let
login_hash
=
hex
::
encode
(
Sha256
::
new_with_prefix
(
LOGIN
.as_bytes
())
.finalize
());
let
res
=
ctx
.pod_client
.post_to
(
json!
({
"login"
:
"anastasiia@example.com"
,
"login"
:
LOGIN
,
"password"
:
PASS
}),
"account/register"
,
...
...
@@ -35,7 +39,8 @@ async fn test_account_creation_flow(ctx: &mut TestDataForCreateAccount) {
.post_to_with_owner
(
json!
(
{
"type"
:
"PodUserAccount"
"type"
:
"PodUserAccount"
,
"loginHash"
:
login_hash
}
),
"search"
,
...
...
@@ -52,7 +57,7 @@ async fn test_account_creation_flow(ctx: &mut TestDataForCreateAccount) {
.pod_client
.post_to
(
json!
({
"login"
:
"anastasiia@example.com"
,
"login"
:
LOGIN
,
"password"
:
PASS
}),
"account/register"
,
...
...
@@ -65,7 +70,7 @@ async fn test_account_creation_flow(ctx: &mut TestDataForCreateAccount) {
.json
::
<
String
>
()
.await
.unwrap
()
.contains
(
"
Account already exists
"
),);
.contains
(
"
This email is already registered.
"
),);
// Login is case insensitive
let
res
=
ctx
...
...
@@ -85,7 +90,7 @@ async fn test_account_creation_flow(ctx: &mut TestDataForCreateAccount) {
.json
::
<
String
>
()
.await
.unwrap
()
.contains
(
"
Account already exists
"
),);
.contains
(
"
This email is already registered.
"
),);
// Login is invalid mail
let
res
=
ctx
...
...
@@ -112,14 +117,14 @@ async fn test_account_creation_flow(ctx: &mut TestDataForCreateAccount) {
.pod_client
.post_to
(
json!
({
"login"
:
"anastasiia@example.com"
,
"login"
:
LOGIN
,
"code"
:
invalid_token
}),
"account/verify"
,
)
.await
;
assert_eq!
(
res
.status
(),
StatusCode
::
BAD_REQUEST
);
assert_eq!
(
res
.status
(),
StatusCode
::
UNAUTHORIZED
);
assert!
(
res
.json
::
<
String
>
()
...
...
@@ -132,7 +137,7 @@ async fn test_account_creation_flow(ctx: &mut TestDataForCreateAccount) {
.pod_client
.post_to
(
json!
({
"login"
:
"anastasiia@example.com"
,
"login"
:
LOGIN
,
"code"
:
token
}),
"account/verify"
,
...
...
@@ -147,27 +152,27 @@ async fn test_account_creation_flow(ctx: &mut TestDataForCreateAccount) {
.pod_client
.post_to
(
json!
({
"login"
:
"anastasiia@example.com"
,
"login"
:
LOGIN
,
"password"
:
"wrong"
}),
"account/pod/derive_keys"
,
)
.await
;
assert_eq!
(
res
.status
(),
StatusCode
::
BAD_REQUEST
);
assert_eq!
(
res
.status
(),
StatusCode
::
UNAUTHORIZED
);
assert!
(
res
.json
::
<
String
>
()
.await
.unwrap
()
.contains
(
"
Invalid password
"
),);
.contains
(
"
Oops, wrong password! Please try again.
"
),);
// with valid pass
let
res
=
ctx
.pod_client
.post_to
(
json!
({
"login"
:
"anastasiia@example.com"
,
"login"
:
LOGIN
,
"password"
:
PASS
}),
"account/pod/derive_keys"
,
...
...
@@ -194,7 +199,7 @@ async fn test_account_creation_flow(ctx: &mut TestDataForCreateAccount) {
.pod_client
.post_to
(
json!
({
"login"
:
"anastasiia@example.com"
,
"login"
:
LOGIN
,
"recoveryPhrase"
:
"steak oyster salt play nominee debris great identify ugly obey marble announce"
}),
"account/pod/recover"
,
...
...
@@ -208,7 +213,7 @@ async fn test_account_creation_flow(ctx: &mut TestDataForCreateAccount) {
.pod_client
.post_to
(
json!
({
"login"
:
"anastasiia@example.com"
,
"login"
:
LOGIN
,
"recoveryPhrase"
:
register_response
.recovery_phrase
.expose_secret
()
}),
"account/pod/recover"
,
...
...
@@ -228,6 +233,161 @@ async fn test_account_creation_flow(ctx: &mut TestDataForCreateAccount) {
);
}
#[test_context(TestDataForCreateAccount)]
#[tokio::test]
async
fn
test_account_resend_email
(
ctx
:
&
mut
TestDataForCreateAccount
)
{
// Call register for fresh account
const
LOGIN
:
&
str
=
"bob@example.com"
;
const
PASS
:
&
str
=
"bobx0x0"
;
let
login_hash
=
hex
::
encode
(
Sha256
::
new_with_prefix
(
LOGIN
.as_bytes
())
.finalize
());
let
res
=
ctx
.pod_client
.post_to
(
json!
({
"login"
:
LOGIN
,
"password"
:
PASS
}),
"account/register"
,
)
.await
;
assert!
(
res
.status
()
.is_success
());
let
old_token
=
{
let
res
=
ctx
.pod_client_to_shared_db
.post_to_with_owner
(
json!
(
{
"type"
:
"PodUserAccount"
,
"loginHash"
:
login_hash
,
}
),
"search"
,
)
.await
;
let
user_account_data
:
Value
=
res
.json
()
.await
.unwrap
();
user_account_data
[
0
][
"code"
]
.as_str
()
.unwrap
()
.to_string
()
};
// re-send verification mail - generates new token
let
res
=
ctx
.pod_client
.post_to
(
json!
({
"login"
:
LOGIN
,
"password"
:
PASS
}),
"account/resend_mail"
,
)
.await
;
assert!
(
res
.status
()
.is_success
(),
"The status is {}, body {:#}"
,
res
.status
(),
res
.json
::
<
Value
>
()
.await
.unwrap
()
);
// Verify
// with old token
let
res
=
ctx
.pod_client
.post_to
(
json!
({
"login"
:
"anastasiia@example.com"
,
"code"
:
old_token
}),
"account/verify"
,
)
.await
;
assert_eq!
(
res
.status
(),
StatusCode
::
BAD_REQUEST
);
// with valid token
let
token
=
{
let
res
=
ctx
.pod_client_to_shared_db
.post_to_with_owner
(
json!
(
{
"type"
:
"PodUserAccount"
,
"loginHash"
:
login_hash
}
),
"search"
,
)
.await
;
let
user_account_data
:
Value
=
res
.json
()
.await
.unwrap
();
user_account_data
[
0
][
"code"
]
.as_str
()
.unwrap
()
.to_string
()
};
let
res
=
ctx
.pod_client
.post_to
(
json!
({
"login"
:
LOGIN
,
"code"
:
token
}),
"account/verify"
,
)
.await
;
assert_eq!
(
res
.status
(),
StatusCode
::
OK
);
// cannot resend mail, if account is not in verify state
let
res
=
ctx
.pod_client
.post_to
(
json!
({
"login"
:
LOGIN
,
"password"
:
PASS
}),
"account/resend_mail"
,
)
.await
;
assert_eq!
(
res
.status
(),
StatusCode
::
BAD_REQUEST
,
"The status is {}, body {:#}"
,
res
.status
(),
res
.json
::
<
Value
>
()
.await
.unwrap
()
);
// Can generate pod keys
// with valid pass
let
res
=
ctx
.pod_client
.post_to
(
json!
({
"login"
:
LOGIN
,
"password"
:
PASS
}),
"account/pod/derive_keys"
,
)
.await
;
assert_eq!
(
res
.status
(),
StatusCode
::
OK
);
let
pod_credentials
:
PodCredentials
=
res
.json
()
.await
.unwrap
();
// Can open POD
let
res
=
ctx
.pod_client
.post_to
(
serde_json
::
to_value
(
pod_credentials
.clone
())
.unwrap
(),
"account/pod/open"
,
)
.await
;
assert_eq!
(
res
.status
(),
StatusCode
::
OK
);
}
pub
struct
TestDataForCreateAccount
(
TestData
);
#[async_trait::async_trait]
...
...
This diff is collapsed.
Click to expand it.
pod/tests/test_pod_keys.rs
+
1
-
1
View file @
8517736e
...
...
@@ -165,5 +165,5 @@ async fn test_account_outside_allow_list(ctx: &mut TestData) {
// # 1 trying to use pod, while not yet registered
let
res
=
use_pod
(
&
user_outside_list
)
.await
;
assert_eq!
(
res
.status
(),
StatusCode
::
FORBIDDEN
);
assert_eq!
(
res
.status
(),
StatusCode
::
UNAUTHORIZED
);
}
This diff is collapsed.
Click to expand it.
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new 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