Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ require (
go.lumeweb.com/canimcp v0.0.0-20260912112527-1c27fd5becdd
go.lumeweb.com/fieldcraft v0.0.0-20260907095025-2a7bfdd040cb
go.lumeweb.com/ipfs-content v0.1.18
go.lumeweb.com/ipfs-sdk v0.1.98
go.lumeweb.com/ipfs-sdk v0.1.99
go.lumeweb.com/ipfs-sdk/dnsname v0.1.64
go.lumeweb.com/mcpcanvas v0.0.0-20260907112052-7adbd4d8dcac
go.lumeweb.com/mcpforge v0.0.0-20260907155938-f64f92060f4e
go.lumeweb.com/mcpplane v0.0.0-20260912095121-3753dd085aa8
go.lumeweb.com/oauth v0.1.6
go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9
go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0
go.lumeweb.com/portal-sdk v0.1.73
go.lumeweb.com/pinner v0.0.0-20260915191542-82fb73a71799
go.lumeweb.com/portal-sdk v0.1.74
go.lumeweb.com/queryutil v0.3.19
go.lumeweb.com/tunneler v0.0.0-20260907123602-56944ca7aeae
go.lumeweb.com/tunneler/cloudflare v0.0.0-20260907123602-56944ca7aeae
Expand Down
22 changes: 6 additions & 16 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -739,8 +739,8 @@ go.lumeweb.com/gswagger v0.20.12 h1:FNSrDAeMQVAGIogzJ4SQX+X8U/FMeWQ80yxdyfsM2/M=
go.lumeweb.com/gswagger v0.20.12/go.mod h1:qz+9/8qstYy7WJ48VZJW/0FonlExw/Igv7vWGgp5uoU=
go.lumeweb.com/ipfs-content v0.1.18 h1:Lq3/A+2kZcS4RK+bpdk+m4iJ95ZgpEa4DKhiH6bQWUg=
go.lumeweb.com/ipfs-content v0.1.18/go.mod h1:idWCsfndMDCsE5LtU0ZBNaFTBAaiuIe50dce0SDJLxw=
go.lumeweb.com/ipfs-sdk v0.1.98 h1:iiXIllSx2ylSlz5BkofFxzRjqHZXWDHnMGnSbNxU5AI=
go.lumeweb.com/ipfs-sdk v0.1.98/go.mod h1:WMlp/mBGeguV5tmUYd+Z2MkPrJczZOPhqnK/BLUcBGU=
go.lumeweb.com/ipfs-sdk v0.1.99 h1:+suUmUwlGvyT2Yru/XscPbPL1BhJno6/7l3UkJ8Vqrs=
go.lumeweb.com/ipfs-sdk v0.1.99/go.mod h1:mNTAVzCrDUfT8uliHXH5vlAE6z8EzjnpMKdgMTTgfbA=
go.lumeweb.com/ipfs-sdk/dnsname v0.1.64 h1:RTBD+zoXHOYYKreNRoQhyLLKKWp/VgYfr7qojCzKjww=
go.lumeweb.com/ipfs-sdk/dnsname v0.1.64/go.mod h1:1++6EWMiG/BC5UQjlIobId2YcWDVHjHEhdsEzeISKpQ=
go.lumeweb.com/mcpcanvas v0.0.0-20260907112052-7adbd4d8dcac h1:3IF4iIQABGV2Q4KMwTKOkmiG3DBnZygQuMj3VqCCn7E=
Expand All @@ -753,26 +753,16 @@ go.lumeweb.com/oauth v0.1.6 h1:6c6LrXxMwx5klbq3OshzXjkGwvYVjAQhjX2FxCAgqqg=
go.lumeweb.com/oauth v0.1.6/go.mod h1:Bfdxi1gkv+Ypj9yj9uOSmKnbZX8C7q7Pyn1OdPyuCbM=
go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9 h1:kb2adBZ8Ed11yuXoQw2I8LfmbPjpPkc0NPwyShKJ5KE=
go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9/go.mod h1:DbO27Lr6pBSzC+HogQMNNVEZMvkAHYwckL7i79aTETM=
go.lumeweb.com/pinner v0.0.0-20260913155112-17000af806af h1:oi/hQ2cYZaKpl3SHGbPbVSj7waf0CcNKNIzPboaAvzY=
go.lumeweb.com/pinner v0.0.0-20260913155112-17000af806af/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw=
go.lumeweb.com/pinner v0.0.0-20260913165141-91bcd5ac166f h1:oM/B5k2iWw+rPwYO6d1Cw6PbCJ5eBLWnXib4zOLDTYg=
go.lumeweb.com/pinner v0.0.0-20260913165141-91bcd5ac166f/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw=
go.lumeweb.com/pinner v0.0.0-20260913165231-607910a86128 h1:FHM7hyCcAfkLsAEMtNWFL+adNrTxS+4rRIV4EonNdiw=
go.lumeweb.com/pinner v0.0.0-20260913165231-607910a86128/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw=
go.lumeweb.com/pinner v0.0.0-20260913165519-0cd854ac5743 h1:R3GNQ7ENa2IGbaHJWvB5iWS0QkI/f4s6+zLzsD+aXts=
go.lumeweb.com/pinner v0.0.0-20260913165519-0cd854ac5743/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw=
go.lumeweb.com/pinner v0.0.0-20260913174043-31c1b6e65acb h1:Cw0gjgj5auBFpEhnLj0YWRhNOoHCLiTQFMrqur74Wlw=
go.lumeweb.com/pinner v0.0.0-20260913174043-31c1b6e65acb/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw=
go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0 h1:QsMQcXyrRXZoO0E5PPbFaDLMJMpfh6B5K30f35hxZ9M=
go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw=
go.lumeweb.com/pinner v0.0.0-20260915191542-82fb73a71799 h1:jSkhXZgG2xmZpsa5toP3Xu44w5HFvff/wws77x22oO0=
go.lumeweb.com/pinner v0.0.0-20260915191542-82fb73a71799/go.mod h1:f8uj7cmOeJW79CWhBAwmva5Lh8xSLj5BRjjUbNFbpls=
go.lumeweb.com/portal v0.5.1 h1:l28uCNFmT+VewwRGhFwlmonYEwLxQfRh06hm7n0SQr8=
go.lumeweb.com/portal v0.5.1/go.mod h1:JXy/eHHlUdXMsPbXSbRyX2ZM7s/OGNfSNsXH7XRYWCk=
go.lumeweb.com/portal-middleware v0.3.7 h1:kq4SZq4T/uhauqHehw2JaTbNJpPpE8/EQAC3R737H7c=
go.lumeweb.com/portal-middleware v0.3.7/go.mod h1:Yn9ZsFy5n3x2jUJ085M9B/aoZ+ANQV5QD/MAE0BpJXo=
go.lumeweb.com/portal-router v0.7.7 h1:6mUGkG2BtHDygIPqRZs1sRkPLkz9l2wTXMUhZmdp24E=
go.lumeweb.com/portal-router v0.7.7/go.mod h1:hl51sHDKcgw8yxJ6RraYBOI1Vnufq33FhKwfKiqCkVg=
go.lumeweb.com/portal-sdk v0.1.73 h1:rUbkoQlCtWEwdWEx4ha5zAKP9czQqlfAiQbX+EWmDzM=
go.lumeweb.com/portal-sdk v0.1.73/go.mod h1:4TPf4QRYC0U5VDxo4BpL+ifkq4JMioysB8J/c2WFaEs=
go.lumeweb.com/portal-sdk v0.1.74 h1:pg1i4xCZeXBAL2opNilCv9Uos+X+Cy1y4B/5o1m4188=
go.lumeweb.com/portal-sdk v0.1.74/go.mod h1:+ZD06oI+NjP6JCIBUZAr0fEkGu20WigCysMC2ITwIj4=
go.lumeweb.com/queryutil v0.3.19 h1:GV+zKsyGJ+YYPZi0mqOocDAROCQruGdizrE8Aw8cNFk=
go.lumeweb.com/queryutil v0.3.19/go.mod h1:YPrXdEelsjJNBPXrG4IiYp4aq5YUk/l4uXw8z2cSAuw=
go.lumeweb.com/tunneler v0.0.0-20260907123602-56944ca7aeae h1:t2jwV1pyuUJb1Xbbz63JqcWrW2Jza52AU9AKP1E9BxE=
Expand Down
36 changes: 32 additions & 4 deletions internal/cli/account_api_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ func accountAPIKeysList(ctx context.Context, cmd flagGetter, output Output, cfgM
svc := svcFactory(authService, authToken)

search := cmd.String(FlagSearch)
keys, total, err := svc.ListAPIKeys(ctx, search)
keys, err := allAPIKeys(ctx, svc, search)
if err != nil {
return fmt.Errorf("failed to list API keys: %w", err)
}
total := len(keys)

if len(keys) == 0 {
if output.IsJSON() {
Expand Down Expand Up @@ -58,6 +59,27 @@ func accountAPIKeysList(ctx context.Context, cmd flagGetter, output Output, cfgM
return nil
}

// allAPIKeys pages through every API key for the authenticated account. The
// backend applies a default 10-row window to ListAPIKeys unless an explicit
// _start/_end window is supplied, so a key that lives past the first page would
// otherwise be missed. This mirrors the allPlatformDomains/allSocialProviders
// full-scan helpers used for the admin name/id resolution paths.
func allAPIKeys(ctx context.Context, svc APIKeyService, search string) ([]*portalsdk.APIKey, error) {
const pageSize = 100
var all []*portalsdk.APIKey
for start := 0; ; start += pageSize {
keys, total, err := svc.ListAPIKeys(ctx, search, start, pageSize)
if err != nil {
return nil, err
}
all = append(all, keys...)
if len(keys) == 0 || len(keys) < pageSize || (total > 0 && len(all) >= total) {
break
}
Comment thread
pcfreak30 marked this conversation as resolved.
}
return all, nil
}

func accountAPIKeysCreate(ctx context.Context, cmd argsFlagGetter, output Output, cfgMgr config.Manager, authToken string, authServiceFactory AuthServiceFactory, svcFactory APIKeyServiceFactory) error {
apiEndpoint := cfgMgr.Config().GetAPIEndpoint()
authService := authServiceFactory(cfgMgr, apiEndpoint)
Expand Down Expand Up @@ -107,8 +129,14 @@ func accountAPIKeysDelete(ctx context.Context, cmd argsFlagGetterWithBool, outpu

currentUUID := svc.GetCurrentAPIKeyUUID()
resolvedID := idOrName
if currentUUID != "" && !isUUIDString(idOrName) {
keys, _, listErr := svc.ListAPIKeys(ctx, idOrName)
if !isUUIDString(idOrName) {
// Resolve a name to its UUID using the name as the backend search
// filter, while still paging through the (filtered) results so keys
// past the backend's default first page are found. Passing the name as
// the filter keeps the backend from returning every key on the account;
// the paginated scan in allAPIKeys guarantees a match deeper than the
// first page still resolves before we delete by the resolved UUID.
keys, listErr := allAPIKeys(ctx, svc, idOrName)
if listErr == nil {
for _, key := range keys {
if key.Name == idOrName {
Expand All @@ -120,7 +148,7 @@ func accountAPIKeysDelete(ctx context.Context, cmd argsFlagGetterWithBool, outpu
}
isCurrentKey := currentUUID != "" && currentUUID == resolvedID

if err := svc.DeleteAPIKey(ctx, idOrName, force); err != nil {
if err := svc.DeleteAPIKey(ctx, resolvedID, force); err != nil {
return err
}

Expand Down
166 changes: 158 additions & 8 deletions internal/cli/account_api_keys_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package cli

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"testing"

mock "github.com/stretchr/testify/mock"
Expand Down Expand Up @@ -87,7 +89,7 @@ func TestAPIKeyService_ListAPIKeys(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
svc := setupAPIKeyServiceWithAuth(t, "test-token", tt.setupAcc)

keys, total, err := svc.ListAPIKeys(context.Background(), tt.search)
keys, total, err := svc.ListAPIKeys(context.Background(), tt.search, 0, 0)
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tt.errContains)
Expand Down Expand Up @@ -332,16 +334,16 @@ func TestNewAccountAPIKeysCommand(t *testing.T) {
}

type mockAPIKeyServiceForCLI struct {
listFunc func(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error)
listFunc func(ctx context.Context, search string, start, limit int) ([]*portalsdk.APIKey, int, error)
createFunc func(ctx context.Context, name string) (*portalsdk.APIKey, error)
deleteFunc func(ctx context.Context, idOrName string, force bool) error
currentUUIDFunc func() string
requireAuthErr error
}

func (m *mockAPIKeyServiceForCLI) ListAPIKeys(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error) {
func (m *mockAPIKeyServiceForCLI) ListAPIKeys(ctx context.Context, search string, start, limit int) ([]*portalsdk.APIKey, int, error) {
if m.listFunc != nil {
return m.listFunc(ctx, search)
return m.listFunc(ctx, search, start, limit)
}
return nil, 0, nil
}
Expand Down Expand Up @@ -380,7 +382,7 @@ func setupAPIKeyHandlerTest(t *testing.T) (*mockAPIKeyServiceForCLI, config.Mana

func TestAccountAPIKeysList_Success(t *testing.T) {
mockSvc, cfgMgr := setupAPIKeyHandlerTest(t)
mockSvc.listFunc = func(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error) {
mockSvc.listFunc = func(ctx context.Context, search string, _ int, _ int) ([]*portalsdk.APIKey, int, error) {
return []*portalsdk.APIKey{
newTestAPIKey("my-key", "00000000-0000-0000-0000-000000000001"),
}, 1, nil
Expand All @@ -401,7 +403,7 @@ func TestAccountAPIKeysList_Success(t *testing.T) {

func TestAccountAPIKeysList_Empty(t *testing.T) {
mockSvc, cfgMgr := setupAPIKeyHandlerTest(t)
mockSvc.listFunc = func(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error) {
mockSvc.listFunc = func(ctx context.Context, search string, _ int, _ int) ([]*portalsdk.APIKey, int, error) {
return []*portalsdk.APIKey{}, 0, nil
}

Expand All @@ -420,7 +422,7 @@ func TestAccountAPIKeysList_Empty(t *testing.T) {

func TestAccountAPIKeysList_WithSearch(t *testing.T) {
mockSvc, cfgMgr := setupAPIKeyHandlerTest(t)
mockSvc.listFunc = func(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error) {
mockSvc.listFunc = func(ctx context.Context, search string, _ int, _ int) ([]*portalsdk.APIKey, int, error) {
require.Equal(t, "my-key", search)
return []*portalsdk.APIKey{}, 0, nil
}
Expand All @@ -440,7 +442,7 @@ func TestAccountAPIKeysList_WithSearch(t *testing.T) {

func TestAccountAPIKeysList_ServiceError(t *testing.T) {
mockSvc, cfgMgr := setupAPIKeyHandlerTest(t)
mockSvc.listFunc = func(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error) {
mockSvc.listFunc = func(ctx context.Context, search string, _ int, _ int) ([]*portalsdk.APIKey, int, error) {
return nil, 0, fmt.Errorf("server error")
}

Expand Down Expand Up @@ -593,6 +595,154 @@ func TestAccountAPIKeysDelete_WithForce(t *testing.T) {
require.NoError(t, err)
}

func newTestAPIKeyBatch(n int) []*portalsdk.APIKey {
keys := make([]*portalsdk.APIKey, 0, n)
for i := 0; i < n; i++ {
keys = append(keys, newTestAPIKey(
fmt.Sprintf("key-%03d", i+1),
fmt.Sprintf("00000000-0000-0000-0000-%012d", i+1),
))
}
return keys
}

// pagedAPIKeyList emulates the portal admin API-key list endpoint. With no
// explicit _start/_end window (limit == 0) the backend applies a default 10-row
// window; with an explicit window it returns the requested slice. search
// narrows by name substring, matching the backend's q filter.
func pagedAPIKeyList(keys []*portalsdk.APIKey) func(ctx context.Context, search string, start, limit int) ([]*portalsdk.APIKey, int, error) {
return func(ctx context.Context, search string, start, limit int) ([]*portalsdk.APIKey, int, error) {
matched := keys
if search != "" {
matched = nil
for _, k := range keys {
if strings.Contains(k.Name, search) {
matched = append(matched, k)
}
}
}
end := len(matched)
if limit > 0 {
end = min(start+limit, len(matched))
} else {
end = min(10, len(matched))
}
if start >= len(matched) {
return []*portalsdk.APIKey{}, len(matched), nil
}
if end < start {
end = start
}
return matched[start:end], len(matched), nil
}
}

func TestAllAPIKeys_FullScanReturnsEveryPage(t *testing.T) {
keys := newTestAPIKeyBatch(125)
mockSvc := &mockAPIKeyServiceForCLI{}
mockSvc.listFunc = pagedAPIKeyList(keys)

// The naive single call with no explicit window only surfaces the first 10
// rows while reporting the full total — the truncation this guards against.
naiveKeys, naiveTotal, err := mockSvc.ListAPIKeys(context.Background(), "", 0, 0)
require.NoError(t, err)
require.Equal(t, len(keys), naiveTotal)
require.Len(t, naiveKeys, 10)

all, err := allAPIKeys(context.Background(), mockSvc, "")
require.NoError(t, err)
require.Len(t, all, len(keys))
}

func TestAccountAPIKeysList_PagesAllKeys(t *testing.T) {
keys := newTestAPIKeyBatch(125)
mockSvc, cfgMgr := setupAPIKeyHandlerTest(t)
mockSvc.listFunc = pagedAPIKeyList(keys)

var buf bytes.Buffer
output := NewOutputFormatter(true, false, false, false)
output.SetWriter(&buf)

cmd := newMockCommand()
err := accountAPIKeysList(context.Background(), cmd, output, cfgMgr, "test-token",
Comment thread
pcfreak30 marked this conversation as resolved.
func(cm config.Manager, apiEndpoint string) AuthService {
return NewMockAuthService(t)
},
func(authService AuthService, authToken string) APIKeyService {
return mockSvc
},
)
require.NoError(t, err)

var result struct {
Count int `json:"count"`
Keys []*portalsdk.APIKey `json:"keys"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Equal(t, len(keys), result.Count)
require.Len(t, result.Keys, len(keys))
}

func TestAccountAPIKeysDelete_ResolvesNameBeyondFirstPage(t *testing.T) {
// Names share the "key-110" prefix so the backend search (name substring)
// returns far more than one pageSize of filtered rows. The exact-name
// target is placed past the first page of filtered results to prove that
// filtered paging still reaches it — and that the backend is asked to
// narrow by name rather than handed a full-scan search.
const total = 205
keys := make([]*portalsdk.APIKey, 0, total)
for i := 0; i < total; i++ {
if i == 150 {
keys = append(keys, newTestAPIKey("key-110", "00000000-0000-0000-0000-000000000110"))
continue
}
keys = append(keys, newTestAPIKey(
fmt.Sprintf("key-110-%03d", i),
fmt.Sprintf("00000000-0000-0000-0000-%012d", i+1),
))
}

mockSvc, cfgMgr := setupAPIKeyHandlerTest(t)
var searches []string
mockSvc.listFunc = func(ctx context.Context, search string, start, limit int) ([]*portalsdk.APIKey, int, error) {
searches = append(searches, search)
return pagedAPIKeyList(keys)(ctx, search, start, limit)
}
mockSvc.currentUUIDFunc = func() string {
return "00000000-0000-0000-0000-999999999999"
}

var deletedID string
mockSvc.deleteFunc = func(ctx context.Context, idOrName string, force bool) error {
deletedID = idOrName
return nil
}

// key-110 sits well past the backend's first page; the name-filtered paged
// scan must resolve it to its UUID so the delete targets it directly.
output := newTestOutput()
cmd := newMockCommand().withArgs("key-110")
err := accountAPIKeysDelete(context.Background(), cmd, output, cfgMgr, "test-token",
func(cm config.Manager, apiEndpoint string) AuthService {
return NewMockAuthService(t)
},
func(authService AuthService, authToken string) APIKeyService {
return mockSvc
},
)
require.NoError(t, err)
require.Equal(t, "00000000-0000-0000-0000-000000000110", deletedID)

// Prove the optimization: every paged request carried the name as the
// backend search filter (not a full-scan ""), yet paging still spanned
// multiple filtered pages to reach the key past the first page.
require.NotEmpty(t, searches)
require.Greater(t, len(searches), 1)
for _, s := range searches {
require.Equal(t, "key-110", s)
}
}

// makeAPIKeyJWT creates a minimal JWT string with the given subject and audience.
func makeAPIKeyJWT(sub, aud string) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
Expand Down
8 changes: 4 additions & 4 deletions internal/cli/admin_platform_domains_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,13 @@ func TestAdminActionAdapterForwardsPositionalAndFlags(t *testing.T) {
// driven by function fields, used to exercise resolvePlatformDomainID without a
// real service or network.
type fakePlatformDomainService struct {
listFn func(ctx context.Context) ([]*admin.PlatformDomain, int, error)
listFn func(ctx context.Context, params *admin.GetApiIpfsPlatformDomainsParams) ([]*admin.PlatformDomain, int, error)
}

func (f *fakePlatformDomainService) RequireAuthenticated() error { return nil }
func (f *fakePlatformDomainService) ListPlatformDomains(ctx context.Context) ([]*admin.PlatformDomain, int, error) {
func (f *fakePlatformDomainService) ListPlatformDomains(ctx context.Context, params *admin.GetApiIpfsPlatformDomainsParams) ([]*admin.PlatformDomain, int, error) {
if f.listFn != nil {
return f.listFn(ctx)
return f.listFn(ctx, params)
}
return nil, 0, nil
}
Expand All @@ -166,7 +166,7 @@ func (f *fakePlatformDomainService) BindWebsiteToPlatformDomain(ctx context.Cont
// ListPlatformDomains.
func TestResolvePlatformDomainID(t *testing.T) {
svc := &fakePlatformDomainService{
listFn: func(ctx context.Context) ([]*admin.PlatformDomain, int, error) {
listFn: func(ctx context.Context, _ *admin.GetApiIpfsPlatformDomainsParams) ([]*admin.PlatformDomain, int, error) {
d1 := &admin.PlatformDomain{}
d1.Id, d1.Domain = 7, "pinned.site"
d2 := &admin.PlatformDomain{}
Expand Down
Loading
Loading