Skip to content

Commit e4cecfe

Browse files
committed
Adding support to clone restricted assets
1 parent 7c89ebf commit e4cecfe

1 file changed

Lines changed: 118 additions & 13 deletions

File tree

cloudinary_cli/modules/clone.py

Lines changed: 118 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from click import command, option, style, argument
22
from cloudinary_cli.utils.utils import normalize_list_params, print_help_and_exit
33
import cloudinary
4+
from cloudinary.auth_token import _digest
45
from cloudinary_cli.utils.utils import run_tasks_concurrently
56
from cloudinary_cli.utils.api_utils import upload_file
6-
from cloudinary_cli.utils.config_utils import load_config, get_cloudinary_config, config_to_dict
7+
from cloudinary_cli.utils.config_utils import get_cloudinary_config, config_to_dict
78
from cloudinary_cli.defaults import logger
89
from cloudinary_cli.core.search import execute_single_request, handle_auto_pagination
10+
import time
11+
import re
912

1013
DEFAULT_MAX_RESULTS = 500
1114

@@ -32,32 +35,53 @@
3235
@option("-w", "--concurrent_workers", type=int, default=30,
3336
help="Specify the number of concurrent network threads.")
3437
@option("-fi", "--fields", multiple=True,
35-
help="Specify whether to copy tags and/or context. Valid options: `tags,context`.")
38+
help=("Specify whether to copy tags and/or context. "
39+
"Valid options: `tags,context`."))
3640
@option("-se", "--search_exp", default="",
3741
help="Define a search expression to filter the assets to clone.")
3842
@option("--async", "async_", is_flag=True, default=False,
3943
help="Clone the assets asynchronously.")
4044
@option("-nu", "--notification_url",
4145
help="Webhook notification URL.")
42-
def clone(target, force, overwrite, concurrent_workers, fields, search_exp, async_, notification_url):
46+
@option("-t", "--ttl", type=int, default=3600,
47+
help=("URL expiration duration in seconds. Only relevant if cloning "
48+
"restricted assets without providing an auth_key in which case "
49+
"a private download URL is generated which may incur additional "
50+
"bandwidth costs."))
51+
def clone(target, force, overwrite, concurrent_workers, fields,
52+
search_exp, async_, notification_url, ttl):
4353
if not target:
4454
print_help_and_exit()
4555

4656
target_config = get_cloudinary_config(target)
4757
if not target_config:
48-
logger.error("The specified config does not exist or the CLOUDINARY_URL scheme provided is invalid"
49-
" (expecting to start with 'cloudinary://').")
58+
logger.error("The specified config does not exist or the "
59+
"CLOUDINARY_URL scheme provided is invalid "
60+
"(expecting to start with 'cloudinary://').")
5061
return False
5162

5263
if cloudinary.config().cloud_name == target_config.cloud_name:
53-
logger.error("Target environment cannot be the same as source environment.")
64+
logger.error("Target environment cannot be the same "
65+
"as source environment.")
66+
return False
67+
68+
source_config = config_to_dict(cloudinary.config())
69+
auth_token = source_config.get("auth_token")
70+
if auth_token and not validate_authtoken(auth_token):
71+
# It is important to validate auth_token if provided as this prevents
72+
# customer from having to re-run the command as well as
73+
# saving Admin API calls and time.
5474
return False
5575

5676
source_assets = search_assets(force, search_exp)
77+
if not source_assets:
78+
return False
5779

5880
upload_list = []
5981
for r in source_assets.get('resources'):
60-
updated_options, asset_url = process_metadata(r, overwrite, async_, notification_url,
82+
updated_options, asset_url = process_metadata(r, overwrite, async_,
83+
notification_url,
84+
auth_token, ttl,
6185
normalize_list_params(fields))
6286
updated_options.update(config_to_dict(target_config))
6387
upload_list.append((asset_url, {**updated_options}))
@@ -73,9 +97,57 @@ def clone(target, force, overwrite, concurrent_workers, fields, search_exp, asyn
7397
return True
7498

7599

100+
def validate_authtoken(auth_token):
101+
duration = auth_token.get('duration')
102+
if not duration:
103+
logger.error("Duration is required when using auth_token. Include "
104+
"`auth_token[duration]=<duration>` in your config.")
105+
return False
106+
try:
107+
duration = int(duration)
108+
if duration < 0:
109+
logger.error("Duration cannot be negative.")
110+
return False
111+
except (ValueError, TypeError):
112+
logger.error("Duration must be an integer.")
113+
return False
114+
key = auth_token.get('key')
115+
if not key:
116+
logger.error("No auth_token key found. Include "
117+
"`auth_token[key]=<key>` in your config.")
118+
return False
119+
try:
120+
_digest("", key)
121+
except Exception as e:
122+
logger.error(f"Auth token invalid: {e}.")
123+
return False
124+
return True
125+
126+
76127
def search_assets(force, search_exp):
128+
# Prevent other unsupported types to prevent
129+
# avoidable errors during the upload process
130+
# and append the default types in not in the
131+
# search expression
132+
ALLOWED_TYPES = {"type:upload", "type:private", "type:authenticated",
133+
"type=upload", "type=private", "type=authenticated"}
134+
if search_exp and re.search(r"\btype\s*[:=]\s*\w+", search_exp):
135+
exp_types = re.findall(r"\btype\s*[:=]\s*\w+", search_exp)
136+
exp_types_cleaned = [''.join(t.split()) for t in exp_types]
137+
unallowed_types = [t for t in exp_types_cleaned if t not in ALLOWED_TYPES]
138+
if unallowed_types:
139+
logger.error("Unsupported type(s) in search expression: "
140+
f"{', '.join(unallowed_types)}. "
141+
"Only upload/private/authenticated types allowed.")
142+
return False
143+
elif search_exp:
144+
search_exp += " AND (type:upload OR type:private OR type:authenticated)"
145+
else:
146+
search_exp = "type:upload OR type:private OR type:authenticated"
147+
print(search_exp)
77148
search = cloudinary.search.Search().expression(search_exp)
78-
search.fields(['tags', 'context', 'access_control', 'secure_url', 'display_name'])
149+
search.fields(['tags', 'context', 'access_control',
150+
'secure_url', 'display_name', 'format'])
79151
search.max_results(DEFAULT_MAX_RESULTS)
80152

81153
res = execute_single_request(search, fields_to_keep="")
@@ -84,12 +156,45 @@ def search_assets(force, search_exp):
84156
return res
85157

86158

87-
def process_metadata(res, overwrite, async_, notification_url, copy_fields=""):
159+
def process_metadata(res, overwrite, async_, notification_url,
160+
auth_token, ttl, copy_fields=""):
88161
cloned_options = {}
89-
asset_url = res.get('secure_url')
90-
cloned_options['public_id'] = res.get('public_id')
91-
cloned_options['type'] = res.get('type')
92-
cloned_options['resource_type'] = res.get('resource_type')
162+
acc_ctl = res.get('access_control')
163+
pub_id = res.get('public_id')
164+
del_type = res.get('type')
165+
reso_type = res.get('resource_type')
166+
file_format = res.get('format')
167+
if (
168+
isinstance(acc_ctl, list)
169+
and len(acc_ctl) > 0
170+
and isinstance(acc_ctl[0], dict)
171+
and acc_ctl[0].get("access_type") == "token"
172+
):
173+
# Generate a time-limited URL for restricted assets
174+
# Use private url if no auth_token provided
175+
if auth_token:
176+
pub_id_format = (pub_id if reso_type == "raw"
177+
else f"{pub_id}.{file_format}")
178+
asset_url = cloudinary.utils.cloudinary_url(
179+
pub_id_format,
180+
type=del_type,
181+
resource_type=reso_type,
182+
secure=True,
183+
sign_url=True)
184+
else:
185+
expiry_date = int(time.time()) + ttl
186+
asset_url = cloudinary.utils.private_download_url(
187+
pub_id,
188+
file_format,
189+
resource_type=reso_type,
190+
type=del_type,
191+
expires_at=expiry_date)
192+
else:
193+
asset_url = res.get('secure_url')
194+
cloned_options['access_control'] = acc_ctl
195+
cloned_options['public_id'] = pub_id
196+
cloned_options['type'] = del_type
197+
cloned_options['resource_type'] = reso_type
93198
cloned_options['overwrite'] = overwrite
94199
cloned_options['async'] = async_
95200
if "tags" in copy_fields:

0 commit comments

Comments
 (0)