graph/sharedbyme: populate the permissions property
Set the exipration date, grantedToV2 and link attributes on the permissions. The displayName for users and groups isn't expanded yet (only the "id" is set) Also, the "roles" attribute cannot be populated yet.
This commit is contained in:
committed by
Ralf Haferkamp
parent
454ec52955
commit
580a4e0d92
@@ -3,6 +3,8 @@ package svc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
|
||||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||||
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||||
@@ -30,7 +32,7 @@ func (g Graph) GetSharedByMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = g.listPublicShares(ctx, driveItems)
|
driveItems, err = g.listPublicShares(ctx, driveItems)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorcode.RenderError(w, r, err)
|
errorcode.RenderError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -116,6 +118,29 @@ func (g Graph) cs3UserSharesToDriveItems(ctx context.Context, shares []*collabor
|
|||||||
}
|
}
|
||||||
item = *itemptr
|
item = *itemptr
|
||||||
}
|
}
|
||||||
|
perm := libregraph.Permission{}
|
||||||
|
perm.SetRoles([]string{})
|
||||||
|
perm.SetId(s.Id.OpaqueId)
|
||||||
|
grantedTo := libregraph.SharePointIdentitySet{}
|
||||||
|
switch s.Grantee.Type {
|
||||||
|
case storageprovider.GranteeType_GRANTEE_TYPE_USER:
|
||||||
|
user := libregraph.NewIdentityWithDefaults()
|
||||||
|
user.SetId(s.Grantee.GetUserId().GetOpaqueId())
|
||||||
|
grantedTo.SetUser(*user)
|
||||||
|
case storageprovider.GranteeType_GRANTEE_TYPE_GROUP:
|
||||||
|
grp := libregraph.NewIdentityWithDefaults()
|
||||||
|
grp.SetId(s.Grantee.GetGroupId().GetOpaqueId())
|
||||||
|
grantedTo.SetGroup(*grp)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// set expiration date
|
||||||
|
if s.GetExpiration() != nil {
|
||||||
|
perm.SetExpirationDateTime(cs3TimestampToTime(s.GetExpiration()))
|
||||||
|
}
|
||||||
|
|
||||||
|
perm.SetGrantedToV2(grantedTo)
|
||||||
|
item.Permissions = append(item.Permissions, perm)
|
||||||
driveItems[resIDStr] = item
|
driveItems[resIDStr] = item
|
||||||
}
|
}
|
||||||
return driveItems, nil
|
return driveItems, nil
|
||||||
@@ -134,6 +159,28 @@ func (g Graph) cs3PublicSharesToDriveItems(ctx context.Context, shares []*link.P
|
|||||||
}
|
}
|
||||||
item = *itemptr
|
item = *itemptr
|
||||||
}
|
}
|
||||||
|
perm := libregraph.Permission{}
|
||||||
|
perm.SetRoles([]string{})
|
||||||
|
perm.SetId(s.Id.OpaqueId)
|
||||||
|
link := libregraph.SharingLink{}
|
||||||
|
webURL, err := url.Parse(g.config.Spaces.WebDavBase)
|
||||||
|
if err != nil {
|
||||||
|
g.logger.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("url", g.config.Spaces.WebDavBase).
|
||||||
|
Msg("failed to parse webURL base url")
|
||||||
|
return driveItems, err
|
||||||
|
}
|
||||||
|
|
||||||
|
webURL.Path = path.Join(webURL.Path, "s", s.GetToken())
|
||||||
|
link.SetWebUrl(webURL.String())
|
||||||
|
perm.SetLink(link)
|
||||||
|
// set expiration date
|
||||||
|
if s.GetExpiration() != nil {
|
||||||
|
perm.SetExpirationDateTime(cs3TimestampToTime(s.GetExpiration()))
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Permissions = append(item.Permissions, perm)
|
||||||
driveItems[resIDStr] = item
|
driveItems[resIDStr] = item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
|
cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
|
||||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||||
"github.com/owncloud/ocis/v2/services/graph/mocks"
|
"github.com/owncloud/ocis/v2/services/graph/mocks"
|
||||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||||
@@ -44,9 +43,8 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
identityBackend *identitymocks.Backend
|
identityBackend *identitymocks.Backend
|
||||||
|
|
||||||
rr *httptest.ResponseRecorder
|
rr *httptest.ResponseRecorder
|
||||||
|
|
||||||
newGroup *libregraph.Group
|
|
||||||
)
|
)
|
||||||
|
expiration := time.Now()
|
||||||
userShare := collaboration.Share{
|
userShare := collaboration.Share{
|
||||||
Id: &collaboration.ShareId{
|
Id: &collaboration.ShareId{
|
||||||
OpaqueId: "share-id",
|
OpaqueId: "share-id",
|
||||||
@@ -100,7 +98,19 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Expiration: utils.TimeToTS(time.Now()),
|
Expiration: utils.TimeToTS(expiration),
|
||||||
|
}
|
||||||
|
|
||||||
|
publicShare := link.PublicShare{
|
||||||
|
Id: &link.PublicShareId{
|
||||||
|
OpaqueId: "public-share-id",
|
||||||
|
},
|
||||||
|
Token: "public-share-token",
|
||||||
|
ResourceId: &provider.ResourceId{
|
||||||
|
StorageId: "storageid",
|
||||||
|
SpaceId: "spaceid",
|
||||||
|
OpaqueId: "public-share-opaqueid",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
@@ -110,14 +120,6 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
|
|
||||||
pool.RemoveSelector("GatewaySelector" + "com.owncloud.api.gateway")
|
pool.RemoveSelector("GatewaySelector" + "com.owncloud.api.gateway")
|
||||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||||
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(
|
|
||||||
&link.ListPublicSharesResponse{
|
|
||||||
Status: status.NewOK(ctx),
|
|
||||||
Share: []*link.PublicShare{},
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
// no stat for the image
|
|
||||||
gatewayClient.On("Stat",
|
gatewayClient.On("Stat",
|
||||||
mock.Anything,
|
mock.Anything,
|
||||||
mock.MatchedBy(
|
mock.MatchedBy(
|
||||||
@@ -130,6 +132,18 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
Id: userShareWithExpiration.ResourceId,
|
Id: userShareWithExpiration.ResourceId,
|
||||||
},
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
|
gatewayClient.On("Stat",
|
||||||
|
mock.Anything,
|
||||||
|
mock.MatchedBy(
|
||||||
|
func(req *provider.StatRequest) bool {
|
||||||
|
return req.Ref.ResourceId.OpaqueId == publicShare.ResourceId.OpaqueId
|
||||||
|
})).
|
||||||
|
Return(&provider.StatResponse{
|
||||||
|
Status: status.NewOK(ctx),
|
||||||
|
Info: &provider.ResourceInfo{
|
||||||
|
Id: publicShare.ResourceId,
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
gatewayClient.On("Stat",
|
gatewayClient.On("Stat",
|
||||||
mock.Anything,
|
mock.Anything,
|
||||||
mock.Anything).
|
mock.Anything).
|
||||||
@@ -148,9 +162,6 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
identityBackend = &identitymocks.Backend{}
|
identityBackend = &identitymocks.Backend{}
|
||||||
newGroup = libregraph.NewGroup()
|
|
||||||
newGroup.SetMembersodataBind([]string{"/users/user1"})
|
|
||||||
newGroup.SetId("group1")
|
|
||||||
|
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
@@ -169,8 +180,27 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
emptyListPublicSharesMock := func() {
|
||||||
|
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(
|
||||||
|
&link.ListPublicSharesResponse{
|
||||||
|
Status: status.NewOK(ctx),
|
||||||
|
Share: []*link.PublicShare{},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
emptyListSharesMock := func() {
|
||||||
|
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||||
|
&collaboration.ListSharesResponse{
|
||||||
|
Status: status.NewOK(ctx),
|
||||||
|
Shares: []*collaboration.Share{},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
Describe("GetSharedByMe", func() {
|
Describe("GetSharedByMe", func() {
|
||||||
It("handles a failing ListShares", func() {
|
It("handles a failing ListShares", func() {
|
||||||
|
emptyListPublicSharesMock()
|
||||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(nil, errors.New("some error"))
|
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(nil, errors.New("some error"))
|
||||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||||
svc.GetSharedByMe(rr, r)
|
svc.GetSharedByMe(rr, r)
|
||||||
@@ -178,6 +208,7 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("handles ListShares returning an error status", func() {
|
It("handles ListShares returning an error status", func() {
|
||||||
|
emptyListPublicSharesMock()
|
||||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||||
&collaboration.ListSharesResponse{Status: status.NewInternal(ctx, "error listing shares")},
|
&collaboration.ListSharesResponse{Status: status.NewInternal(ctx, "error listing shares")},
|
||||||
nil,
|
nil,
|
||||||
@@ -189,13 +220,8 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("succeeds, when no shares are returned", func() {
|
It("succeeds, when no shares are returned", func() {
|
||||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
emptyListPublicSharesMock()
|
||||||
&collaboration.ListSharesResponse{
|
emptyListSharesMock()
|
||||||
Status: status.NewOK(ctx),
|
|
||||||
Shares: []*collaboration.Share{},
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||||
svc.GetSharedByMe(rr, r)
|
svc.GetSharedByMe(rr, r)
|
||||||
@@ -212,6 +238,7 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("returns a proper driveItem, when a single user share is returned", func() {
|
It("returns a proper driveItem, when a single user share is returned", func() {
|
||||||
|
emptyListPublicSharesMock()
|
||||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||||
&collaboration.ListSharesResponse{
|
&collaboration.ListSharesResponse{
|
||||||
Status: status.NewOK(ctx),
|
Status: status.NewOK(ctx),
|
||||||
@@ -237,9 +264,22 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
|
|
||||||
di := res.Value[0]
|
di := res.Value[0]
|
||||||
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(*userShare.GetResourceId())))
|
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(*userShare.GetResourceId())))
|
||||||
|
|
||||||
|
perm := di.GetPermissions()
|
||||||
|
Expect(perm[0].GetId()).To(Equal(userShare.GetId().GetOpaqueId()))
|
||||||
|
_, ok := perm[0].GetExpirationDateTimeOk()
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
_, ok = perm[0].GrantedToV2.GetGroupOk()
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
user, ok := perm[0].GrantedToV2.GetUserOk()
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(user.GetId()).To(Equal(userShare.GetGrantee().GetUserId().GetOpaqueId()))
|
||||||
|
_, ok = perm[0].GetLinkOk()
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns a proper driveItem, when a single group share is returned", func() {
|
It("returns a proper driveItem, when a single group share is returned", func() {
|
||||||
|
emptyListPublicSharesMock()
|
||||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||||
&collaboration.ListSharesResponse{
|
&collaboration.ListSharesResponse{
|
||||||
Status: status.NewOK(ctx),
|
Status: status.NewOK(ctx),
|
||||||
@@ -264,9 +304,22 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
|
|
||||||
di := res.Value[0]
|
di := res.Value[0]
|
||||||
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(*groupShare.GetResourceId())))
|
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(*groupShare.GetResourceId())))
|
||||||
|
|
||||||
|
perm := di.GetPermissions()
|
||||||
|
Expect(perm[0].GetId()).To(Equal(userShare.GetId().GetOpaqueId()))
|
||||||
|
_, ok := perm[0].GetExpirationDateTimeOk()
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
_, ok = perm[0].GrantedToV2.GetUserOk()
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
group, ok := perm[0].GrantedToV2.GetGroupOk()
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(group.GetId()).To(Equal(groupShare.GetGrantee().GetGroupId().GetOpaqueId()))
|
||||||
|
_, ok = perm[0].GetLinkOk()
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns a single driveItem, when a mulitple shares for the same resource are returned", func() {
|
It("returns a single driveItem, when a mulitple shares for the same resource are returned", func() {
|
||||||
|
emptyListPublicSharesMock()
|
||||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||||
&collaboration.ListSharesResponse{
|
&collaboration.ListSharesResponse{
|
||||||
Status: status.NewOK(ctx),
|
Status: status.NewOK(ctx),
|
||||||
@@ -292,9 +345,13 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
|
|
||||||
di := res.Value[0]
|
di := res.Value[0]
|
||||||
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(*groupShare.GetResourceId())))
|
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(*groupShare.GetResourceId())))
|
||||||
|
|
||||||
|
// one permission per share
|
||||||
|
Expect(len(di.GetPermissions())).To(Equal(2))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("return a driveItem with the expiration date set, for expiring shares", func() {
|
It("return a driveItem with the expiration date set, for expiring shares", func() {
|
||||||
|
emptyListPublicSharesMock()
|
||||||
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
gatewayClient.On("ListShares", mock.Anything, mock.Anything).Return(
|
||||||
&collaboration.ListSharesResponse{
|
&collaboration.ListSharesResponse{
|
||||||
Status: status.NewOK(ctx),
|
Status: status.NewOK(ctx),
|
||||||
@@ -319,6 +376,80 @@ var _ = Describe("sharedbyme", func() {
|
|||||||
|
|
||||||
di := res.Value[0]
|
di := res.Value[0]
|
||||||
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(*userShareWithExpiration.GetResourceId())))
|
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(*userShareWithExpiration.GetResourceId())))
|
||||||
|
|
||||||
|
perm := di.GetPermissions()
|
||||||
|
Expect(perm[0].GetId()).To(Equal(userShareWithExpiration.GetId().GetOpaqueId()))
|
||||||
|
exp, ok := perm[0].GetExpirationDateTimeOk()
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(exp.Equal(expiration)).To(BeTrue())
|
||||||
|
_, ok = perm[0].GrantedToV2.GetGroupOk()
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
user, ok := perm[0].GrantedToV2.GetUserOk()
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(user.GetId()).To(Equal(userShareWithExpiration.GetGrantee().GetUserId().GetOpaqueId()))
|
||||||
|
_, ok = perm[0].GetLinkOk()
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Public Shares / "links" in graph terms
|
||||||
|
It("handles a failing ListPublicShares", func() {
|
||||||
|
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(nil, errors.New("some error"))
|
||||||
|
emptyListSharesMock()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||||
|
svc.GetSharedByMe(rr, r)
|
||||||
|
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles ListPublicShares returning an error status", func() {
|
||||||
|
emptyListSharesMock()
|
||||||
|
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(
|
||||||
|
&link.ListPublicSharesResponse{Status: status.NewInternal(ctx, "error listing shares")},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||||
|
svc.GetSharedByMe(rr, r)
|
||||||
|
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns a proper driveItem, when a single public share is returned", func() {
|
||||||
|
emptyListSharesMock()
|
||||||
|
gatewayClient.On("ListPublicShares", mock.Anything, mock.Anything).Return(
|
||||||
|
&link.ListPublicSharesResponse{
|
||||||
|
Status: status.NewOK(ctx),
|
||||||
|
Share: []*link.PublicShare{
|
||||||
|
&publicShare,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives/sharedByMe", nil)
|
||||||
|
svc.GetSharedByMe(rr, r)
|
||||||
|
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
data, err := io.ReadAll(rr.Body)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
res := itemsList{}
|
||||||
|
err = json.Unmarshal(data, &res)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
Expect(len(res.Value)).To(Equal(1))
|
||||||
|
|
||||||
|
di := res.Value[0]
|
||||||
|
Expect(di.GetId()).To(Equal(storagespace.FormatResourceID(*publicShare.GetResourceId())))
|
||||||
|
|
||||||
|
perm := di.GetPermissions()
|
||||||
|
Expect(perm[0].GetId()).To(Equal(publicShare.GetId().GetOpaqueId()))
|
||||||
|
_, ok := perm[0].GetExpirationDateTimeOk()
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
_, ok = perm[0].GetGrantedToV2Ok()
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
link, ok := perm[0].GetLinkOk()
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(link.GetWebUrl()).To(Equal("https://localhost:9200/s/" + publicShare.GetToken()))
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user