1+ from PyQt5 .QtCore import Qt
2+ from PyQt5 .QtGui import QIcon
3+ from PyQt5 .QtWidgets import (QWidget , QVBoxLayout , QHBoxLayout , QPushButton ,
4+ QMessageBox , QGroupBox , QFormLayout , QLineEdit ,
5+ QListWidget , QListWidgetItem , QCheckBox , QTextEdit ,
6+ QLabel )
7+ from botocore .exceptions import ClientError
8+
9+ from finch .common import s3_session , center_window , resource_path , StringUtils
10+ from finch .error import show_error_dialog
11+
12+
13+ class CORSWindow (QWidget ):
14+ """
15+ CORS Window to manage CORS configurations for passed bucket name.
16+ """
17+ def __init__ (self , bucket_name ):
18+ super ().__init__ ()
19+ self .bucket_name = bucket_name
20+ self .setWindowTitle (f"CORS Configurations - { bucket_name } " )
21+ self .resize (600 , 400 )
22+ center_window (self )
23+
24+ layout = QVBoxLayout ()
25+ self .setLayout (layout )
26+
27+ # List of CORS Rules
28+ self .rules_list = QListWidget ()
29+ self .rules_list .itemClicked .connect (self .show_rule_details )
30+ self .rules_list .currentRowChanged .connect (self .show_rule_details )
31+ layout .addWidget (self .rules_list )
32+
33+ # Rule Editor Group
34+ rule_group = QGroupBox ("Rule Details" )
35+ rule_layout = QFormLayout ()
36+ rule_group .setLayout (rule_layout )
37+
38+ # Set the form layout to expand fields horizontally
39+ rule_layout .setFieldGrowthPolicy (QFormLayout .ExpandingFieldsGrow )
40+
41+ self .allowed_origins_input = QTextEdit ()
42+ self .allowed_origins_input .setMaximumHeight (80 )
43+ rule_layout .addRow ("Allowed Origins:" , self .allowed_origins_input )
44+ help_label = QLabel ("Enter * or http://example.com\n One origin per line" )
45+ help_label .setStyleSheet ("QLabel { font-size: 11px; font-style: italic; color: #666; }" )
46+ rule_layout .addRow ("" , help_label )
47+
48+ # Methods as checkboxes
49+ methods_group = QWidget ()
50+ methods_layout = QHBoxLayout ()
51+ methods_group .setLayout (methods_layout )
52+ self .method_checkboxes = {}
53+ for method in ["GET" , "PUT" , "POST" , "DELETE" , "HEAD" ]:
54+ checkbox = QCheckBox (method )
55+ self .method_checkboxes [method ] = checkbox
56+ methods_layout .addWidget (checkbox )
57+ rule_layout .addRow ("Allowed Methods:" , methods_group )
58+
59+ self .allowed_headers_input = QTextEdit ()
60+ self .allowed_headers_input .setMaximumHeight (80 )
61+ rule_layout .addRow ("Allowed Headers:" , self .allowed_headers_input )
62+ help_label = QLabel ("Enter * or specific headers\n One header per line" )
63+ help_label .setStyleSheet ("QLabel { font-size: 11px; font-style: italic; color: #666; }" )
64+ rule_layout .addRow ("" , help_label )
65+
66+ self .expose_headers_input = QTextEdit ()
67+ self .expose_headers_input .setMaximumHeight (80 )
68+ rule_layout .addRow ("Expose Headers:" , self .expose_headers_input )
69+ help_label = QLabel ("Enter headers to expose\n One header per line" )
70+ help_label .setStyleSheet ("QLabel { font-size: 11px; font-style: italic; color: #666; }" )
71+ rule_layout .addRow ("" , help_label )
72+
73+ self .max_age_input = QLineEdit ()
74+ rule_layout .addRow ("Max Age (seconds):" , self .max_age_input )
75+ help_label = QLabel ("Enter maximum age in seconds" )
76+ help_label .setStyleSheet ("QLabel { font-size: 11px; font-style: italic; color: #666; }" )
77+ rule_layout .addRow ("" , help_label )
78+
79+ # Add buttons to form
80+ buttons_widget = QWidget ()
81+ buttons_layout = QHBoxLayout ()
82+ buttons_widget .setLayout (buttons_layout )
83+
84+ self .save_rule_button = QPushButton ("Save Changes" )
85+ self .save_rule_button .setIcon (QIcon (resource_path ("img/save.svg" )))
86+ self .save_rule_button .clicked .connect (self .save_rule )
87+ self .save_rule_button .setEnabled (False )
88+
89+ self .delete_rule_button = QPushButton ("Delete Rule" )
90+ self .delete_rule_button .setIcon (QIcon (resource_path ("img/trash.svg" )))
91+ self .delete_rule_button .clicked .connect (self .delete_rule )
92+ self .delete_rule_button .setEnabled (False )
93+
94+ buttons_layout .addWidget (self .save_rule_button )
95+ buttons_layout .addWidget (self .delete_rule_button )
96+ buttons_layout .setAlignment (Qt .AlignLeft )
97+ rule_layout .addRow ("" , buttons_widget )
98+
99+ layout .addWidget (rule_group )
100+
101+ # Bottom buttons
102+ button_layout = QHBoxLayout ()
103+ self .add_rule_button = QPushButton ("Add New Rule" )
104+ self .add_rule_button .setIcon (QIcon (resource_path ("img/plus.svg" )))
105+ self .add_rule_button .clicked .connect (self .add_new_rule )
106+
107+ self .apply_button = QPushButton ("Apply CORS Rules" )
108+ self .apply_button .setIcon (QIcon (resource_path ("img/save.svg" )))
109+ self .apply_button .clicked .connect (self .apply_cors )
110+
111+ button_layout .addWidget (self .add_rule_button )
112+ button_layout .addWidget (self .apply_button )
113+ button_layout .setAlignment (Qt .AlignRight )
114+
115+ layout .addLayout (button_layout )
116+
117+ # Start with form disabled
118+ self ._enable_form (False )
119+
120+ # Load existing CORS configuration
121+ self .load_cors_config ()
122+
123+ def load_cors_config (self ):
124+ """Load existing CORS configuration for the bucket"""
125+ try :
126+ response = s3_session .resource .meta .client .get_bucket_cors (Bucket = self .bucket_name )
127+ rules = response .get ('CORSRules' , [])
128+
129+ for rule in rules :
130+ item = QListWidgetItem (self ._format_rule_display (
131+ rule ['AllowedMethods' ],
132+ rule ['AllowedOrigins' ]
133+ ))
134+ item .setData (Qt .UserRole , rule )
135+ self .rules_list .addItem (item )
136+
137+ # Select first rule if any exist
138+ if self .rules_list .count () > 0 :
139+ self .rules_list .setCurrentRow (0 )
140+
141+ except ClientError as e :
142+ if e .response ['Error' ]['Code' ] == 'NoSuchCORSConfiguration' :
143+ # No CORS configuration exists yet
144+ pass
145+ else :
146+ show_error_dialog (str (e ))
147+
148+ def show_rule_details (self , item_or_row ):
149+ """Show details of selected rule"""
150+ # Convert row number to item if needed
151+ if isinstance (item_or_row , int ):
152+ item = self .rules_list .item (item_or_row )
153+ else :
154+ item = item_or_row
155+
156+ if not item :
157+ self ._enable_form (False )
158+ return
159+
160+ self ._enable_form (True )
161+ rule = item .data (Qt .UserRole )
162+ self .allowed_origins_input .setPlainText ("\n " .join (rule .get ('AllowedOrigins' , [])))
163+
164+ # Set method checkboxes
165+ allowed_methods = rule .get ('AllowedMethods' , [])
166+ for method , checkbox in self .method_checkboxes .items ():
167+ checkbox .setChecked (method in allowed_methods )
168+
169+ self .allowed_headers_input .setPlainText ("\n " .join (rule .get ('AllowedHeaders' , [])))
170+ self .expose_headers_input .setPlainText ("\n " .join (rule .get ('ExposeHeaders' , [])))
171+ self .max_age_input .setText (str (rule .get ('MaxAgeSeconds' , '' )))
172+ self .save_rule_button .setEnabled (False ) # Disable save button when loading rule
173+ self .delete_rule_button .setEnabled (True )
174+
175+ def add_new_rule (self ):
176+ """Add empty rule to the list"""
177+ # Save existing changes if any
178+ if self .save_rule_button .isEnabled ():
179+ if not self ._get_rule_from_form (): # Returns None if validation fails
180+ return # Don't add new rule if current rule has validation errors
181+ self .save_rule ()
182+
183+ # Then add new rule
184+ item = QListWidgetItem ("New Rule" )
185+ item .setData (Qt .UserRole , {"AllowedOrigins" : [], "AllowedMethods" : []})
186+ self .rules_list .addItem (item )
187+ self .rules_list .setCurrentItem (item )
188+ self ._clear_form ()
189+ self ._enable_form (True )
190+ self .save_rule_button .setEnabled (True )
191+ self .delete_rule_button .setEnabled (True )
192+
193+ def _format_rule_display (self , methods , origins ):
194+ """Format CORS rules to display in the list"""
195+ methods_text = StringUtils .format_list_with_conjunction (methods )
196+ origins_text = StringUtils .format_list_with_conjunction (['anywhere' if o == '*' else o for o in origins ])
197+ return f"{ methods_text } on { origins_text } "
198+
199+ def save_rule (self ):
200+ """Save current form data to selected rule"""
201+ current_item = self .rules_list .currentItem ()
202+ if current_item :
203+ updated_rule = self ._get_rule_from_form ()
204+ if updated_rule :
205+ current_item .setData (Qt .UserRole , updated_rule )
206+ current_item .setText (self ._format_rule_display (
207+ updated_rule ['AllowedMethods' ],
208+ updated_rule ['AllowedOrigins' ]
209+ ))
210+ self .save_rule_button .setEnabled (False )
211+
212+ def _on_form_changed (self ):
213+ """Enable save button when form content changes"""
214+ if self .rules_list .currentItem ():
215+ current_rule = self .rules_list .currentItem ().data (Qt .UserRole )
216+ new_rule = self ._get_rule_from_form (validate = False )
217+ if new_rule :
218+ self .save_rule_button .setEnabled (current_rule != new_rule )
219+
220+ def _get_rule_from_form (self , validate = True ):
221+ """Get rule dict from form fields"""
222+ origins = [o .strip () for o in self .allowed_origins_input .toPlainText ().splitlines () if o .strip ()]
223+ methods = [method for method , checkbox in self .method_checkboxes .items () if checkbox .isChecked ()]
224+
225+ if validate :
226+ if not origins :
227+ show_error_dialog ("At least one origin is required" )
228+ return None
229+
230+ if not methods :
231+ show_error_dialog ("At least one method must be selected" )
232+ return None
233+
234+ rule = {
235+ "AllowedOrigins" : origins ,
236+ "AllowedMethods" : methods
237+ }
238+
239+ headers = [h .strip () for h in self .allowed_headers_input .toPlainText ().splitlines () if h .strip ()]
240+ if headers :
241+ rule ["AllowedHeaders" ] = headers
242+
243+ expose = [h .strip () for h in self .expose_headers_input .toPlainText ().splitlines () if h .strip ()]
244+ if expose :
245+ rule ["ExposeHeaders" ] = expose
246+
247+ if self .max_age_input .text ().strip ():
248+ try :
249+ rule ["MaxAgeSeconds" ] = int (self .max_age_input .text ())
250+ except ValueError :
251+ if validate :
252+ show_error_dialog ("Max Age must be a number" )
253+ return None
254+
255+ return rule
256+
257+ def _clear_form (self ):
258+ """Clear all form fields"""
259+ self .allowed_origins_input .clear ()
260+ for checkbox in self .method_checkboxes .values ():
261+ checkbox .setChecked (False )
262+ self .allowed_headers_input .clear ()
263+ self .expose_headers_input .clear ()
264+ self .max_age_input .clear ()
265+ self .save_rule_button .setEnabled (False )
266+ self .delete_rule_button .setEnabled (False )
267+
268+ def delete_rule (self ):
269+ """Delete selected CORS rule"""
270+ current_row = self .rules_list .currentRow ()
271+ if current_row >= 0 :
272+ self .rules_list .takeItem (current_row )
273+ self ._clear_form ()
274+
275+ # Select last rule if any exist
276+ new_count = self .rules_list .count ()
277+ if new_count > 0 :
278+ last_row = new_count - 1
279+ self .rules_list .setCurrentRow (last_row )
280+ self .show_rule_details (last_row ) # Explicitly load the form
281+ else :
282+ self ._enable_form (False )
283+
284+ def apply_cors (self ):
285+ """Apply CORS configuration to bucket"""
286+ try :
287+ rules = []
288+
289+ # Update the current rule if one is selected
290+ current_item = self .rules_list .currentItem ()
291+ if current_item :
292+ updated_rule = self ._get_rule_from_form ()
293+ if updated_rule :
294+ current_item .setData (Qt .UserRole , updated_rule )
295+ current_item .setText (f"{ StringUtils .format_list_with_conjunction (updated_rule ['AllowedMethods' ])} on { ', ' .join (updated_rule ['AllowedOrigins' ])} " )
296+
297+ # Collect all rules
298+ for i in range (self .rules_list .count ()):
299+ rules .append (self .rules_list .item (i ).data (Qt .UserRole ))
300+
301+ if rules :
302+ s3_session .resource .meta .client .put_bucket_cors (
303+ Bucket = self .bucket_name ,
304+ CORSConfiguration = {
305+ 'CORSRules' : rules
306+ }
307+ )
308+ else :
309+ s3_session .resource .meta .client .delete_bucket_cors (Bucket = self .bucket_name )
310+
311+ QMessageBox .information (self , "Success" , "CORS configuration applied successfully" )
312+
313+ except Exception as e :
314+ show_error_dialog (str (e ))
315+
316+ def _enable_form (self , enabled = True ):
317+ """Enable/disable all form fields"""
318+ self .allowed_origins_input .setEnabled (enabled )
319+ for checkbox in self .method_checkboxes .values ():
320+ checkbox .setEnabled (enabled )
321+ self .allowed_headers_input .setEnabled (enabled )
322+ self .expose_headers_input .setEnabled (enabled )
323+ self .max_age_input .setEnabled (enabled )
324+ self .save_rule_button .setEnabled (False ) # Always start with save disabled
325+ self .delete_rule_button .setEnabled (enabled )
0 commit comments