Skip to content

Commit e2bd7f8

Browse files
committed
Start v4 versioning by supporting Echo v4.9.x and some older versions. Next release will remove extractors from this repo and start using Echo v4.10.0 exposed ones.
1 parent 8a206e9 commit e2bd7f8

File tree

6 files changed

+227
-27
lines changed

6 files changed

+227
-27
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ This repository does not use semantic versioning. MAJOR version tracks which Ech
99
tracks API changes (possibly backwards incompatible) and PATCH version is incremented for fixes.
1010

1111
For Echo `v4` use `v4.x.y` releases.
12+
Minimal needed Echo versions:
13+
* `v4.0.0` needs Echo `v4.7.0+`
1214

1315
`main` branch is compatible with the latest Echo version.
1416

extrators.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2016 LabStack and Echo contributors
3+
4+
package echojwt
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
"github.com/labstack/echo/v4"
10+
"github.com/labstack/echo/v4/middleware"
11+
"net/textproto"
12+
"strings"
13+
)
14+
15+
const (
16+
// extractorLimit is arbitrary number to limit values extractor can return. this limits possible resource exhaustion
17+
// attack vector
18+
extractorLimit = 20
19+
)
20+
21+
var errHeaderExtractorValueMissing = errors.New("missing value in request header")
22+
var errHeaderExtractorValueInvalid = errors.New("invalid value in request header")
23+
var errQueryExtractorValueMissing = errors.New("missing value in the query string")
24+
var errParamExtractorValueMissing = errors.New("missing value in path params")
25+
var errCookieExtractorValueMissing = errors.New("missing value in cookies")
26+
var errFormExtractorValueMissing = errors.New("missing value in the form")
27+
28+
// CreateExtractors creates ValuesExtractors from given lookups.
29+
// Lookups is a string in the form of "<source>:<name>" or "<source>:<name>,<source>:<name>" that is used
30+
// to extract key from the request.
31+
// Possible values:
32+
// - "header:<name>" or "header:<name>:<cut-prefix>"
33+
// `<cut-prefix>` is argument value to cut/trim prefix of the extracted value. This is useful if header
34+
// value has static prefix like `Authorization: <auth-scheme> <authorisation-parameters>` where part that we
35+
// want to cut is `<auth-scheme> ` note the space at the end.
36+
// In case of basic authentication `Authorization: Basic <credentials>` prefix we want to remove is `Basic `.
37+
// - "query:<name>"
38+
// - "param:<name>"
39+
// - "form:<name>"
40+
// - "cookie:<name>"
41+
//
42+
// Multiple sources example:
43+
// - "header:Authorization,header:X-Api-Key"
44+
func CreateExtractors(lookups string) ([]middleware.ValuesExtractor, error) {
45+
if lookups == "" {
46+
return nil, nil
47+
}
48+
sources := strings.Split(lookups, ",")
49+
var extractors = make([]middleware.ValuesExtractor, 0)
50+
for _, source := range sources {
51+
parts := strings.Split(source, ":")
52+
if len(parts) < 2 {
53+
return nil, fmt.Errorf("extractor source for lookup could not be split into needed parts: %v", source)
54+
}
55+
56+
switch parts[0] {
57+
case "query":
58+
extractors = append(extractors, valuesFromQuery(parts[1]))
59+
case "param":
60+
extractors = append(extractors, valuesFromParam(parts[1]))
61+
case "cookie":
62+
extractors = append(extractors, valuesFromCookie(parts[1]))
63+
case "form":
64+
extractors = append(extractors, valuesFromForm(parts[1]))
65+
case "header":
66+
prefix := ""
67+
if len(parts) > 2 {
68+
prefix = parts[2]
69+
}
70+
extractors = append(extractors, valuesFromHeader(parts[1], prefix))
71+
}
72+
}
73+
return extractors, nil
74+
}
75+
76+
// valuesFromHeader returns a functions that extracts values from the request header.
77+
// valuePrefix is parameter to remove first part (prefix) of the extracted value. This is useful if header value has static
78+
// prefix like `Authorization: <auth-scheme> <authorisation-parameters>` where part that we want to remove is `<auth-scheme> `
79+
// note the space at the end. In case of basic authentication `Authorization: Basic <credentials>` prefix we want to remove
80+
// is `Basic `. In case of JWT tokens `Authorization: Bearer <token>` prefix is `Bearer `.
81+
// If prefix is left empty the whole value is returned.
82+
func valuesFromHeader(header string, valuePrefix string) middleware.ValuesExtractor {
83+
prefixLen := len(valuePrefix)
84+
// standard library parses http.Request header keys in canonical form but we may provide something else so fix this
85+
header = textproto.CanonicalMIMEHeaderKey(header)
86+
return func(c echo.Context) ([]string, error) {
87+
values := c.Request().Header.Values(header)
88+
if len(values) == 0 {
89+
return nil, errHeaderExtractorValueMissing
90+
}
91+
92+
result := make([]string, 0)
93+
for i, value := range values {
94+
if prefixLen == 0 {
95+
result = append(result, value)
96+
if i >= extractorLimit-1 {
97+
break
98+
}
99+
continue
100+
}
101+
if len(value) > prefixLen && strings.EqualFold(value[:prefixLen], valuePrefix) {
102+
result = append(result, value[prefixLen:])
103+
if i >= extractorLimit-1 {
104+
break
105+
}
106+
}
107+
}
108+
109+
if len(result) == 0 {
110+
if prefixLen > 0 {
111+
return nil, errHeaderExtractorValueInvalid
112+
}
113+
return nil, errHeaderExtractorValueMissing
114+
}
115+
return result, nil
116+
}
117+
}
118+
119+
// valuesFromQuery returns a function that extracts values from the query string.
120+
func valuesFromQuery(param string) middleware.ValuesExtractor {
121+
return func(c echo.Context) ([]string, error) {
122+
result := c.QueryParams()[param]
123+
if len(result) == 0 {
124+
return nil, errQueryExtractorValueMissing
125+
} else if len(result) > extractorLimit-1 {
126+
result = result[:extractorLimit]
127+
}
128+
return result, nil
129+
}
130+
}
131+
132+
// valuesFromParam returns a function that extracts values from the url param string.
133+
func valuesFromParam(param string) middleware.ValuesExtractor {
134+
return func(c echo.Context) ([]string, error) {
135+
result := make([]string, 0)
136+
paramVales := c.ParamValues()
137+
for i, p := range c.ParamNames() {
138+
if param == p {
139+
result = append(result, paramVales[i])
140+
if i >= extractorLimit-1 {
141+
break
142+
}
143+
}
144+
}
145+
if len(result) == 0 {
146+
return nil, errParamExtractorValueMissing
147+
}
148+
return result, nil
149+
}
150+
}
151+
152+
// valuesFromCookie returns a function that extracts values from the named cookie.
153+
func valuesFromCookie(name string) middleware.ValuesExtractor {
154+
return func(c echo.Context) ([]string, error) {
155+
cookies := c.Cookies()
156+
if len(cookies) == 0 {
157+
return nil, errCookieExtractorValueMissing
158+
}
159+
160+
result := make([]string, 0)
161+
for i, cookie := range cookies {
162+
if name == cookie.Name {
163+
result = append(result, cookie.Value)
164+
if i >= extractorLimit-1 {
165+
break
166+
}
167+
}
168+
}
169+
if len(result) == 0 {
170+
return nil, errCookieExtractorValueMissing
171+
}
172+
return result, nil
173+
}
174+
}
175+
176+
// valuesFromForm returns a function that extracts values from the form field.
177+
func valuesFromForm(name string) middleware.ValuesExtractor {
178+
return func(c echo.Context) ([]string, error) {
179+
if c.Request().Form == nil {
180+
_ = c.Request().ParseMultipartForm(32 << 20) // same what `c.Request().FormValue(name)` does
181+
}
182+
values := c.Request().Form[name]
183+
if len(values) == 0 {
184+
return nil, errFormExtractorValueMissing
185+
}
186+
if len(values) > extractorLimit-1 {
187+
values = values[:extractorLimit]
188+
}
189+
result := append([]string{}, values...)
190+
return result, nil
191+
}
192+
}

go.mod

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ module github.com/labstack/echo-jwt/v4
33
go 1.17
44

55
require (
6-
github.com/golang-jwt/jwt/v4 v4.4.2
7-
github.com/labstack/echo/v4 v4.9.2-0.20221125112752-466bf80e418b
6+
github.com/golang-jwt/jwt/v4 v4.4.3
7+
github.com/labstack/echo/v4 v4.9.0
88
github.com/stretchr/testify v1.8.1
99
)
1010

@@ -17,10 +17,10 @@ require (
1717
github.com/pmezard/go-difflib v1.0.0 // indirect
1818
github.com/valyala/bytebufferpool v1.0.0 // indirect
1919
github.com/valyala/fasttemplate v1.2.2 // indirect
20-
golang.org/x/crypto v0.3.0 // indirect
21-
golang.org/x/net v0.2.0 // indirect
22-
golang.org/x/sys v0.2.0 // indirect
23-
golang.org/x/text v0.4.0 // indirect
24-
golang.org/x/time v0.2.0 // indirect
20+
golang.org/x/crypto v0.4.0 // indirect
21+
golang.org/x/net v0.4.0 // indirect
22+
golang.org/x/sys v0.3.0 // indirect
23+
golang.org/x/text v0.5.0 // indirect
24+
golang.org/x/time v0.3.0 // indirect
2525
gopkg.in/yaml.v3 v3.0.1 // indirect
2626
)

go.sum

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
33
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
44
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
55
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
6-
github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
7-
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
8-
github.com/labstack/echo/v4 v4.9.2-0.20221125112752-466bf80e418b h1:Eti1w5N61XcwiYIunbW8d0kK19bJgHfgVAtvwdE0wM4=
9-
github.com/labstack/echo/v4 v4.9.2-0.20221125112752-466bf80e418b/go.mod h1:kq/6ZjeSsnAFnNX90OOjzKHVbr9EpVofsWO0outT4kk=
6+
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
7+
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
8+
github.com/labstack/echo/v4 v4.9.0 h1:wPOF1CE6gvt/kmbMR4dGzWvHMPT+sAEUJOwOTtvITVY=
9+
github.com/labstack/echo/v4 v4.9.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
10+
github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
1011
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
1112
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
1213
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
@@ -32,39 +33,44 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
3233
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
3334
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
3435
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
36+
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
3537
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
36-
golang.org/x/crypto v0.2.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
37-
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
38-
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
38+
golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
39+
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
3940
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
4041
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
4142
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
43+
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
4244
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
43-
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
44-
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
45+
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
46+
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
47+
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
4548
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
4649
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
4750
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
4851
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
52+
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
4953
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5054
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5155
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5256
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5357
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5458
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5559
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
56-
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
57-
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
60+
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
61+
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5862
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
5963
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
60-
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
64+
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
6165
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
6266
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
67+
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
6368
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
64-
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
65-
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
66-
golang.org/x/time v0.2.0 h1:52I/1L54xyEQAYdtcSuxtiT84KGYTBGXwayxmIpNJhE=
67-
golang.org/x/time v0.2.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
69+
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
70+
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
71+
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
72+
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
73+
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
6874
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
6975
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
7076
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

jwt.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func (config Config) ToMiddleware() (echo.MiddlewareFunc, error) {
173173
if config.ParseTokenFunc == nil {
174174
config.ParseTokenFunc = config.defaultParseTokenFunc
175175
}
176-
extractors, err := middleware.CreateExtractors(config.TokenLookup)
176+
extractors, err := CreateExtractors(config.TokenLookup)
177177
if err != nil {
178178
return nil, err
179179
}
@@ -226,9 +226,9 @@ func (config Config) ToMiddleware() (echo.MiddlewareFunc, error) {
226226
return tmpErr
227227
}
228228
if lastTokenErr == nil {
229-
return ErrJWTMissing.WithInternal(err)
229+
return echo.NewHTTPError(http.StatusUnauthorized, "missing or malformed jwt").SetInternal(err)
230230
}
231-
return ErrJWTInvalid.WithInternal(err)
231+
return echo.NewHTTPError(http.StatusUnauthorized, "invalid or expired jwt").SetInternal(err)
232232
}
233233
}, nil
234234
}

jwt_extranal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
"time"
1717
)
1818

19-
func ExampleJWTWithConfig_usage() {
19+
func ExampleWithConfig_usage() {
2020
e := echo.New()
2121

2222
e.Use(echojwt.WithConfig(echojwt.Config{

0 commit comments

Comments
 (0)