d7da1b6044
The Pinning Services API standard mandates Bearer token authentication. This adds JWT bearer token authentication to the IPFS Cluster REST and PINSVC APIs. The basic_auth_credentials configuration option needs to be not null and have at least one username/passwords entry. A user authenticated via Basic Auth can then "POST /token" and obtain a json object: ```json { "token" : "<JWTtoken>" } ``` The JWT token has the "iss" (issuer) field set to the Basic auth user that authorized its creation and is HMAC-signed with its password. When basic-auth-credentials are set, the APIs will verify that requests come with either Basic Auth authorization header or with a Bearer token authorization header. Bearer tokens will be decoded and the signature will be verified against the password of the issuer. At the moment we provide no support to revoke tokens, set "expiration date", "not before" etc, but this may come in the future.
645 lines
15 KiB
Go
645 lines
15 KiB
Go
package common
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ipfs-cluster/ipfs-cluster/api"
|
|
"github.com/ipfs-cluster/ipfs-cluster/api/common/test"
|
|
rpctest "github.com/ipfs-cluster/ipfs-cluster/test"
|
|
|
|
libp2p "github.com/libp2p/go-libp2p"
|
|
rpc "github.com/libp2p/go-libp2p-gorpc"
|
|
ma "github.com/multiformats/go-multiaddr"
|
|
)
|
|
|
|
const (
|
|
SSLCertFile = "test/server.crt"
|
|
SSLKeyFile = "test/server.key"
|
|
validUserName = "validUserName"
|
|
validUserPassword = "validUserPassword"
|
|
adminUserName = "adminUserName"
|
|
adminUserPassword = "adminUserPassword"
|
|
invalidUserName = "invalidUserName"
|
|
invalidUserPassword = "invalidUserPassword"
|
|
)
|
|
|
|
var (
|
|
validToken, _ = generateSignedTokenString(validUserName, validUserPassword)
|
|
invalidToken, _ = generateSignedTokenString(invalidUserName, invalidUserPassword)
|
|
)
|
|
|
|
func routes(c *rpc.Client) []Route {
|
|
return []Route{
|
|
{
|
|
"Test",
|
|
"GET",
|
|
"/test",
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Add("Content-Type", "application/json")
|
|
w.Write([]byte(`{ "thisis": "atest" }`))
|
|
},
|
|
},
|
|
}
|
|
|
|
}
|
|
|
|
func testAPIwithConfig(t *testing.T, cfg *Config, name string) *API {
|
|
ctx := context.Background()
|
|
apiMAddr, _ := ma.NewMultiaddr("/ip4/127.0.0.1/tcp/0")
|
|
h, err := libp2p.New(libp2p.ListenAddrs(apiMAddr))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg.HTTPListenAddr = []ma.Multiaddr{apiMAddr}
|
|
|
|
rest, err := NewAPIWithHost(ctx, cfg, h, routes)
|
|
if err != nil {
|
|
t.Fatalf("should be able to create a new %s API: %s", name, err)
|
|
}
|
|
|
|
// No keep alive for tests
|
|
rest.server.SetKeepAlivesEnabled(false)
|
|
rest.SetClient(rpctest.NewMockRPCClient(t))
|
|
|
|
return rest
|
|
}
|
|
|
|
func testAPI(t *testing.T) *API {
|
|
cfg := newDefaultTestConfig(t)
|
|
cfg.CORSAllowedOrigins = []string{test.ClientOrigin}
|
|
cfg.CORSAllowedMethods = []string{"GET", "POST", "DELETE"}
|
|
//cfg.CORSAllowedHeaders = []string{"Content-Type"}
|
|
cfg.CORSMaxAge = 10 * time.Minute
|
|
|
|
return testAPIwithConfig(t, cfg, "basic")
|
|
}
|
|
|
|
func testHTTPSAPI(t *testing.T) *API {
|
|
cfg := newDefaultTestConfig(t)
|
|
cfg.PathSSLCertFile = SSLCertFile
|
|
cfg.PathSSLKeyFile = SSLKeyFile
|
|
var err error
|
|
cfg.TLS, err = newTLSConfig(cfg.PathSSLCertFile, cfg.PathSSLKeyFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return testAPIwithConfig(t, cfg, "https")
|
|
}
|
|
|
|
func testAPIwithBasicAuth(t *testing.T) *API {
|
|
cfg := newDefaultTestConfig(t)
|
|
cfg.BasicAuthCredentials = map[string]string{
|
|
validUserName: validUserPassword,
|
|
adminUserName: adminUserPassword,
|
|
}
|
|
|
|
return testAPIwithConfig(t, cfg, "Basic Authentication")
|
|
}
|
|
|
|
func TestAPIShutdown(t *testing.T) {
|
|
ctx := context.Background()
|
|
rest := testAPI(t)
|
|
err := rest.Shutdown(ctx)
|
|
if err != nil {
|
|
t.Error("should shutdown cleanly: ", err)
|
|
}
|
|
// test shutting down twice
|
|
rest.Shutdown(ctx)
|
|
|
|
}
|
|
|
|
func TestHTTPSTestEndpoint(t *testing.T) {
|
|
ctx := context.Background()
|
|
rest := testAPI(t)
|
|
httpsrest := testHTTPSAPI(t)
|
|
defer rest.Shutdown(ctx)
|
|
defer httpsrest.Shutdown(ctx)
|
|
|
|
tf := func(t *testing.T, url test.URLFunc) {
|
|
r := make(map[string]string)
|
|
test.MakeGet(t, rest, url(rest)+"/test", &r)
|
|
if r["thisis"] != "atest" {
|
|
t.Error("expected correct body")
|
|
}
|
|
}
|
|
|
|
httpstf := func(t *testing.T, url test.URLFunc) {
|
|
r := make(map[string]string)
|
|
test.MakeGet(t, httpsrest, url(httpsrest)+"/test", &r)
|
|
if r["thisis"] != "atest" {
|
|
t.Error("expected correct body")
|
|
}
|
|
}
|
|
|
|
test.BothEndpoints(t, tf)
|
|
test.HTTPSEndPoint(t, httpstf)
|
|
}
|
|
|
|
func TestAPILogging(t *testing.T) {
|
|
ctx := context.Background()
|
|
cfg := newDefaultTestConfig(t)
|
|
|
|
logFile, err := filepath.Abs("http.log")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.HTTPLogFile = logFile
|
|
|
|
rest := testAPIwithConfig(t, cfg, "log_enabled")
|
|
defer os.Remove(cfg.HTTPLogFile)
|
|
|
|
info, err := os.Stat(cfg.HTTPLogFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Size() > 0 {
|
|
t.Errorf("expected empty log file")
|
|
}
|
|
|
|
id := api.ID{}
|
|
test.MakeGet(t, rest, test.HTTPURL(rest)+"/test", &id)
|
|
|
|
info, err = os.Stat(cfg.HTTPLogFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
size1 := info.Size()
|
|
if size1 == 0 {
|
|
t.Error("did not expect an empty log file")
|
|
}
|
|
|
|
// Restart API and make sure that logs are being appended
|
|
rest.Shutdown(ctx)
|
|
|
|
rest = testAPIwithConfig(t, cfg, "log_enabled")
|
|
defer rest.Shutdown(ctx)
|
|
|
|
test.MakeGet(t, rest, test.HTTPURL(rest)+"/id", &id)
|
|
|
|
info, err = os.Stat(cfg.HTTPLogFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
size2 := info.Size()
|
|
if size2 == 0 {
|
|
t.Error("did not expect an empty log file")
|
|
}
|
|
|
|
if !(size2 > size1) {
|
|
t.Error("logs were not appended")
|
|
}
|
|
|
|
}
|
|
|
|
func TestNotFoundHandler(t *testing.T) {
|
|
ctx := context.Background()
|
|
rest := testAPI(t)
|
|
defer rest.Shutdown(ctx)
|
|
|
|
tf := func(t *testing.T, url test.URLFunc) {
|
|
bytes := make([]byte, 10)
|
|
for i := 0; i < 10; i++ {
|
|
bytes[i] = byte(65 + rand.Intn(25)) //A=65 and Z = 65+25
|
|
}
|
|
|
|
var errResp api.Error
|
|
test.MakePost(t, rest, url(rest)+"/"+string(bytes), []byte{}, &errResp)
|
|
if errResp.Code != 404 {
|
|
t.Errorf("expected error not found: %+v", errResp)
|
|
}
|
|
|
|
var errResp1 api.Error
|
|
test.MakeGet(t, rest, url(rest)+"/"+string(bytes), &errResp1)
|
|
if errResp1.Code != 404 {
|
|
t.Errorf("expected error not found: %+v", errResp)
|
|
}
|
|
}
|
|
|
|
test.BothEndpoints(t, tf)
|
|
}
|
|
|
|
func TestCORS(t *testing.T) {
|
|
ctx := context.Background()
|
|
rest := testAPI(t)
|
|
defer rest.Shutdown(ctx)
|
|
|
|
type testcase struct {
|
|
method string
|
|
path string
|
|
}
|
|
|
|
tf := func(t *testing.T, url test.URLFunc) {
|
|
reqHeaders := make(http.Header)
|
|
reqHeaders.Set("Origin", "myorigin")
|
|
reqHeaders.Set("Access-Control-Request-Headers", "Content-Type")
|
|
|
|
for _, tc := range []testcase{
|
|
{"GET", "/test"},
|
|
// testcase{},
|
|
} {
|
|
reqHeaders.Set("Access-Control-Request-Method", tc.method)
|
|
headers := test.MakeOptions(t, rest, url(rest)+tc.path, reqHeaders)
|
|
aorigin := headers.Get("Access-Control-Allow-Origin")
|
|
amethods := headers.Get("Access-Control-Allow-Methods")
|
|
aheaders := headers.Get("Access-Control-Allow-Headers")
|
|
acreds := headers.Get("Access-Control-Allow-Credentials")
|
|
maxage := headers.Get("Access-Control-Max-Age")
|
|
|
|
if aorigin != "myorigin" {
|
|
t.Error("Bad ACA-Origin:", aorigin)
|
|
}
|
|
|
|
if amethods != tc.method {
|
|
t.Error("Bad ACA-Methods:", amethods)
|
|
}
|
|
|
|
if aheaders != "Content-Type" {
|
|
t.Error("Bad ACA-Headers:", aheaders)
|
|
}
|
|
|
|
if acreds != "true" {
|
|
t.Error("Bad ACA-Credentials:", acreds)
|
|
}
|
|
|
|
if maxage != "600" {
|
|
t.Error("Bad AC-Max-Age:", maxage)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
test.BothEndpoints(t, tf)
|
|
}
|
|
|
|
type responseChecker func(*http.Response) error
|
|
type requestShaper func(*http.Request) error
|
|
|
|
type httpTestcase struct {
|
|
method string
|
|
path string
|
|
header http.Header
|
|
body io.ReadCloser
|
|
shaper requestShaper
|
|
checker responseChecker
|
|
}
|
|
|
|
func httpStatusCodeChecker(resp *http.Response, expectedStatus int) error {
|
|
if resp.StatusCode == expectedStatus {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode)
|
|
}
|
|
|
|
func assertHTTPStatusIsUnauthoriazed(resp *http.Response) error {
|
|
return httpStatusCodeChecker(resp, http.StatusUnauthorized)
|
|
}
|
|
|
|
func assertHTTPStatusIsTooLarge(resp *http.Response) error {
|
|
return httpStatusCodeChecker(resp, http.StatusRequestHeaderFieldsTooLarge)
|
|
}
|
|
|
|
func makeHTTPStatusNegatedAssert(checker responseChecker) responseChecker {
|
|
return func(resp *http.Response) error {
|
|
if checker(resp) == nil {
|
|
return fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (tc *httpTestcase) getTestFunction(api *API) test.Func {
|
|
return func(t *testing.T, prefixMaker test.URLFunc) {
|
|
h := test.MakeHost(t, api)
|
|
defer h.Close()
|
|
url := prefixMaker(api) + tc.path
|
|
c := test.HTTPClient(t, h, test.IsHTTPS(url))
|
|
req, err := http.NewRequest(tc.method, url, tc.body)
|
|
if err != nil {
|
|
t.Fatal("Failed to assemble a HTTP request: ", err)
|
|
}
|
|
if tc.header != nil {
|
|
req.Header = tc.header
|
|
}
|
|
if tc.shaper != nil {
|
|
err := tc.shaper(req)
|
|
if err != nil {
|
|
t.Fatal("Failed to shape a HTTP request: ", err)
|
|
}
|
|
}
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
t.Fatal("Failed to make a HTTP request: ", err)
|
|
}
|
|
if tc.checker != nil {
|
|
if err := tc.checker(resp); err != nil {
|
|
r, e := httputil.DumpRequest(req, true)
|
|
if e != nil {
|
|
t.Errorf("Assertion failed with: %q", err)
|
|
} else {
|
|
t.Errorf("Assertion failed with: %q on request: \n%.100s", err, r)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func makeBasicAuthRequestShaper(username, password string) requestShaper {
|
|
return func(req *http.Request) error {
|
|
req.SetBasicAuth(username, password)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func makeTokenAuthRequestShaper(token string) requestShaper {
|
|
return func(req *http.Request) error {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func makeLongHeaderShaper(size int) requestShaper {
|
|
return func(req *http.Request) error {
|
|
for sz := size; sz > 0; sz -= 8 {
|
|
req.Header.Add("Foo", "bar")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func TestBasicAuth(t *testing.T) {
|
|
ctx := context.Background()
|
|
rest := testAPIwithBasicAuth(t)
|
|
defer rest.Shutdown(ctx)
|
|
|
|
for _, tc := range []httpTestcase{
|
|
{},
|
|
{
|
|
method: "",
|
|
path: "",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "DELETE",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "HEAD",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "OPTIONS", // Always allowed for CORS
|
|
path: "/foo",
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
{
|
|
method: "PUT",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "TRACE",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "CONNECT",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "BAR",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
shaper: makeBasicAuthRequestShaper(invalidUserName, invalidUserPassword),
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
shaper: makeBasicAuthRequestShaper(validUserName, invalidUserPassword),
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
shaper: makeBasicAuthRequestShaper(invalidUserName, validUserPassword),
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
shaper: makeBasicAuthRequestShaper(adminUserName, validUserPassword),
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword),
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "/foo",
|
|
shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword),
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
{
|
|
method: "DELETE",
|
|
path: "/foo",
|
|
shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword),
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
{
|
|
method: "BAR",
|
|
path: "/foo",
|
|
shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword),
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/test",
|
|
shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword),
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
} {
|
|
test.BothEndpoints(t, tc.getTestFunction(rest))
|
|
}
|
|
}
|
|
|
|
func TestTokenAuth(t *testing.T) {
|
|
ctx := context.Background()
|
|
rest := testAPIwithBasicAuth(t)
|
|
defer rest.Shutdown(ctx)
|
|
|
|
for _, tc := range []httpTestcase{
|
|
{},
|
|
{
|
|
method: "",
|
|
path: "",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "DELETE",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "HEAD",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "OPTIONS", // Always allowed for CORS
|
|
path: "/foo",
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
{
|
|
method: "PUT",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "TRACE",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "CONNECT",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "BAR",
|
|
path: "/foo",
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
shaper: makeTokenAuthRequestShaper(invalidToken),
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
shaper: makeTokenAuthRequestShaper(invalidToken),
|
|
checker: assertHTTPStatusIsUnauthoriazed,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
shaper: makeTokenAuthRequestShaper(validToken),
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "/foo",
|
|
shaper: makeTokenAuthRequestShaper(validToken),
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
{
|
|
method: "DELETE",
|
|
path: "/foo",
|
|
shaper: makeTokenAuthRequestShaper(validToken),
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
{
|
|
method: "BAR",
|
|
path: "/foo",
|
|
shaper: makeTokenAuthRequestShaper(validToken),
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/test",
|
|
shaper: makeTokenAuthRequestShaper(validToken),
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
|
|
},
|
|
} {
|
|
test.BothEndpoints(t, tc.getTestFunction(rest))
|
|
}
|
|
}
|
|
|
|
func TestLimitMaxHeaderSize(t *testing.T) {
|
|
maxHeaderBytes := 4 * DefaultMaxHeaderBytes
|
|
cfg := newTestConfig()
|
|
cfg.MaxHeaderBytes = maxHeaderBytes
|
|
ctx := context.Background()
|
|
rest := testAPIwithConfig(t, cfg, "http with maxHeaderBytes")
|
|
defer rest.Shutdown(ctx)
|
|
|
|
for _, tc := range []httpTestcase{
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
shaper: makeLongHeaderShaper(maxHeaderBytes * 2),
|
|
checker: assertHTTPStatusIsTooLarge,
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/foo",
|
|
shaper: makeLongHeaderShaper(maxHeaderBytes / 2),
|
|
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsTooLarge),
|
|
},
|
|
} {
|
|
test.BothEndpoints(t, tc.getTestFunction(rest))
|
|
}
|
|
}
|