mirror of https://github.com/minio/minio.git
validate correct ETag for the parts sent during CompleteMultipart (#15751)
This commit is contained in:
parent
50a8ba6a6f
commit
b04c0697e1
|
@ -41,10 +41,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: 0
|
||||||
GO111MODULE: on
|
GO111MODULE: on
|
||||||
MINIO_KMS_KES_CERT_FILE: /home/runner/work/minio/minio/.github/workflows/root.cert
|
MINIO_KMS_SECRET_KEY: "my-minio-key:OSMM+vkKUTCvQs9YL/CVMIMt43HFhkUpqJxTmGl6rYw="
|
||||||
MINIO_KMS_KES_KEY_FILE: /home/runner/work/minio/minio/.github/workflows/root.key
|
|
||||||
MINIO_KMS_KES_ENDPOINT: "https://play.min.io:7373"
|
|
||||||
MINIO_KMS_KES_KEY_NAME: "my-minio-key"
|
|
||||||
MINIO_KMS_AUTO_ENCRYPTION: on
|
MINIO_KMS_AUTO_ENCRYPTION: on
|
||||||
run: |
|
run: |
|
||||||
sudo sysctl net.ipv6.conf.all.disable_ipv6=0
|
sudo sysctl net.ipv6.conf.all.disable_ipv6=0
|
||||||
|
|
|
@ -706,7 +706,7 @@ func (c *diskCache) updateMetadata(ctx context.Context, bucket, object, etag str
|
||||||
|
|
||||||
if globalCacheKMS != nil {
|
if globalCacheKMS != nil {
|
||||||
// Calculating object encryption key
|
// Calculating object encryption key
|
||||||
key, err = decryptObjectInfo(key, bucket, object, m.Meta)
|
key, err = decryptObjectMeta(key, bucket, object, m.Meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -1397,7 +1397,7 @@ func (c *diskCache) SavePartMetadata(ctx context.Context, bucket, object, upload
|
||||||
var objectEncryptionKey crypto.ObjectKey
|
var objectEncryptionKey crypto.ObjectKey
|
||||||
if globalCacheKMS != nil {
|
if globalCacheKMS != nil {
|
||||||
// Calculating object encryption key
|
// Calculating object encryption key
|
||||||
key, err = decryptObjectInfo(key, bucket, object, m.Meta)
|
key, err = decryptObjectMeta(key, bucket, object, m.Meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -1427,7 +1427,7 @@ func newCachePartEncryptReader(ctx context.Context, bucket, object string, partI
|
||||||
var objectEncryptionKey, partEncryptionKey crypto.ObjectKey
|
var objectEncryptionKey, partEncryptionKey crypto.ObjectKey
|
||||||
|
|
||||||
// Calculating object encryption key
|
// Calculating object encryption key
|
||||||
key, err = decryptObjectInfo(key, bucket, object, metadata)
|
key, err = decryptObjectMeta(key, bucket, object, metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -473,7 +473,7 @@ func EncryptRequest(content io.Reader, r *http.Request, bucket, object string, m
|
||||||
return newEncryptReader(r.Context(), content, kind, keyID, key, bucket, object, metadata, ctx)
|
return newEncryptReader(r.Context(), content, kind, keyID, key, bucket, object, metadata, ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func decryptObjectInfo(key []byte, bucket, object string, metadata map[string]string) ([]byte, error) {
|
func decryptObjectMeta(key []byte, bucket, object string, metadata map[string]string) ([]byte, error) {
|
||||||
switch kind, _ := crypto.IsEncrypted(metadata); kind {
|
switch kind, _ := crypto.IsEncrypted(metadata); kind {
|
||||||
case crypto.S3:
|
case crypto.S3:
|
||||||
var KMS kms.KMS = GlobalKMS
|
var KMS kms.KMS = GlobalKMS
|
||||||
|
@ -544,7 +544,7 @@ func DecryptCopyRequestR(client io.Reader, h http.Header, bucket, object string,
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDecryptReader(client io.Reader, key []byte, bucket, object string, seqNumber uint32, metadata map[string]string) (io.Reader, error) {
|
func newDecryptReader(client io.Reader, key []byte, bucket, object string, seqNumber uint32, metadata map[string]string) (io.Reader, error) {
|
||||||
objectEncryptionKey, err := decryptObjectInfo(key, bucket, object, metadata)
|
objectEncryptionKey, err := decryptObjectMeta(key, bucket, object, metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -656,7 +656,7 @@ func (d *DecryptBlocksReader) buildDecrypter(partID int) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
objectEncryptionKey, err := decryptObjectInfo(key, d.bucket, d.object, m)
|
objectEncryptionKey, err := decryptObjectMeta(key, d.bucket, d.object, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -822,7 +822,7 @@ func getDecryptedETag(headers http.Header, objInfo ObjectInfo, copySource bool)
|
||||||
return objInfo.ETag[len(objInfo.ETag)-32:]
|
return objInfo.ETag[len(objInfo.ETag)-32:]
|
||||||
}
|
}
|
||||||
|
|
||||||
objectEncryptionKey, err := decryptObjectInfo(key[:], objInfo.Bucket, objInfo.Name, objInfo.UserDefined)
|
objectEncryptionKey, err := decryptObjectMeta(key[:], objInfo.Bucket, objInfo.Name, objInfo.UserDefined)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return objInfo.ETag
|
return objInfo.ETag
|
||||||
}
|
}
|
||||||
|
@ -1085,7 +1085,7 @@ func (o *ObjectInfo) metadataDecrypter() objectMetaDecryptFn {
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := decryptObjectInfo(nil, o.Bucket, o.Name, o.UserDefined)
|
key, err := decryptObjectMeta(nil, o.Bucket, o.Name, o.UserDefined)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,7 +79,7 @@ func TestEncryptRequest(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var decryptObjectInfoTests = []struct {
|
var decryptObjectMetaTests = []struct {
|
||||||
info ObjectInfo
|
info ObjectInfo
|
||||||
request *http.Request
|
request *http.Request
|
||||||
expErr error
|
expErr error
|
||||||
|
@ -122,7 +122,7 @@ var decryptObjectInfoTests = []struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecryptObjectInfo(t *testing.T) {
|
func TestDecryptObjectInfo(t *testing.T) {
|
||||||
for i, test := range decryptObjectInfoTests {
|
for i, test := range decryptObjectMetaTests {
|
||||||
if encrypted, err := DecryptObjectInfo(&test.info, test.request); err != test.expErr {
|
if encrypted, err := DecryptObjectInfo(&test.info, test.request); err != test.expErr {
|
||||||
t.Errorf("Test %d: Decryption returned wrong error code: got %d , want %d", i, err, test.expErr)
|
t.Errorf("Test %d: Decryption returned wrong error code: got %d , want %d", i, err, test.expErr)
|
||||||
} else if _, enc := crypto.IsEncrypted(test.info.UserDefined); encrypted && enc != encrypted {
|
} else if _, enc := crypto.IsEncrypted(test.info.UserDefined); encrypted && enc != encrypted {
|
||||||
|
|
|
@ -32,6 +32,7 @@ import (
|
||||||
|
|
||||||
"github.com/klauspost/readahead"
|
"github.com/klauspost/readahead"
|
||||||
"github.com/minio/minio-go/v7/pkg/set"
|
"github.com/minio/minio-go/v7/pkg/set"
|
||||||
|
"github.com/minio/minio/internal/crypto"
|
||||||
"github.com/minio/minio/internal/hash"
|
"github.com/minio/minio/internal/hash"
|
||||||
xhttp "github.com/minio/minio/internal/http"
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
"github.com/minio/minio/internal/logger"
|
"github.com/minio/minio/internal/logger"
|
||||||
|
@ -982,8 +983,28 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var checksumCombined []byte
|
var checksumCombined []byte
|
||||||
|
|
||||||
|
// However, in case of encryption, the persisted part ETags don't match
|
||||||
|
// what we have sent to the client during PutObjectPart. The reason is
|
||||||
|
// that ETags are encrypted. Hence, the client will send a list of complete
|
||||||
|
// part ETags of which non can match the ETag of any part. For example
|
||||||
|
// ETag (client): 30902184f4e62dd8f98f0aaff810c626
|
||||||
|
// ETag (server-internal): 20000f00ce5dc16e3f3b124f586ae1d88e9caa1c598415c2759bbb50e84a59f630902184f4e62dd8f98f0aaff810c626
|
||||||
|
//
|
||||||
|
// Therefore, we adjust all ETags sent by the client to match what is stored
|
||||||
|
// on the backend.
|
||||||
|
kind, isEncrypted := crypto.IsEncrypted(fi.Metadata)
|
||||||
|
|
||||||
|
var objectEncryptionKey []byte
|
||||||
|
if isEncrypted && kind == crypto.S3 {
|
||||||
|
objectEncryptionKey, err = decryptObjectMeta(nil, bucket, object, fi.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
return oi, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, part := range partInfoFiles {
|
for i, part := range partInfoFiles {
|
||||||
partID := parts[i].PartNumber
|
partID := parts[i].PartNumber
|
||||||
if part.Error != "" || !part.Exists {
|
if part.Error != "" || !part.Exists {
|
||||||
|
@ -1042,21 +1063,22 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
||||||
}
|
}
|
||||||
return oi, invp
|
return oi, invp
|
||||||
}
|
}
|
||||||
gotPart := currentFI.Parts[partIdx]
|
expPart := currentFI.Parts[partIdx]
|
||||||
|
|
||||||
// ensure that part ETag is canonicalized to strip off extraneous quotes
|
// ensure that part ETag is canonicalized to strip off extraneous quotes
|
||||||
part.ETag = canonicalizeETag(part.ETag)
|
part.ETag = canonicalizeETag(part.ETag)
|
||||||
if gotPart.ETag != part.ETag {
|
expETag := tryDecryptETag(objectEncryptionKey, expPart.ETag, kind != crypto.S3)
|
||||||
|
if expETag != part.ETag {
|
||||||
invp := InvalidPart{
|
invp := InvalidPart{
|
||||||
PartNumber: part.PartNumber,
|
PartNumber: part.PartNumber,
|
||||||
ExpETag: gotPart.ETag,
|
ExpETag: expETag,
|
||||||
GotETag: part.ETag,
|
GotETag: part.ETag,
|
||||||
}
|
}
|
||||||
return oi, invp
|
return oi, invp
|
||||||
}
|
}
|
||||||
|
|
||||||
if checksumType.IsSet() {
|
if checksumType.IsSet() {
|
||||||
crc := gotPart.Checksums[checksumType.String()]
|
crc := expPart.Checksums[checksumType.String()]
|
||||||
if crc == "" {
|
if crc == "" {
|
||||||
return oi, InvalidPart{
|
return oi, InvalidPart{
|
||||||
PartNumber: part.PartNumber,
|
PartNumber: part.PartNumber,
|
||||||
|
@ -1088,24 +1110,24 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
||||||
if (i < len(parts)-1) && !isMinAllowedPartSize(currentFI.Parts[partIdx].ActualSize) {
|
if (i < len(parts)-1) && !isMinAllowedPartSize(currentFI.Parts[partIdx].ActualSize) {
|
||||||
return oi, PartTooSmall{
|
return oi, PartTooSmall{
|
||||||
PartNumber: part.PartNumber,
|
PartNumber: part.PartNumber,
|
||||||
PartSize: gotPart.ActualSize,
|
PartSize: expPart.ActualSize,
|
||||||
PartETag: part.ETag,
|
PartETag: part.ETag,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save for total object size.
|
// Save for total object size.
|
||||||
objectSize += gotPart.Size
|
objectSize += expPart.Size
|
||||||
|
|
||||||
// Save the consolidated actual size.
|
// Save the consolidated actual size.
|
||||||
objectActualSize += gotPart.ActualSize
|
objectActualSize += expPart.ActualSize
|
||||||
|
|
||||||
// Add incoming parts.
|
// Add incoming parts.
|
||||||
fi.Parts[i] = ObjectPartInfo{
|
fi.Parts[i] = ObjectPartInfo{
|
||||||
Number: part.PartNumber,
|
Number: part.PartNumber,
|
||||||
Size: gotPart.Size,
|
Size: expPart.Size,
|
||||||
ActualSize: gotPart.ActualSize,
|
ActualSize: expPart.ActualSize,
|
||||||
ModTime: gotPart.ModTime,
|
ModTime: expPart.ModTime,
|
||||||
Index: gotPart.Index,
|
Index: expPart.Index,
|
||||||
Checksums: nil, // Not transferred since we do not need it.
|
Checksums: nil, // Not transferred since we do not need it.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,6 +43,7 @@ import (
|
||||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||||
"github.com/minio/minio/internal/bucket/object/lock"
|
"github.com/minio/minio/internal/bucket/object/lock"
|
||||||
"github.com/minio/minio/internal/bucket/replication"
|
"github.com/minio/minio/internal/bucket/replication"
|
||||||
|
"github.com/minio/minio/internal/crypto"
|
||||||
"github.com/minio/minio/internal/event"
|
"github.com/minio/minio/internal/event"
|
||||||
"github.com/minio/minio/internal/hash"
|
"github.com/minio/minio/internal/hash"
|
||||||
xhttp "github.com/minio/minio/internal/http"
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
|
@ -2677,6 +2678,25 @@ func (es *erasureSingle) CompleteMultipartUpload(ctx context.Context, bucket str
|
||||||
return oi, err
|
return oi, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// However, in case of encryption, the persisted part ETags don't match
|
||||||
|
// what we have sent to the client during PutObjectPart. The reason is
|
||||||
|
// that ETags are encrypted. Hence, the client will send a list of complete
|
||||||
|
// part ETags of which non can match the ETag of any part. For example
|
||||||
|
// ETag (client): 30902184f4e62dd8f98f0aaff810c626
|
||||||
|
// ETag (server-internal): 20000f00ce5dc16e3f3b124f586ae1d88e9caa1c598415c2759bbb50e84a59f630902184f4e62dd8f98f0aaff810c626
|
||||||
|
//
|
||||||
|
// Therefore, we adjust all ETags sent by the client to match what is stored
|
||||||
|
// on the backend.
|
||||||
|
kind, isEncrypted := crypto.IsEncrypted(fi.Metadata)
|
||||||
|
|
||||||
|
var objectEncryptionKey []byte
|
||||||
|
if isEncrypted && kind == crypto.S3 {
|
||||||
|
objectEncryptionKey, err = decryptObjectMeta(nil, bucket, object, fi.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
return oi, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate full object size.
|
// Calculate full object size.
|
||||||
var objectSize int64
|
var objectSize int64
|
||||||
|
|
||||||
|
@ -2707,10 +2727,11 @@ func (es *erasureSingle) CompleteMultipartUpload(ctx context.Context, bucket str
|
||||||
|
|
||||||
// ensure that part ETag is canonicalized to strip off extraneous quotes
|
// ensure that part ETag is canonicalized to strip off extraneous quotes
|
||||||
part.ETag = canonicalizeETag(part.ETag)
|
part.ETag = canonicalizeETag(part.ETag)
|
||||||
if currentFI.Parts[partIdx].ETag != part.ETag {
|
expETag := tryDecryptETag(objectEncryptionKey, currentFI.Parts[partIdx].ETag, kind != crypto.S3)
|
||||||
|
if expETag != part.ETag {
|
||||||
invp := InvalidPart{
|
invp := InvalidPart{
|
||||||
PartNumber: part.PartNumber,
|
PartNumber: part.PartNumber,
|
||||||
ExpETag: currentFI.Parts[partIdx].ETag,
|
ExpETag: expETag,
|
||||||
GotETag: part.ETag,
|
GotETag: part.ETag,
|
||||||
}
|
}
|
||||||
return oi, invp
|
return oi, invp
|
||||||
|
|
|
@ -2534,7 +2534,7 @@ func (api objectAPIHandlers) CopyObjectPartHandler(w http.ResponseWriter, r *htt
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
key, err = decryptObjectInfo(key, dstBucket, dstObject, mi.UserDefined)
|
key, err = decryptObjectMeta(key, dstBucket, dstObject, mi.UserDefined)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||||
return
|
return
|
||||||
|
|
|
@ -427,7 +427,7 @@ func (api objectAPIHandlers) PutObjectPartHandler(w http.ResponseWriter, r *http
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculating object encryption key
|
// Calculating object encryption key
|
||||||
key, err = decryptObjectInfo(key, bucket, object, mi.UserDefined)
|
key, err = decryptObjectMeta(key, bucket, object, mi.UserDefined)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||||
return
|
return
|
||||||
|
@ -646,47 +646,6 @@ func (api objectAPIHandlers) CompleteMultipartUploadHandler(w http.ResponseWrite
|
||||||
multipartETag := etag.Multipart(completeETags...)
|
multipartETag := etag.Multipart(completeETags...)
|
||||||
opts.UserDefined["etag"] = multipartETag.String()
|
opts.UserDefined["etag"] = multipartETag.String()
|
||||||
|
|
||||||
// However, in case of encryption, the persisted part ETags don't match
|
|
||||||
// what we have sent to the client during PutObjectPart. The reason is
|
|
||||||
// that ETags are encrypted. Hence, the client will send a list of complete
|
|
||||||
// part ETags of which non can match the ETag of any part. For example
|
|
||||||
// ETag (client): 30902184f4e62dd8f98f0aaff810c626
|
|
||||||
// ETag (server-internal): 20000f00ce5dc16e3f3b124f586ae1d88e9caa1c598415c2759bbb50e84a59f630902184f4e62dd8f98f0aaff810c626
|
|
||||||
//
|
|
||||||
// Therefore, we adjust all ETags sent by the client to match what is stored
|
|
||||||
// on the backend.
|
|
||||||
// TODO(klauspost): This should be done while object is finalized instead of fetching the data twice
|
|
||||||
if objectAPI.IsEncryptionSupported() {
|
|
||||||
mi, err := objectAPI.GetMultipartInfo(ctx, bucket, object, uploadID, ObjectOptions{})
|
|
||||||
if err != nil {
|
|
||||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := crypto.IsEncrypted(mi.UserDefined); ok {
|
|
||||||
// Only fetch parts in between first and last.
|
|
||||||
// We already checked if we have at least one part.
|
|
||||||
start := complMultipartUpload.Parts[0].PartNumber
|
|
||||||
maxParts := complMultipartUpload.Parts[len(complMultipartUpload.Parts)-1].PartNumber - start + 1
|
|
||||||
listPartsInfo, err := objectAPI.ListObjectParts(ctx, bucket, object, uploadID, start-1, maxParts, ObjectOptions{})
|
|
||||||
if err != nil {
|
|
||||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sort.Slice(listPartsInfo.Parts, func(i, j int) bool {
|
|
||||||
return listPartsInfo.Parts[i].PartNumber < listPartsInfo.Parts[j].PartNumber
|
|
||||||
})
|
|
||||||
for i := range listPartsInfo.Parts {
|
|
||||||
for j := range complMultipartUpload.Parts {
|
|
||||||
if listPartsInfo.Parts[i].PartNumber == complMultipartUpload.Parts[j].PartNumber {
|
|
||||||
complMultipartUpload.Parts[j].ETag = listPartsInfo.Parts[i].ETag
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w = &whiteSpaceWriter{ResponseWriter: w, Flusher: w.(http.Flusher)}
|
w = &whiteSpaceWriter{ResponseWriter: w, Flusher: w.(http.Flusher)}
|
||||||
completeDoneCh := sendWhiteSpace(ctx, w)
|
completeDoneCh := sendWhiteSpace(ctx, w)
|
||||||
objInfo, err := completeMultiPartUpload(ctx, bucket, object, uploadID, complMultipartUpload.Parts, opts)
|
objInfo, err := completeMultiPartUpload(ctx, bucket, object, uploadID, complMultipartUpload.Parts, opts)
|
||||||
|
@ -854,7 +813,7 @@ func (api objectAPIHandlers) ListObjectPartsHandler(w http.ResponseWriter, r *ht
|
||||||
if kind, ok := crypto.IsEncrypted(listPartsInfo.UserDefined); ok && objectAPI.IsEncryptionSupported() {
|
if kind, ok := crypto.IsEncrypted(listPartsInfo.UserDefined); ok && objectAPI.IsEncryptionSupported() {
|
||||||
var objectEncryptionKey []byte
|
var objectEncryptionKey []byte
|
||||||
if kind == crypto.S3 {
|
if kind == crypto.S3 {
|
||||||
objectEncryptionKey, err = decryptObjectInfo(nil, bucket, object, listPartsInfo.UserDefined)
|
objectEncryptionKey, err = decryptObjectMeta(nil, bucket, object, listPartsInfo.UserDefined)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||||
return
|
return
|
||||||
|
|
Loading…
Reference in New Issue