11from click import command , option , style , argument
22from cloudinary_cli .utils .utils import normalize_list_params , print_help_and_exit
33import cloudinary
4+ from cloudinary .auth_token import _digest
45from cloudinary_cli .utils .utils import run_tasks_concurrently
56from 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
78from cloudinary_cli .defaults import logger
89from cloudinary_cli .core .search import execute_single_request , handle_auto_pagination
10+ import time
11+ import re
912
1013DEFAULT_MAX_RESULTS = 500
1114
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+
76127def 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