Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VDR: Produce did:web documents #2631

Merged
merged 25 commits into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d6a2e0f
added VDR v2 spec, generated code and empty wrapper
woutslakhorst Nov 29, 2023
44dbd8c
VDR: Produce did:web documents
reinkrul Nov 29, 2023
d013f62
copyrights
reinkrul Nov 29, 2023
ce8e642
feedback
reinkrul Nov 30, 2023
b2862e7
did:web in API, correct VM references
reinkrul Nov 30, 2023
9ad3cf8
fixed tests
reinkrul Dec 1, 2023
b0710b5
cleanup
reinkrul Dec 1, 2023
80443d6
tests
reinkrul Dec 1, 2023
e26138d
it's ours
reinkrul Dec 1, 2023
0207b6d
green tests
woutslakhorst Dec 1, 2023
1eb0cb0
implemented Create on VDR v2 API
woutslakhorst Dec 1, 2023
a2a6d6e
added client cmd for web:did creation
woutslakhorst Dec 1, 2023
2262a48
small stuff
woutslakhorst Dec 1, 2023
1944c7a
Merge branch 'master' into vdr/produce-didweb
woutslakhorst Dec 1, 2023
b412627
remove dup method after merge conflict
woutslakhorst Dec 1, 2023
11fbed1
one step further
woutslakhorst Dec 1, 2023
8ae5333
additional files for e2e test
woutslakhorst Dec 1, 2023
6aae5cc
load a VC into a wallet via the internal API
woutslakhorst Dec 4, 2023
d22a378
auth server metadata now only works for did:web, working e2e test for…
woutslakhorst Dec 4, 2023
fa9d503
docker images back to master for e2e-tests
woutslakhorst Dec 4, 2023
d90181a
Merge branch 'master' into vdr/produce-didweb
woutslakhorst Dec 5, 2023
b5aa7d7
fix generation
woutslakhorst Dec 5, 2023
30ef159
fix changed client API signature
woutslakhorst Dec 5, 2023
62c57f7
test fixes
woutslakhorst Dec 5, 2023
88713f4
update cli docs
woutslakhorst Dec 6, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,7 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho

// OAuthAuthorizationServerMetadata returns the Authorization Server's metadata
func (r Wrapper) OAuthAuthorizationServerMetadata(ctx context.Context, request OAuthAuthorizationServerMetadataRequestObject) (OAuthAuthorizationServerMetadataResponseObject, error) {
// TODO: must be web DID once web DID creation and DB are implemented
ownDID := idToNutsDID(request.Id)
ownDID := r.idToDID(request.Id)
owned, err := r.vdr.IsOwner(ctx, ownDID)
if err != nil {
if resolver.IsFunctionalResolveError(err) {
Expand All @@ -302,17 +301,15 @@ func (r Wrapper) OAuthAuthorizationServerMetadata(ctx context.Context, request O
return OAuthAuthorizationServerMetadata200JSONResponse(authorizationServerMetadata(*identity)), nil
}

func (r Wrapper) GetWebDID(ctx context.Context, request GetWebDIDRequestObject) (GetWebDIDResponseObject, error) {
baseURL := *(r.auth.PublicURL().JoinPath(apiPath))
// TODO: must be web DID once web DID creation and DB are implemented
ownDID := idToNutsDID(request.Id)
func (r Wrapper) GetWebDID(_ context.Context, request GetWebDIDRequestObject) (GetWebDIDResponseObject, error) {
ownDID := r.idToDID(request.Id)

document, err := r.vdr.DeriveWebDIDDocument(ctx, baseURL, ownDID)
document, err := r.vdr.ResolveManaged(ownDID)
if err != nil {
if resolver.IsFunctionalResolveError(err) {
return GetWebDID404Response{}, nil
}
log.Logger().WithError(err).Errorf("Could not resolve Nuts DID: %s", ownDID.String())
log.Logger().WithError(err).Errorf("Could not resolve Web DID: %s", ownDID.String())
return nil, errors.New("unable to resolve DID")
}
return GetWebDID200JSONResponse(*document), nil
Expand Down
33 changes: 16 additions & 17 deletions auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import (
"testing"

"github.com/labstack/echo/v4"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/audit"
Expand All @@ -49,14 +48,15 @@ import (

var nutsDID = did.MustParseDID("did:nuts:123")
var webDID = did.MustParseDID("did:web:example.com:iam:123")
var webIDPart = "123"

func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) {
t.Run("ok", func(t *testing.T) {
// 200
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, nutsDID).Return(true, nil)
ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(true, nil)

res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: nutsDID.ID})
res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart})

require.NoError(t, err)
assert.IsType(t, OAuthAuthorizationServerMetadata200JSONResponse{}, res)
Expand All @@ -65,9 +65,9 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) {
t.Run("error - did not managed by this node", func(t *testing.T) {
//404
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, nutsDID)
ctx.vdr.EXPECT().IsOwner(nil, webDID)

res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: nutsDID.ID})
res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart})

assert.Equal(t, 404, statusCodeFrom(err))
assert.EqualError(t, err, "authz server metadata: did not owned")
Expand All @@ -76,9 +76,9 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) {
t.Run("error - did does not exist", func(t *testing.T) {
//404
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, nutsDID).Return(false, resolver.ErrNotFound)
ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(false, resolver.ErrNotFound)

res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: nutsDID.ID})
res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart})

assert.Equal(t, 404, statusCodeFrom(err))
assert.EqualError(t, err, "authz server metadata: unable to find the DID document")
Expand All @@ -87,9 +87,9 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) {
t.Run("error - internal error 500", func(t *testing.T) {
//500
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, nutsDID).Return(false, errors.New("unknown error"))
ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(false, errors.New("unknown error"))

res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: nutsDID.ID})
res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart})

assert.Equal(t, 500, statusCodeFrom(err))
assert.EqualError(t, err, "authz server metadata: unknown error")
Expand All @@ -99,8 +99,7 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) {

func TestWrapper_GetWebDID(t *testing.T) {
webDID := did.MustParseDID("did:web:example.com:iam:123")
publicURL := ssi.MustParseURI("https://example.com").URL
webDIDBaseURL := publicURL.JoinPath("/iam")
id := "123"
ctx := audit.TestContext()
expectedWebDIDDoc := did.Document{
ID: webDID,
Expand All @@ -111,27 +110,27 @@ func TestWrapper_GetWebDID(t *testing.T) {

t.Run("ok", func(t *testing.T) {
test := newTestClient(t)
test.vdr.EXPECT().DeriveWebDIDDocument(gomock.Any(), *webDIDBaseURL, nutsDID).Return(&expectedWebDIDDoc, nil)
test.vdr.EXPECT().ResolveManaged(webDID).Return(&expectedWebDIDDoc, nil)

response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{nutsDID.ID})
response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id})

assert.NoError(t, err)
assert.Equal(t, expectedWebDIDDoc, did.Document(response.(GetWebDID200JSONResponse)))
})
t.Run("unknown DID", func(t *testing.T) {
test := newTestClient(t)
test.vdr.EXPECT().DeriveWebDIDDocument(ctx, *webDIDBaseURL, nutsDID).Return(nil, resolver.ErrNotFound)
test.vdr.EXPECT().ResolveManaged(webDID).Return(nil, resolver.ErrNotFound)

response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{nutsDID.ID})
response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id})

assert.NoError(t, err)
assert.IsType(t, GetWebDID404Response{}, response)
})
t.Run("other error", func(t *testing.T) {
test := newTestClient(t)
test.vdr.EXPECT().DeriveWebDIDDocument(gomock.Any(), *webDIDBaseURL, nutsDID).Return(nil, errors.New("failed"))
test.vdr.EXPECT().ResolveManaged(webDID).Return(nil, errors.New("failed"))

response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{nutsDID.ID})
response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id})

assert.EqualError(t, err, "unable to resolve DID")
assert.Nil(t, response)
Expand Down
23 changes: 12 additions & 11 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,9 @@ import (
"context"
"errors"
"fmt"
"github.com/nuts-foundation/nuts-node/discovery"
"github.com/nuts-foundation/nuts-node/vdr/resolver"

"github.com/nuts-foundation/nuts-node/golden_hammer"
goldenHammerCmd "github.com/nuts-foundation/nuts-node/golden_hammer/cmd"
"github.com/nuts-foundation/nuts-node/vdr/didnuts"
"github.com/nuts-foundation/nuts-node/vdr/didnuts/didstore"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"io"
"os"
"runtime/pprof"
Expand All @@ -47,9 +43,12 @@ import (
"github.com/nuts-foundation/nuts-node/didman"
didmanAPI "github.com/nuts-foundation/nuts-node/didman/api/v1"
didmanCmd "github.com/nuts-foundation/nuts-node/didman/cmd"
"github.com/nuts-foundation/nuts-node/discovery"
discoveryCmd "github.com/nuts-foundation/nuts-node/discovery/cmd"
"github.com/nuts-foundation/nuts-node/events"
eventsCmd "github.com/nuts-foundation/nuts-node/events/cmd"
"github.com/nuts-foundation/nuts-node/golden_hammer"
goldenHammerCmd "github.com/nuts-foundation/nuts-node/golden_hammer/cmd"
httpEngine "github.com/nuts-foundation/nuts-node/http"
httpCmd "github.com/nuts-foundation/nuts-node/http/cmd"
"github.com/nuts-foundation/nuts-node/jsonld"
Expand All @@ -65,10 +64,11 @@ import (
vcrCmd "github.com/nuts-foundation/nuts-node/vcr/cmd"
"github.com/nuts-foundation/nuts-node/vdr"
vdrAPI "github.com/nuts-foundation/nuts-node/vdr/api/v1"
vdrAPIv2 "github.com/nuts-foundation/nuts-node/vdr/api/v2"
vdrCmd "github.com/nuts-foundation/nuts-node/vdr/cmd"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/nuts-foundation/nuts-node/vdr/didnuts"
"github.com/nuts-foundation/nuts-node/vdr/didnuts/didstore"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
)

var stdOutWriter io.Writer = os.Stdout
Expand Down Expand Up @@ -191,7 +191,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System {
didStore := didstore.New(storageInstance.GetProvider(vdr.ModuleName))
eventManager := events.NewManager()
networkInstance := network.NewNetworkInstance(network.DefaultConfig(), didStore, cryptoInstance, eventManager, storageInstance.GetProvider(network.ModuleName), pkiInstance)
vdrInstance := vdr.NewVDR(cryptoInstance, networkInstance, didStore, eventManager)
vdrInstance := vdr.NewVDR(cryptoInstance, networkInstance, didStore, eventManager, storageInstance)
credentialInstance := vcr.NewVCRInstance(cryptoInstance, vdrInstance, networkInstance, jsonld, eventManager, storageInstance, pkiInstance)
didmanInstance := didman.NewDidmanInstance(vdrInstance, credentialInstance, jsonld)
discoveryInstance := discovery.New(storageInstance, credentialInstance)
Expand All @@ -209,6 +209,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System {
Updater: vdrInstance,
Resolver: vdrInstance.Resolver(),
}})
system.RegisterRoutes(&vdrAPIv2.Wrapper{VDR: vdrInstance})
system.RegisterRoutes(&vcrAPI.Wrapper{VCR: credentialInstance, ContextManager: jsonld})
system.RegisterRoutes(&openid4vciAPI.Wrapper{
VCR: credentialInstance,
Expand Down
1 change: 1 addition & 0 deletions core/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func TestServerConfig(template ServerConfig) ServerConfig {
config.Datadir = template.Datadir
config.Strictmode = template.Strictmode
config.InternalRateLimiter = template.InternalRateLimiter
config.URL = template.URL
return *config
}

Expand Down
22 changes: 22 additions & 0 deletions crypto/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,25 @@ func (t TestKey) Public() crypto.PublicKey {
func (t TestKey) Private() crypto.PrivateKey {
return t.PrivateKey
}

// TestPublicKey is a Key impl for testing purposes that only contains a public key. It can't be used for signing.
type TestPublicKey struct {
Kid string
PublicKey crypto.PublicKey
}

func (t TestPublicKey) Signer() crypto.Signer {
panic("test public key is not for signing")
}

func (t TestPublicKey) KID() string {
return t.Kid
}

func (t TestPublicKey) Public() crypto.PublicKey {
return t.PublicKey
}

func (t TestPublicKey) Private() crypto.PrivateKey {
panic("test public key is not for signing")
}
12 changes: 11 additions & 1 deletion e2e-tests/oauth-flow/rfc021/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ services:
- "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/key.pem:ro"
- "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro"
- "./node-A/html:/etc/nginx/html:ro"
nodeB:
nodeB-backend:
image: "${IMAGE_NODE_B:-nutsfoundation/nuts-node:master}"
ports:
- "21323:1323"
Expand All @@ -38,3 +38,13 @@ services:
- "../../tls-certs/truststore.pem:/etc/ssl/certs/truststore.pem:ro"
healthcheck:
interval: 1s # Make test run quicker by checking health status more often
nodeB:
image: nginx:1.25.1
ports:
- "20443:443"
volumes:
- "./node-B/nginx.conf:/etc/nginx/nginx.conf:ro"
- "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/server.pem:ro"
- "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/key.pem:ro"
- "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro"
- "./node-B/html:/etc/nginx/html:ro"
1 change: 1 addition & 0 deletions e2e-tests/oauth-flow/rfc021/node-B/html/ping
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pong
47 changes: 47 additions & 0 deletions e2e-tests/oauth-flow/rfc021/node-B/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
user nginx;
worker_processes 1;

error_log /var/log/nginx/error.log debug;
pid /var/run/nginx.pid;


events {
worker_connections 1024;
}


http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

access_log /var/log/nginx/access.log main;

keepalive_timeout 65;

include /etc/nginx/conf.d/*.conf;

upstream nodeB-backend {
server nodeB-backend:1323;
}

server {
server_name nodeB;
listen 443 ssl;
http2 on;
ssl_certificate /etc/nginx/ssl/server.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_client_certificate /etc/nginx/ssl/truststore.pem;
ssl_verify_client optional;
ssl_verify_depth 1;
ssl_protocols TLSv1.3;

location / {
proxy_set_header X-Ssl-Client-Cert $ssl_client_escaped_cert;
proxy_pass http://nodeB-backend;
}
}
}
1 change: 0 additions & 1 deletion e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,3 @@ tls:
truststorefile: /opt/nuts/truststore.pem
certfile: /opt/nuts/certificate-and-key.pem
certkeyfile: /opt/nuts/certificate-and-key.pem

33 changes: 13 additions & 20 deletions e2e-tests/oauth-flow/rfc021/run-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,17 @@ echo "------------------------------------"
echo "Registering vendors..."
echo "------------------------------------"
# Register Vendor A
VENDOR_A_DIDDOC=$(docker compose exec nodeA-backend nuts vdr create-did)
VENDOR_A_DIDDOC=$(docker compose exec nodeA-backend nuts vdr create-did --v2)
VENDOR_A_DID=$(echo $VENDOR_A_DIDDOC | jq -r .id)
woutslakhorst marked this conversation as resolved.
Show resolved Hide resolved
echo Vendor A DID: $VENDOR_A_DID
# Add assertionMethod
VENDOR_A_KEYID=$(echo $VENDOR_A_DIDDOC | jq -r '.verificationMethod[0].id')
VENDOR_A_DIDDOC=$(echo $VENDOR_A_DIDDOC | jq ". |= . + {assertionMethod: [\"${VENDOR_A_KEYID}\"]}")
# Perform update
echo $VENDOR_A_DIDDOC > ./node-A/data/updated-did.json
DIDDOC_HASH=$(docker compose exec nodeA-backend nuts vdr resolve $VENDOR_A_DID --metadata | jq -r .hash)
docker compose exec nodeA-backend nuts vdr update "${VENDOR_A_DID}" "${DIDDOC_HASH}" /opt/nuts/data/updated-did.json

# Register Vendor B
VENDOR_B_DIDDOC=$(docker compose exec nodeB nuts vdr create-did)
VENDOR_B_DIDDOC=$(docker compose exec nodeB-backend nuts vdr create-did --v2)
VENDOR_B_DID=$(echo $VENDOR_B_DIDDOC | jq -r .id)
echo Vendor B DID: $VENDOR_B_DID
# Add assertionMethod
VENDOR_B_KEYID=$(echo $VENDOR_B_DIDDOC | jq -r '.verificationMethod[0].id')
VENDOR_B_DIDDOC=$(echo $VENDOR_B_DIDDOC | jq ". |= . + {assertionMethod: [\"${VENDOR_B_KEYID}\"]}")
# Perform update
echo $VENDOR_B_DIDDOC > ./node-B/data/updated-did.json
DIDDOC_HASH=$(docker compose exec nodeB nuts vdr resolve $VENDOR_B_DID --metadata | jq -r .hash)
docker compose exec nodeB nuts vdr update "${VENDOR_B_DID}" "${DIDDOC_HASH}" /opt/nuts/data/updated-did.json

# Issue NutsOrganizationCredential for Vendor B
REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${VENDOR_B_DID}\", \"credentialSubject\": {\"id\":\"${VENDOR_B_DID}\", \"organization\":{\"name\":\"Caresoft B.V.\", \"city\":\"Caretown\"}},\"visibility\": \"public\"}"
REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${VENDOR_B_DID}\", \"credentialSubject\": {\"id\":\"${VENDOR_B_DID}\", \"organization\":{\"name\":\"Caresoft B.V.\", \"city\":\"Caretown\"}},\"publishToNetwork\": false}"
RESPONSE=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:21323/internal/vcr/v2/issuer/vc -H "Content-Type:application/json")
if echo $RESPONSE | grep -q "VerifiableCredential"; then
echo "VC issued"
Expand All @@ -53,14 +39,21 @@ else
exitWithDockerLogs 1
fi

RESPONSE=$(echo $RESPONSE | curl -X POST --data-binary @- http://localhost:21323/internal/vcr/v2/holder/${VENDOR_B_DID}/vc -H "Content-Type:application/json")
if echo $RESPONSE == ""; then
echo "VC stored in wallet"
else
echo "FAILED: Could not load NutsOrganizationCredential in node-B wallet" 1>&2
echo $RESPONSE
exitWithDockerLogs 1
fi

echo "---------------------------------------"
echo "Perform OAuth 2.0 rfc021 flow..."
echo "---------------------------------------"
# Request access token
# Create DID for A with :nuts: replaced with :web:
VENDOR_A_DID_WEB=$(echo $VENDOR_A_DID | sed 's/:nuts/:web:nodeA:iam/')
VENDOR_B_DID_WEB=$(echo $VENDOR_B_DID | sed 's/:nuts/:web:nodeB:iam/')
REQUEST="{\"verifier\":\"${VENDOR_A_DID_WEB}\",\"scope\":\"test\"}"
REQUEST="{\"verifier\":\"${VENDOR_A_DID}\",\"scope\":\"test\"}"
RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:21323/internal/auth/v2/$VENDOR_B_DID/request-access-token -H "Content-Type:application/json" -v)
#if echo $RESPONSE | grep -q "access_token"; then
# echo $RESPONSE | sed -E 's/.*"access_token":"([^"]*).*/\1/' > ./node-B/data/accesstoken.txt
Expand Down
Loading
Loading