Skip to content

Commit 875902f

Browse files
authored
Merge pull request #26 from geekdinazor/main
Bucket CORS Configuration tool added
2 parents 956bf56 + 360c34f commit 875902f

20 files changed

+371
-242
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Finch S3 Client is an open source project, and we welcome contributions from the
3939
## License
4040
Finch S3 Client is released under the [MIT License](https://github.com/mantis-software-company/finch/blob/main/LICENSE).
4141

42-
Icons used in GUI was copied from GNOME [Adwaita](https://gitlab.gnome.org/GNOME/adwaita-icon-theme) icon theme.
42+
Icons used in GUI is taken from [Feather Icons](https://feathericons.com/).
4343

4444
## Credits
4545
S3 Client was created by [Furkan Kalkan](https://github.com/geekdinazor).

finch/__main__.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from finch.about import AboutWindow
1818
from finch.common import ObjectType, s3_session, apply_theme, center_window, CONFIG_PATH, StringUtils, resource_path
19+
from finch.cors import CORSWindow
1920
from finch.credentials import CredentialsManager, ManageCredentialsWindow
2021
from finch.download import DownloadProgressDialog
2122
from finch.error import show_error_dialog
@@ -156,7 +157,7 @@ def show_s3_files(self, cred_index):
156157

157158
download_action = QAction(self)
158159
download_action.setText("&Download")
159-
download_action.setIcon(QIcon(resource_path('img/save.svg')))
160+
download_action.setIcon(QIcon(resource_path('img/download.svg')))
160161
download_action.triggered.connect(self.download_file)
161162
download_action.setDisabled(True)
162163

@@ -296,6 +297,15 @@ def open_context_menu(self, position):
296297
create_folder_action.setIcon(QIcon(resource_path('img/new-folder.svg')))
297298
create_folder_action.triggered.connect(self.create_folder)
298299
menu.addAction(create_folder_action)
300+
301+
tools_menu = menu.addMenu("Tools")
302+
tools_menu.setIcon(QIcon(resource_path('img/tools.svg')))
303+
304+
cors_action = QAction(self)
305+
cors_action.setText("&CORS Configurations")
306+
cors_action.setIcon(QIcon(resource_path('img/globe.svg')))
307+
cors_action.triggered.connect(self.show_cors_window)
308+
tools_menu.addAction(cors_action)
299309
elif indexes[1].data() == ObjectType.FOLDER:
300310
delete_folder_action = QAction("Delete Folder")
301311
delete_folder_action.setIcon(QIcon(resource_path('img/trash.svg')))
@@ -492,6 +502,15 @@ def search(self):
492502
action.setDisabled(True)
493503
self.layout.addWidget(self.search_widget)
494504

505+
def show_cors_window(self) -> None:
506+
""" Open CORS configuration window """
507+
indexes = self.tree_widget.selectedIndexes()
508+
if indexes[1].data() == ObjectType.BUCKET:
509+
bucket_name = self.get_bucket_name_from_selected_item()
510+
# get bucket name and pass it to CORSWindow
511+
self.cors_window = CORSWindow(bucket_name=bucket_name)
512+
self.cors_window.show()
513+
495514
def open_about_window(self) -> None:
496515
""" Open about window """
497516
self.about_window = AboutWindow()

finch/common.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,10 @@ def format_size(file_size: Union[int, float], decimal_places=2) -> str:
9292
break
9393
file_size /= 1024.0
9494
return f'{StringUtils.remove_trailing_zeros(f"{file_size:.{decimal_places}f}"): >8} {unit}'
95+
96+
def format_list_with_conjunction(items: list, conjunction='and') -> str:
97+
"""Format list items with proper punctuation and conjunction.
98+
Example: ['a', 'b', 'c'] -> 'a, b and c'"""
99+
if len(items) > 1:
100+
return f"{', '.join(items[:-1])} {conjunction} {items[-1]}"
101+
return items[0] if items else ''

finch/cors.py

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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\nOne 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\nOne 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\nOne 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

Comments
 (0)