mirror of
https://github.com/kataras/iris.git
synced 2025-01-23 10:41:03 +01:00
add hCaptcha middleware and example and memstore json struct tags
Former-commit-id: 31e4f68429e354c9130ebcf3829683a6d52e4492
This commit is contained in:
parent
a1e6d81b49
commit
eafa63da50
3
_examples/miscellaneous/hcaptcha/hosts
Normal file
3
_examples/miscellaneous/hcaptcha/hosts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# https://docs.hcaptcha.com/#localdev
|
||||||
|
# Add to the end of your hosts file, e.g. on windows: C:/windows/system32/drivers/etc/hosts
|
||||||
|
127.0.0.1 yourdomain.com
|
46
_examples/miscellaneous/hcaptcha/main.go
Normal file
46
_examples/miscellaneous/hcaptcha/main.go
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/kataras/iris/v12"
|
||||||
|
"github.com/kataras/iris/v12/middleware/hcaptcha"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get the following values from: https://dashboard.hcaptcha.com
|
||||||
|
// Also, check: https://docs.hcaptcha.com/#localdev to test on local environment.
|
||||||
|
var (
|
||||||
|
siteKey = os.Getenv("HCAPTCHA-SITE-KEY")
|
||||||
|
secretKey = os.Getenv("HCAPTCHA-SECRET-KEY")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := iris.New()
|
||||||
|
app.RegisterView(iris.HTML("./templates", ".html"))
|
||||||
|
|
||||||
|
hCaptcha := hcaptcha.New(secretKey)
|
||||||
|
app.Get("/register", registerForm)
|
||||||
|
app.Post("/register", hCaptcha, register) // See `hcaptcha.SiteVerify` for manual validation too.
|
||||||
|
|
||||||
|
app.Logger().Infof("SiteKey = %s\tSecretKey = %s",
|
||||||
|
siteKey, secretKey)
|
||||||
|
|
||||||
|
// GET: http://yourdomain.com/register
|
||||||
|
app.Listen(":80")
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(ctx iris.Context) {
|
||||||
|
hcaptchaResp, ok := hcaptcha.Get(ctx)
|
||||||
|
if !ok {
|
||||||
|
ctx.StatusCode(iris.StatusUnauthorized)
|
||||||
|
ctx.WriteString("Are you a bot?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Writef("Register action here...action was asked by a Human.\nResponse value is: %#+v", hcaptchaResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerForm(ctx iris.Context) {
|
||||||
|
ctx.ViewData("SiteKey", siteKey)
|
||||||
|
ctx.View("register_form.html")
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>hCaptcha Demo</title>
|
||||||
|
<script src="https://hcaptcha.com/1/api.js" async defer></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<form action="/register" method="POST">
|
||||||
|
<input type="text" name="email" placeholder="Email" />
|
||||||
|
<input type="password" name="password" placeholder="Password" />
|
||||||
|
<div class="h-captcha" data-sitekey="{{ .SiteKey }}"></div>
|
||||||
|
<br />
|
||||||
|
<input type="submit" value="Submit" />
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -21,8 +21,8 @@ type (
|
||||||
}
|
}
|
||||||
// Entry is the entry of the context storage Store - .Values()
|
// Entry is the entry of the context storage Store - .Values()
|
||||||
Entry struct {
|
Entry struct {
|
||||||
Key string
|
Key string `json:"key" msgpack:"key" yaml:"Key" toml:"Value"`
|
||||||
ValueRaw interface{}
|
ValueRaw interface{} `json:"value" msgpack:"value" yaml:"Value" toml:"Value"`
|
||||||
immutable bool // if true then it can't change by its caller.
|
immutable bool // if true then it can't change by its caller.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,14 @@
|
||||||
package memstore
|
package memstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type myTestObject struct {
|
type myTestObject struct {
|
||||||
name string
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMuttable(t *testing.T) {
|
func TestMuttable(t *testing.T) {
|
||||||
|
@ -15,10 +18,10 @@ func TestMuttable(t *testing.T) {
|
||||||
// slice
|
// slice
|
||||||
p.Set("slice", []myTestObject{{"value 1"}, {"value 2"}})
|
p.Set("slice", []myTestObject{{"value 1"}, {"value 2"}})
|
||||||
v := p.Get("slice").([]myTestObject)
|
v := p.Get("slice").([]myTestObject)
|
||||||
v[0].name = "modified"
|
v[0].Name = "modified"
|
||||||
|
|
||||||
vv := p.Get("slice").([]myTestObject)
|
vv := p.Get("slice").([]myTestObject)
|
||||||
if vv[0].name != "modified" {
|
if vv[0].Name != "modified" {
|
||||||
t.Fatalf("expected slice to be muttable but caller was not able to change its value")
|
t.Fatalf("expected slice to be muttable but caller was not able to change its value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,7 +32,7 @@ func TestMuttable(t *testing.T) {
|
||||||
vMap["key 1"] = myTestObject{"modified"}
|
vMap["key 1"] = myTestObject{"modified"}
|
||||||
|
|
||||||
vvMap := p.Get("map").(map[string]myTestObject)
|
vvMap := p.Get("map").(map[string]myTestObject)
|
||||||
if vvMap["key 1"].name != "modified" {
|
if vvMap["key 1"].Name != "modified" {
|
||||||
t.Fatalf("expected map to be muttable but caller was not able to change its value")
|
t.Fatalf("expected map to be muttable but caller was not able to change its value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,10 +41,10 @@ func TestMuttable(t *testing.T) {
|
||||||
// we expect pointer here, as we set it.
|
// we expect pointer here, as we set it.
|
||||||
vObjP := p.Get("objp").(*myTestObject)
|
vObjP := p.Get("objp").(*myTestObject)
|
||||||
|
|
||||||
vObjP.name = "modified"
|
vObjP.Name = "modified"
|
||||||
|
|
||||||
vvObjP := p.Get("objp").(*myTestObject)
|
vvObjP := p.Get("objp").(*myTestObject)
|
||||||
if vvObjP.name != "modified" {
|
if vvObjP.Name != "modified" {
|
||||||
t.Fatalf("expected objp to be muttable but caller was able to change its value")
|
t.Fatalf("expected objp to be muttable but caller was able to change its value")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -52,10 +55,10 @@ func TestImmutable(t *testing.T) {
|
||||||
// slice
|
// slice
|
||||||
p.SetImmutable("slice", []myTestObject{{"value 1"}, {"value 2"}})
|
p.SetImmutable("slice", []myTestObject{{"value 1"}, {"value 2"}})
|
||||||
v := p.Get("slice").([]myTestObject)
|
v := p.Get("slice").([]myTestObject)
|
||||||
v[0].name = "modified"
|
v[0].Name = "modified"
|
||||||
|
|
||||||
vv := p.Get("slice").([]myTestObject)
|
vv := p.Get("slice").([]myTestObject)
|
||||||
if vv[0].name == "modified" {
|
if vv[0].Name == "modified" {
|
||||||
t.Fatalf("expected slice to be immutable but caller was able to change its value")
|
t.Fatalf("expected slice to be immutable but caller was able to change its value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,17 +68,17 @@ func TestImmutable(t *testing.T) {
|
||||||
vMap["key 1"] = myTestObject{"modified"}
|
vMap["key 1"] = myTestObject{"modified"}
|
||||||
|
|
||||||
vvMap := p.Get("map").(map[string]myTestObject)
|
vvMap := p.Get("map").(map[string]myTestObject)
|
||||||
if vvMap["key 1"].name == "modified" {
|
if vvMap["key 1"].Name == "modified" {
|
||||||
t.Fatalf("expected map to be immutable but caller was able to change its value")
|
t.Fatalf("expected map to be immutable but caller was able to change its value")
|
||||||
}
|
}
|
||||||
|
|
||||||
// object value, it's immutable at all cases.
|
// object value, it's immutable at all cases.
|
||||||
p.SetImmutable("obj", myTestObject{"value"})
|
p.SetImmutable("obj", myTestObject{"value"})
|
||||||
vObj := p.Get("obj").(myTestObject)
|
vObj := p.Get("obj").(myTestObject)
|
||||||
vObj.name = "modified"
|
vObj.Name = "modified"
|
||||||
|
|
||||||
vvObj := p.Get("obj").(myTestObject)
|
vvObj := p.Get("obj").(myTestObject)
|
||||||
if vvObj.name == "modified" {
|
if vvObj.Name == "modified" {
|
||||||
t.Fatalf("expected obj to be immutable but caller was able to change its value")
|
t.Fatalf("expected obj to be immutable but caller was able to change its value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,10 +88,10 @@ func TestImmutable(t *testing.T) {
|
||||||
// so it can't be changed by-design
|
// so it can't be changed by-design
|
||||||
vObjP := p.Get("objp").(myTestObject)
|
vObjP := p.Get("objp").(myTestObject)
|
||||||
|
|
||||||
vObjP.name = "modified"
|
vObjP.Name = "modified"
|
||||||
|
|
||||||
vvObjP := p.Get("objp").(myTestObject)
|
vvObjP := p.Get("objp").(myTestObject)
|
||||||
if vvObjP.name == "modified" {
|
if vvObjP.Name == "modified" {
|
||||||
t.Fatalf("expected objp to be immutable but caller was able to change its value")
|
t.Fatalf("expected objp to be immutable but caller was able to change its value")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -100,13 +103,13 @@ func TestImmutableSetOnlyWithSetImmutable(t *testing.T) {
|
||||||
|
|
||||||
p.Set("objp", &myTestObject{"modified"})
|
p.Set("objp", &myTestObject{"modified"})
|
||||||
vObjP := p.Get("objp").(myTestObject)
|
vObjP := p.Get("objp").(myTestObject)
|
||||||
if vObjP.name == "modified" {
|
if vObjP.Name == "modified" {
|
||||||
t.Fatalf("caller should not be able to change the immutable entry with a simple `Set`")
|
t.Fatalf("caller should not be able to change the immutable entry with a simple `Set`")
|
||||||
}
|
}
|
||||||
|
|
||||||
p.SetImmutable("objp", &myTestObject{"value with SetImmutable"})
|
p.SetImmutable("objp", &myTestObject{"value with SetImmutable"})
|
||||||
vvObjP := p.Get("objp").(myTestObject)
|
vvObjP := p.Get("objp").(myTestObject)
|
||||||
if vvObjP.name != "value with SetImmutable" {
|
if vvObjP.Name != "value with SetImmutable" {
|
||||||
t.Fatalf("caller should be able to change the immutable entry with a `SetImmutable`")
|
t.Fatalf("caller should be able to change the immutable entry with a `SetImmutable`")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -119,3 +122,46 @@ func TestGetInt64Default(t *testing.T) {
|
||||||
t.Fatalf("unexpected value of %d", v)
|
t.Fatalf("unexpected value of %d", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJSON(t *testing.T) {
|
||||||
|
var p Store
|
||||||
|
|
||||||
|
p.Set("key1", "value1")
|
||||||
|
p.Set("key2", 2)
|
||||||
|
p.Set("key3", myTestObject{Name: "makis"})
|
||||||
|
|
||||||
|
b, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedJSON := []byte(`[{"key":"key1","value":"value1"},{"key":"key2","value":2},{"key":"key3","value":{"name":"makis"}}]`)
|
||||||
|
|
||||||
|
if !bytes.Equal(b, expectedJSON) {
|
||||||
|
t.Fatalf("expected: %s but got: %s", string(expectedJSON), string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
var newStore Store
|
||||||
|
if err = json.Unmarshal(b, &newStore); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, v := range newStore {
|
||||||
|
expected, got := p.Get(v.Key), v.ValueRaw
|
||||||
|
|
||||||
|
if ex, g := fmt.Sprintf("%v", expected), fmt.Sprintf("%v", got); ex != g {
|
||||||
|
if _, isMap := got.(map[string]interface{}); isMap {
|
||||||
|
// was struct but converted into map (as expected).
|
||||||
|
b1, _ := json.Marshal(expected)
|
||||||
|
b2, _ := json.Marshal(got)
|
||||||
|
|
||||||
|
if !bytes.Equal(b1, b2) {
|
||||||
|
t.Fatalf("[%d] JSON expected: %s but got: %s", i, string(b1), string(b2))
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Fatalf("[%d] expected: %s but got: %s", i, ex, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ Builtin Handlers
|
||||||
| [HTTP method override](methodoverride) | [iris/middleware/methodoverride/methodoverride_test.go](https://github.com/kataras/iris/blob/master/middleware/methodoverride/methodoverride_test.go) |
|
| [HTTP method override](methodoverride) | [iris/middleware/methodoverride/methodoverride_test.go](https://github.com/kataras/iris/blob/master/middleware/methodoverride/methodoverride_test.go) |
|
||||||
| [profiling (pprof)](pprof) | [iris/_examples/miscellaneous/pprof](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/pprof) |
|
| [profiling (pprof)](pprof) | [iris/_examples/miscellaneous/pprof](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/pprof) |
|
||||||
| [Google reCAPTCHA](recaptcha) | [iris/_examples/miscellaneous/recaptcha](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/recaptcha) |
|
| [Google reCAPTCHA](recaptcha) | [iris/_examples/miscellaneous/recaptcha](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/recaptcha) |
|
||||||
|
| [hCaptcha](hcaptcha) | [iris/_examples/miscellaneous/recaptcha](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/hcaptcha) |
|
||||||
| [recovery](recover) | [iris/_examples/miscellaneous/recover](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/recover) |
|
| [recovery](recover) | [iris/_examples/miscellaneous/recover](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/recover) |
|
||||||
|
|
||||||
Community made
|
Community made
|
||||||
|
|
162
middleware/hcaptcha/hcaptcha.go
Normal file
162
middleware/hcaptcha/hcaptcha.go
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
package hcaptcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/kataras/iris/v12/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ResponseContextKey is the default request's context key that response of a hcaptcha request is kept.
|
||||||
|
ResponseContextKey string = "iris.hcaptcha"
|
||||||
|
// DefaultFailureHandler is the default HTTP handler that is fired on hcaptcha failures.
|
||||||
|
// See `Client.FailureHandler`.
|
||||||
|
DefaultFailureHandler = func(ctx context.Context) {
|
||||||
|
ctx.StopWithStatus(http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client represents the hcaptcha client.
|
||||||
|
type Client struct {
|
||||||
|
// FailureHandler if specified, fired when user does not complete hcaptcha successfully.
|
||||||
|
// Failure and error codes information are kept as `Response` type
|
||||||
|
// at the Request's Context key of "hcaptcha".
|
||||||
|
//
|
||||||
|
// Defaults to a handler that writes a status code of 429 (Too Many Requests)
|
||||||
|
// and without additional information.
|
||||||
|
FailureHandler context.Handler
|
||||||
|
|
||||||
|
secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option declares an option for the hcaptcha client.
|
||||||
|
// See `New` package-level function.
|
||||||
|
type Option func(*Client)
|
||||||
|
|
||||||
|
// Response is the hcaptcha JSON response.
|
||||||
|
type Response struct {
|
||||||
|
ChallengeTS string `json:"challenge_ts"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
ErrorCodes []string `json:"error-codes,omitempty"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Credit bool `json:"credit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// New accepts a hpcatcha secret key and returns a new hcaptcha HTTP Client.
|
||||||
|
//
|
||||||
|
// Instructions at: https://docs.hcaptcha.com/.
|
||||||
|
//
|
||||||
|
// See its `Handler` and `SiteVerify` for details.
|
||||||
|
func New(secret string, options ...Option) context.Handler {
|
||||||
|
c := &Client{
|
||||||
|
FailureHandler: DefaultFailureHandler,
|
||||||
|
secret: secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range options {
|
||||||
|
opt(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler is the HTTP route middleware featured hcaptcha validation.
|
||||||
|
// It calls the `SiteVerify` method and fires the "next" when user completed the hcaptcha successfully,
|
||||||
|
// otherwise it calls the Client's `FailureHandler`.
|
||||||
|
// The hcaptcha's `Response` (which contains any `ErrorCodes`)
|
||||||
|
// is saved on the Request's Context (see `GetResponseFromContext`).
|
||||||
|
func (c *Client) Handler(ctx context.Context) {
|
||||||
|
v := SiteVerify(ctx, c.secret)
|
||||||
|
ctx.Values().Set(ResponseContextKey, v)
|
||||||
|
if v.Success {
|
||||||
|
ctx.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.FailureHandler != nil {
|
||||||
|
c.FailureHandler(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseFormValue = "h-captcha-response"
|
||||||
|
const apiURL = "https://hcaptcha.com/siteverify"
|
||||||
|
|
||||||
|
// SiteVerify accepts an Iris Context and a secret key (https://dashboard.hcaptcha.com/settings).
|
||||||
|
// It returns the hcaptcha's `Response`.
|
||||||
|
// The `response.Success` reports whether the validation passed.
|
||||||
|
// Any errors are passed through the `response.ErrorCodes` field.
|
||||||
|
func SiteVerify(ctx context.Context, secret string) (response Response) {
|
||||||
|
generatedResponseID := ctx.FormValue("h-captcha-response")
|
||||||
|
|
||||||
|
if generatedResponseID == "" {
|
||||||
|
response.ErrorCodes = append(response.ErrorCodes,
|
||||||
|
"form[h-captcha-response] is empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.PostForm(apiURL,
|
||||||
|
url.Values{
|
||||||
|
"secret": {secret},
|
||||||
|
"response": {generatedResponseID},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorCodes = append(response.ErrorCodes, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorCodes = append(response.ErrorCodes, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, &response)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorCodes = append(response.ErrorCodes, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the hcaptcha `Response` of the current request and reports whether was found or not.
|
||||||
|
func Get(ctx context.Context) (Response, bool) {
|
||||||
|
v := ctx.Values().Get(ResponseContextKey)
|
||||||
|
if v != nil {
|
||||||
|
if response, ok := v.(Response); ok {
|
||||||
|
return response, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script is the hCaptcha's javascript source file that should be incldued in the HTML head or body.
|
||||||
|
const Script = "https://hcaptcha.com/1/api.js"
|
||||||
|
|
||||||
|
// HTMLForm is the default HTML form for clients.
|
||||||
|
// It's totally optional, use your own code for the best possible result depending on your web application.
|
||||||
|
// See `ParseForm` and `RenderForm` for more.
|
||||||
|
var HTMLForm = `<form action="%s" method="POST">
|
||||||
|
<script src="%s"></script>
|
||||||
|
<div class="h-captcha" data-sitekey="%s"></div>
|
||||||
|
<input type="submit" name="button" value="OK">
|
||||||
|
</form>`
|
||||||
|
|
||||||
|
// ParseForm parses the `HTMLForm` with the necessary parameters and returns
|
||||||
|
// its result for render.
|
||||||
|
func ParseForm(dataSiteKey, postActionRelativePath string) string {
|
||||||
|
return fmt.Sprintf(HTMLForm, postActionRelativePath, Script, dataSiteKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderForm writes the `HTMLForm` to "w" response writer.
|
||||||
|
// See `_examples/basic/register_form.html` example for a custom form instead.
|
||||||
|
func RenderForm(ctx context.Context, dataSiteKey, postActionRelativePath string) (int, error) {
|
||||||
|
return ctx.HTML(ParseForm(dataSiteKey, postActionRelativePath))
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user