2021-04-18 19:41:13 +00:00
|
|
|
// Copyright (c) 2015-2021 MinIO, Inc.
|
|
|
|
//
|
|
|
|
// This file is part of MinIO Object Storage stack
|
|
|
|
//
|
|
|
|
// This program is free software: you can redistribute it and/or modify
|
|
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
|
|
// (at your option) any later version.
|
|
|
|
//
|
|
|
|
// This program is distributed in the hope that it will be useful
|
|
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
// GNU Affero General Public License for more details.
|
|
|
|
//
|
|
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
2020-07-09 00:36:56 +00:00
|
|
|
|
|
|
|
package cmd
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"fmt"
|
|
|
|
"net/http"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
2020-07-14 16:38:05 +00:00
|
|
|
"github.com/minio/minio-go/v7/pkg/encrypt"
|
2021-06-01 21:59:40 +00:00
|
|
|
"github.com/minio/minio/internal/crypto"
|
2022-08-29 23:57:16 +00:00
|
|
|
"github.com/minio/minio/internal/hash"
|
2021-06-01 21:59:40 +00:00
|
|
|
xhttp "github.com/minio/minio/internal/http"
|
|
|
|
"github.com/minio/minio/internal/logger"
|
2020-07-09 00:36:56 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
func getDefaultOpts(header http.Header, copySource bool, metadata map[string]string) (opts ObjectOptions, err error) {
|
|
|
|
var clientKey [32]byte
|
|
|
|
var sse encrypt.ServerSide
|
|
|
|
|
|
|
|
opts = ObjectOptions{UserDefined: metadata}
|
|
|
|
if copySource {
|
|
|
|
if crypto.SSECopy.IsRequested(header) {
|
|
|
|
clientKey, err = crypto.SSECopy.ParseHTTP(header)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if sse, err = encrypt.NewSSEC(clientKey[:]); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
opts.ServerSideEncryption = encrypt.SSECopy(sse)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if crypto.SSEC.IsRequested(header) {
|
|
|
|
clientKey, err = crypto.SSEC.ParseHTTP(header)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if sse, err = encrypt.NewSSEC(clientKey[:]); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
opts.ServerSideEncryption = sse
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if crypto.S3.IsRequested(header) || (metadata != nil && crypto.S3.IsEncrypted(metadata)) {
|
|
|
|
opts.ServerSideEncryption = encrypt.NewSSE()
|
|
|
|
}
|
2021-01-27 19:22:34 +00:00
|
|
|
if v, ok := header[xhttp.MinIOSourceProxyRequest]; ok {
|
|
|
|
opts.ProxyHeaderSet = true
|
|
|
|
opts.ProxyRequest = strings.Join(v, "") == "true"
|
|
|
|
}
|
2021-09-18 20:31:35 +00:00
|
|
|
if _, ok := header[xhttp.MinIOSourceReplicationRequest]; ok {
|
|
|
|
opts.ReplicationRequest = true
|
|
|
|
}
|
2022-07-17 02:35:24 +00:00
|
|
|
opts.Speedtest = header.Get(globalObjectPerfUserMetadata) != ""
|
2020-07-09 00:36:56 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// get ObjectOptions for GET calls from encryption headers
|
|
|
|
func getOpts(ctx context.Context, r *http.Request, bucket, object string) (ObjectOptions, error) {
|
2022-10-25 00:44:15 +00:00
|
|
|
var opts ObjectOptions
|
2020-07-09 00:36:56 +00:00
|
|
|
|
|
|
|
var partNumber int
|
|
|
|
var err error
|
2021-08-08 05:43:01 +00:00
|
|
|
if pn := r.Form.Get(xhttp.PartNumber); pn != "" {
|
2020-07-09 00:36:56 +00:00
|
|
|
partNumber, err = strconv.Atoi(pn)
|
|
|
|
if err != nil {
|
|
|
|
return opts, err
|
|
|
|
}
|
2023-04-19 05:34:30 +00:00
|
|
|
if isMaxPartID(partNumber) {
|
|
|
|
return opts, errInvalidMaxParts
|
|
|
|
}
|
2020-07-09 00:36:56 +00:00
|
|
|
if partNumber <= 0 {
|
|
|
|
return opts, errInvalidArgument
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-08-08 05:43:01 +00:00
|
|
|
vid := strings.TrimSpace(r.Form.Get(xhttp.VersionID))
|
2020-07-09 00:36:56 +00:00
|
|
|
if vid != "" && vid != nullVersionID {
|
2022-12-15 16:25:05 +00:00
|
|
|
if _, err := uuid.Parse(vid); err != nil {
|
2020-11-29 05:15:45 +00:00
|
|
|
return opts, InvalidVersionID{
|
2020-07-09 00:36:56 +00:00
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
VersionID: vid,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// default case of passing encryption headers to backend
|
|
|
|
opts, err = getDefaultOpts(r.Header, false, nil)
|
|
|
|
if err != nil {
|
|
|
|
return opts, err
|
|
|
|
}
|
|
|
|
opts.PartNumber = partNumber
|
|
|
|
opts.VersionID = vid
|
2020-11-20 02:43:58 +00:00
|
|
|
delMarker := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceDeleteMarker))
|
|
|
|
if delMarker != "" {
|
2020-11-12 20:10:59 +00:00
|
|
|
switch delMarker {
|
|
|
|
case "true":
|
|
|
|
opts.DeleteMarker = true
|
|
|
|
case "false":
|
|
|
|
default:
|
|
|
|
err = fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceDeleteMarker, fmt.Errorf("DeleteMarker should be true or false"))
|
2020-11-20 02:43:58 +00:00
|
|
|
logger.LogIf(ctx, err)
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
2020-11-12 20:10:59 +00:00
|
|
|
Err: err,
|
2020-11-20 02:43:58 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-10-21 21:45:06 +00:00
|
|
|
replReadyCheck := strings.TrimSpace(r.Header.Get(xhttp.MinIOCheckDMReplicationReady))
|
|
|
|
if replReadyCheck != "" {
|
|
|
|
switch replReadyCheck {
|
|
|
|
case "true":
|
|
|
|
opts.CheckDMReplicationReady = true
|
|
|
|
case "false":
|
|
|
|
default:
|
|
|
|
err = fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOCheckDMReplicationReady, fmt.Errorf("should be true or false"))
|
|
|
|
logger.LogIf(ctx, err)
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
Err: err,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-07-22 14:19:43 +00:00
|
|
|
opts.Tagging = r.Header.Get(xhttp.AmzTagDirective) == accessDirective
|
2022-06-06 22:14:56 +00:00
|
|
|
opts.Versioned = globalBucketVersioningSys.PrefixEnabled(bucket, object)
|
2022-05-08 23:50:31 +00:00
|
|
|
opts.VersionSuspended = globalBucketVersioningSys.PrefixSuspended(bucket, object)
|
2020-07-09 00:36:56 +00:00
|
|
|
return opts, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func delOpts(ctx context.Context, r *http.Request, bucket, object string) (opts ObjectOptions, err error) {
|
|
|
|
opts, err = getOpts(ctx, r, bucket, object)
|
|
|
|
if err != nil {
|
|
|
|
return opts, err
|
|
|
|
}
|
2023-07-22 14:19:43 +00:00
|
|
|
|
|
|
|
deletePrefix := false
|
|
|
|
if d := r.Header.Get(xhttp.MinIOForceDelete); d != "" {
|
|
|
|
if b, err := strconv.ParseBool(d); err == nil {
|
|
|
|
deletePrefix = b
|
|
|
|
} else {
|
|
|
|
return opts, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
opts.DeletePrefix = deletePrefix
|
2022-05-07 02:05:28 +00:00
|
|
|
opts.Versioned = globalBucketVersioningSys.PrefixEnabled(bucket, object)
|
2022-08-24 20:46:29 +00:00
|
|
|
// Objects matching prefixes should not leave delete markers,
|
|
|
|
// dramatically reduces namespace pollution while keeping the
|
|
|
|
// benefits of replication, make sure to apply version suspension
|
|
|
|
// only at bucket level instead.
|
|
|
|
opts.VersionSuspended = globalBucketVersioningSys.Suspended(bucket)
|
2023-05-04 21:43:52 +00:00
|
|
|
// For directory objects, delete `null` version permanently.
|
|
|
|
if isDirObject(object) && opts.VersionID == "" {
|
|
|
|
opts.VersionID = nullVersionID
|
|
|
|
}
|
|
|
|
|
2020-11-20 02:43:58 +00:00
|
|
|
delMarker := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceDeleteMarker))
|
|
|
|
if delMarker != "" {
|
2020-11-12 20:10:59 +00:00
|
|
|
switch delMarker {
|
|
|
|
case "true":
|
|
|
|
opts.DeleteMarker = true
|
|
|
|
case "false":
|
|
|
|
default:
|
|
|
|
err = fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceDeleteMarker, fmt.Errorf("DeleteMarker should be true or false"))
|
2020-11-20 02:43:58 +00:00
|
|
|
logger.LogIf(ctx, err)
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
2020-11-12 20:10:59 +00:00
|
|
|
Err: err,
|
2020-11-20 02:43:58 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
mtime := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceMTime))
|
|
|
|
if mtime != "" {
|
2020-12-18 19:37:28 +00:00
|
|
|
opts.MTime, err = time.Parse(time.RFC3339Nano, mtime)
|
2020-11-20 02:43:58 +00:00
|
|
|
if err != nil {
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
Err: fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceMTime, err),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
opts.MTime = UTCNow()
|
|
|
|
}
|
2020-07-09 00:36:56 +00:00
|
|
|
return opts, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// get ObjectOptions for PUT calls from encryption headers and metadata
|
|
|
|
func putOpts(ctx context.Context, r *http.Request, bucket, object string, metadata map[string]string) (opts ObjectOptions, err error) {
|
2022-05-07 02:05:28 +00:00
|
|
|
versioned := globalBucketVersioningSys.PrefixEnabled(bucket, object)
|
|
|
|
versionSuspended := globalBucketVersioningSys.PrefixSuspended(bucket, object)
|
|
|
|
|
2021-08-08 05:43:01 +00:00
|
|
|
vid := strings.TrimSpace(r.Form.Get(xhttp.VersionID))
|
2020-07-09 00:36:56 +00:00
|
|
|
if vid != "" && vid != nullVersionID {
|
|
|
|
_, err := uuid.Parse(vid)
|
|
|
|
if err != nil {
|
|
|
|
logger.LogIf(ctx, err)
|
2020-11-29 05:15:45 +00:00
|
|
|
return opts, InvalidVersionID{
|
2020-07-09 00:36:56 +00:00
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
VersionID: vid,
|
|
|
|
}
|
|
|
|
}
|
2020-09-30 20:36:37 +00:00
|
|
|
if !versioned {
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
Err: fmt.Errorf("VersionID specified %s, but versioning not enabled on %s", opts.VersionID, bucket),
|
|
|
|
}
|
|
|
|
}
|
2020-07-09 00:36:56 +00:00
|
|
|
}
|
2022-08-29 23:57:16 +00:00
|
|
|
|
2020-10-03 01:32:22 +00:00
|
|
|
mtimeStr := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceMTime))
|
2020-11-20 02:43:58 +00:00
|
|
|
mtime := UTCNow()
|
2020-10-03 01:32:22 +00:00
|
|
|
if mtimeStr != "" {
|
2023-03-27 18:51:54 +00:00
|
|
|
mtime, err = time.Parse(time.RFC3339Nano, mtimeStr)
|
2020-07-09 00:36:56 +00:00
|
|
|
if err != nil {
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
Err: fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceMTime, err),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2021-09-18 20:31:35 +00:00
|
|
|
retaintimeStr := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceObjectRetentionTimestamp))
|
|
|
|
retaintimestmp := mtime
|
|
|
|
if retaintimeStr != "" {
|
|
|
|
retaintimestmp, err = time.Parse(time.RFC3339, retaintimeStr)
|
|
|
|
if err != nil {
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
Err: fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceObjectRetentionTimestamp, err),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
lholdtimeStr := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceObjectLegalHoldTimestamp))
|
|
|
|
lholdtimestmp := mtime
|
|
|
|
if lholdtimeStr != "" {
|
|
|
|
lholdtimestmp, err = time.Parse(time.RFC3339, lholdtimeStr)
|
|
|
|
if err != nil {
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
Err: fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceObjectLegalHoldTimestamp, err),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
tagtimeStr := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceTaggingTimestamp))
|
|
|
|
taggingtimestmp := mtime
|
|
|
|
if tagtimeStr != "" {
|
|
|
|
taggingtimestmp, err = time.Parse(time.RFC3339, tagtimeStr)
|
|
|
|
if err != nil {
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
Err: fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceTaggingTimestamp, err),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-15 01:44:04 +00:00
|
|
|
if metadata == nil {
|
|
|
|
metadata = make(map[string]string)
|
2020-08-13 00:32:24 +00:00
|
|
|
}
|
2020-11-12 20:12:09 +00:00
|
|
|
|
2023-05-23 14:58:33 +00:00
|
|
|
wantCRC, err := hash.GetContentChecksum(r.Header)
|
2022-08-29 23:57:16 +00:00
|
|
|
if err != nil {
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
Err: fmt.Errorf("invalid/unknown checksum sent: %v", err),
|
|
|
|
}
|
|
|
|
}
|
2022-11-26 22:43:32 +00:00
|
|
|
etag := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceETag))
|
2022-08-29 23:57:16 +00:00
|
|
|
|
2020-07-09 00:36:56 +00:00
|
|
|
if crypto.S3KMS.IsRequested(r.Header) {
|
|
|
|
keyID, context, err := crypto.S3KMS.ParseHTTP(r.Header)
|
|
|
|
if err != nil {
|
|
|
|
return ObjectOptions{}, err
|
|
|
|
}
|
|
|
|
sseKms, err := encrypt.NewSSEKMS(keyID, context)
|
|
|
|
if err != nil {
|
|
|
|
return ObjectOptions{}, err
|
|
|
|
}
|
|
|
|
return ObjectOptions{
|
|
|
|
ServerSideEncryption: sseKms,
|
|
|
|
UserDefined: metadata,
|
|
|
|
VersionID: vid,
|
|
|
|
Versioned: versioned,
|
2021-08-17 14:50:00 +00:00
|
|
|
VersionSuspended: versionSuspended,
|
2020-10-03 01:32:22 +00:00
|
|
|
MTime: mtime,
|
2022-08-29 23:57:16 +00:00
|
|
|
WantChecksum: wantCRC,
|
2022-11-26 22:43:32 +00:00
|
|
|
PreserveETag: etag,
|
2020-07-09 00:36:56 +00:00
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
// default case of passing encryption headers and UserDefined metadata to backend
|
|
|
|
opts, err = getDefaultOpts(r.Header, false, metadata)
|
|
|
|
if err != nil {
|
|
|
|
return opts, err
|
|
|
|
}
|
2023-05-04 21:43:52 +00:00
|
|
|
|
2020-07-09 00:36:56 +00:00
|
|
|
opts.VersionID = vid
|
|
|
|
opts.Versioned = versioned
|
2021-08-17 14:50:00 +00:00
|
|
|
opts.VersionSuspended = versionSuspended
|
2023-05-04 21:43:52 +00:00
|
|
|
|
|
|
|
// For directory objects skip creating new versions.
|
|
|
|
if isDirObject(object) && vid == "" {
|
|
|
|
opts.VersionID = nullVersionID
|
|
|
|
}
|
|
|
|
|
2020-10-03 01:32:22 +00:00
|
|
|
opts.MTime = mtime
|
2021-09-18 20:31:35 +00:00
|
|
|
opts.ReplicationSourceLegalholdTimestamp = lholdtimestmp
|
|
|
|
opts.ReplicationSourceRetentionTimestamp = retaintimestmp
|
|
|
|
opts.ReplicationSourceTaggingTimestamp = taggingtimestmp
|
2022-11-26 22:43:32 +00:00
|
|
|
opts.PreserveETag = etag
|
2022-08-29 23:57:16 +00:00
|
|
|
opts.WantChecksum = wantCRC
|
|
|
|
|
2020-07-09 00:36:56 +00:00
|
|
|
return opts, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// get ObjectOptions for Copy calls with encryption headers provided on the target side and source side metadata
|
|
|
|
func copyDstOpts(ctx context.Context, r *http.Request, bucket, object string, metadata map[string]string) (opts ObjectOptions, err error) {
|
|
|
|
return putOpts(ctx, r, bucket, object, metadata)
|
|
|
|
}
|
|
|
|
|
|
|
|
// get ObjectOptions for Copy calls with encryption headers provided on the source side
|
|
|
|
func copySrcOpts(ctx context.Context, r *http.Request, bucket, object string) (ObjectOptions, error) {
|
2022-10-25 00:44:15 +00:00
|
|
|
var opts ObjectOptions
|
2020-07-09 00:36:56 +00:00
|
|
|
|
|
|
|
// default case of passing encryption headers to backend
|
|
|
|
opts, err := getDefaultOpts(r.Header, false, nil)
|
|
|
|
if err != nil {
|
|
|
|
return opts, err
|
|
|
|
}
|
|
|
|
return opts, nil
|
|
|
|
}
|
2021-06-30 14:44:24 +00:00
|
|
|
|
|
|
|
// get ObjectOptions for CompleteMultipart calls
|
|
|
|
func completeMultipartOpts(ctx context.Context, r *http.Request, bucket, object string) (opts ObjectOptions, err error) {
|
|
|
|
mtimeStr := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceMTime))
|
|
|
|
mtime := UTCNow()
|
|
|
|
if mtimeStr != "" {
|
2023-03-27 18:51:54 +00:00
|
|
|
mtime, err = time.Parse(time.RFC3339Nano, mtimeStr)
|
2021-06-30 14:44:24 +00:00
|
|
|
if err != nil {
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
Err: fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceMTime, err),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-05-23 14:58:33 +00:00
|
|
|
opts.WantChecksum, err = hash.GetContentChecksum(r.Header)
|
2022-08-29 23:57:16 +00:00
|
|
|
if err != nil {
|
|
|
|
return opts, InvalidArgument{
|
|
|
|
Bucket: bucket,
|
|
|
|
Object: object,
|
|
|
|
Err: fmt.Errorf("invalid/unknown checksum sent: %v", err),
|
|
|
|
}
|
|
|
|
}
|
2021-06-30 14:44:24 +00:00
|
|
|
opts.MTime = mtime
|
2021-09-09 05:25:23 +00:00
|
|
|
opts.UserDefined = make(map[string]string)
|
2022-12-07 18:18:18 +00:00
|
|
|
|
|
|
|
// Transfer SSEC key in opts.EncryptFn
|
|
|
|
if crypto.SSEC.IsRequested(r.Header) {
|
|
|
|
key, err := ParseSSECustomerRequest(r)
|
|
|
|
if err == nil {
|
|
|
|
// Set EncryptFn to return SSEC key
|
|
|
|
opts.EncryptFn = func(baseKey string, data []byte) []byte {
|
|
|
|
return key
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2021-06-30 14:44:24 +00:00
|
|
|
return opts, nil
|
|
|
|
}
|