diff --git a/README.md b/README.md
index ab25587..41a03d5 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ Finch S3 Client is an open source project, and we welcome contributions from the
## License
Finch S3 Client is released under the [MIT License](https://github.com/mantis-software-company/finch/blob/main/LICENSE).
-Icons used in GUI was copied from GNOME [Adwaita](https://gitlab.gnome.org/GNOME/adwaita-icon-theme) icon theme.
+Icons used in GUI is taken from [Feather Icons](https://feathericons.com/).
## Credits
S3 Client was created by [Furkan Kalkan](https://github.com/geekdinazor).
diff --git a/finch/__main__.py b/finch/__main__.py
index 932ed8a..89ca70a 100644
--- a/finch/__main__.py
+++ b/finch/__main__.py
@@ -16,6 +16,7 @@
from finch.about import AboutWindow
from finch.common import ObjectType, s3_session, apply_theme, center_window, CONFIG_PATH, StringUtils, resource_path
+from finch.cors import CORSWindow
from finch.credentials import CredentialsManager, ManageCredentialsWindow
from finch.download import DownloadProgressDialog
from finch.error import show_error_dialog
@@ -156,7 +157,7 @@ def show_s3_files(self, cred_index):
download_action = QAction(self)
download_action.setText("&Download")
- download_action.setIcon(QIcon(resource_path('img/save.svg')))
+ download_action.setIcon(QIcon(resource_path('img/download.svg')))
download_action.triggered.connect(self.download_file)
download_action.setDisabled(True)
@@ -296,6 +297,15 @@ def open_context_menu(self, position):
create_folder_action.setIcon(QIcon(resource_path('img/new-folder.svg')))
create_folder_action.triggered.connect(self.create_folder)
menu.addAction(create_folder_action)
+
+ tools_menu = menu.addMenu("Tools")
+ tools_menu.setIcon(QIcon(resource_path('img/tools.svg')))
+
+ cors_action = QAction(self)
+ cors_action.setText("&CORS Configurations")
+ cors_action.setIcon(QIcon(resource_path('img/globe.svg')))
+ cors_action.triggered.connect(self.show_cors_window)
+ tools_menu.addAction(cors_action)
elif indexes[1].data() == ObjectType.FOLDER:
delete_folder_action = QAction("Delete Folder")
delete_folder_action.setIcon(QIcon(resource_path('img/trash.svg')))
@@ -492,6 +502,15 @@ def search(self):
action.setDisabled(True)
self.layout.addWidget(self.search_widget)
+ def show_cors_window(self) -> None:
+ """ Open CORS configuration window """
+ indexes = self.tree_widget.selectedIndexes()
+ if indexes[1].data() == ObjectType.BUCKET:
+ bucket_name = self.get_bucket_name_from_selected_item()
+ # get bucket name and pass it to CORSWindow
+ self.cors_window = CORSWindow(bucket_name=bucket_name)
+ self.cors_window.show()
+
def open_about_window(self) -> None:
""" Open about window """
self.about_window = AboutWindow()
diff --git a/finch/common.py b/finch/common.py
index 7394157..e1f5dc2 100644
--- a/finch/common.py
+++ b/finch/common.py
@@ -92,3 +92,10 @@ def format_size(file_size: Union[int, float], decimal_places=2) -> str:
break
file_size /= 1024.0
return f'{StringUtils.remove_trailing_zeros(f"{file_size:.{decimal_places}f}"): >8} {unit}'
+
+ def format_list_with_conjunction(items: list, conjunction='and') -> str:
+ """Format list items with proper punctuation and conjunction.
+ Example: ['a', 'b', 'c'] -> 'a, b and c'"""
+ if len(items) > 1:
+ return f"{', '.join(items[:-1])} {conjunction} {items[-1]}"
+ return items[0] if items else ''
\ No newline at end of file
diff --git a/finch/cors.py b/finch/cors.py
new file mode 100644
index 0000000..8be5910
--- /dev/null
+++ b/finch/cors.py
@@ -0,0 +1,325 @@
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QIcon
+from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
+ QMessageBox, QGroupBox, QFormLayout, QLineEdit,
+ QListWidget, QListWidgetItem, QCheckBox, QTextEdit,
+ QLabel)
+from botocore.exceptions import ClientError
+
+from finch.common import s3_session, center_window, resource_path, StringUtils
+from finch.error import show_error_dialog
+
+
+class CORSWindow(QWidget):
+ """
+ CORS Window to manage CORS configurations for passed bucket name.
+ """
+ def __init__(self, bucket_name):
+ super().__init__()
+ self.bucket_name = bucket_name
+ self.setWindowTitle(f"CORS Configurations - {bucket_name}")
+ self.resize(600, 400)
+ center_window(self)
+
+ layout = QVBoxLayout()
+ self.setLayout(layout)
+
+ # List of CORS Rules
+ self.rules_list = QListWidget()
+ self.rules_list.itemClicked.connect(self.show_rule_details)
+ self.rules_list.currentRowChanged.connect(self.show_rule_details)
+ layout.addWidget(self.rules_list)
+
+ # Rule Editor Group
+ rule_group = QGroupBox("Rule Details")
+ rule_layout = QFormLayout()
+ rule_group.setLayout(rule_layout)
+
+ # Set the form layout to expand fields horizontally
+ rule_layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
+
+ self.allowed_origins_input = QTextEdit()
+ self.allowed_origins_input.setMaximumHeight(80)
+ rule_layout.addRow("Allowed Origins:", self.allowed_origins_input)
+ help_label = QLabel("Enter * or http://example.com\nOne origin per line")
+ help_label.setStyleSheet("QLabel { font-size: 11px; font-style: italic; color: #666; }")
+ rule_layout.addRow("", help_label)
+
+ # Methods as checkboxes
+ methods_group = QWidget()
+ methods_layout = QHBoxLayout()
+ methods_group.setLayout(methods_layout)
+ self.method_checkboxes = {}
+ for method in ["GET", "PUT", "POST", "DELETE", "HEAD"]:
+ checkbox = QCheckBox(method)
+ self.method_checkboxes[method] = checkbox
+ methods_layout.addWidget(checkbox)
+ rule_layout.addRow("Allowed Methods:", methods_group)
+
+ self.allowed_headers_input = QTextEdit()
+ self.allowed_headers_input.setMaximumHeight(80)
+ rule_layout.addRow("Allowed Headers:", self.allowed_headers_input)
+ help_label = QLabel("Enter * or specific headers\nOne header per line")
+ help_label.setStyleSheet("QLabel { font-size: 11px; font-style: italic; color: #666; }")
+ rule_layout.addRow("", help_label)
+
+ self.expose_headers_input = QTextEdit()
+ self.expose_headers_input.setMaximumHeight(80)
+ rule_layout.addRow("Expose Headers:", self.expose_headers_input)
+ help_label = QLabel("Enter headers to expose\nOne header per line")
+ help_label.setStyleSheet("QLabel { font-size: 11px; font-style: italic; color: #666; }")
+ rule_layout.addRow("", help_label)
+
+ self.max_age_input = QLineEdit()
+ rule_layout.addRow("Max Age (seconds):", self.max_age_input)
+ help_label = QLabel("Enter maximum age in seconds")
+ help_label.setStyleSheet("QLabel { font-size: 11px; font-style: italic; color: #666; }")
+ rule_layout.addRow("", help_label)
+
+ # Add buttons to form
+ buttons_widget = QWidget()
+ buttons_layout = QHBoxLayout()
+ buttons_widget.setLayout(buttons_layout)
+
+ self.save_rule_button = QPushButton("Save Changes")
+ self.save_rule_button.setIcon(QIcon(resource_path("img/save.svg")))
+ self.save_rule_button.clicked.connect(self.save_rule)
+ self.save_rule_button.setEnabled(False)
+
+ self.delete_rule_button = QPushButton("Delete Rule")
+ self.delete_rule_button.setIcon(QIcon(resource_path("img/trash.svg")))
+ self.delete_rule_button.clicked.connect(self.delete_rule)
+ self.delete_rule_button.setEnabled(False)
+
+ buttons_layout.addWidget(self.save_rule_button)
+ buttons_layout.addWidget(self.delete_rule_button)
+ buttons_layout.setAlignment(Qt.AlignLeft)
+ rule_layout.addRow("", buttons_widget)
+
+ layout.addWidget(rule_group)
+
+ # Bottom buttons
+ button_layout = QHBoxLayout()
+ self.add_rule_button = QPushButton("Add New Rule")
+ self.add_rule_button.setIcon(QIcon(resource_path("img/plus.svg")))
+ self.add_rule_button.clicked.connect(self.add_new_rule)
+
+ self.apply_button = QPushButton("Apply CORS Rules")
+ self.apply_button.setIcon(QIcon(resource_path("img/save.svg")))
+ self.apply_button.clicked.connect(self.apply_cors)
+
+ button_layout.addWidget(self.add_rule_button)
+ button_layout.addWidget(self.apply_button)
+ button_layout.setAlignment(Qt.AlignRight)
+
+ layout.addLayout(button_layout)
+
+ # Start with form disabled
+ self._enable_form(False)
+
+ # Load existing CORS configuration
+ self.load_cors_config()
+
+ def load_cors_config(self):
+ """Load existing CORS configuration for the bucket"""
+ try:
+ response = s3_session.resource.meta.client.get_bucket_cors(Bucket=self.bucket_name)
+ rules = response.get('CORSRules', [])
+
+ for rule in rules:
+ item = QListWidgetItem(self._format_rule_display(
+ rule['AllowedMethods'],
+ rule['AllowedOrigins']
+ ))
+ item.setData(Qt.UserRole, rule)
+ self.rules_list.addItem(item)
+
+ # Select first rule if any exist
+ if self.rules_list.count() > 0:
+ self.rules_list.setCurrentRow(0)
+
+ except ClientError as e:
+ if e.response['Error']['Code'] == 'NoSuchCORSConfiguration':
+ # No CORS configuration exists yet
+ pass
+ else:
+ show_error_dialog(str(e))
+
+ def show_rule_details(self, item_or_row):
+ """Show details of selected rule"""
+ # Convert row number to item if needed
+ if isinstance(item_or_row, int):
+ item = self.rules_list.item(item_or_row)
+ else:
+ item = item_or_row
+
+ if not item:
+ self._enable_form(False)
+ return
+
+ self._enable_form(True)
+ rule = item.data(Qt.UserRole)
+ self.allowed_origins_input.setPlainText("\n".join(rule.get('AllowedOrigins', [])))
+
+ # Set method checkboxes
+ allowed_methods = rule.get('AllowedMethods', [])
+ for method, checkbox in self.method_checkboxes.items():
+ checkbox.setChecked(method in allowed_methods)
+
+ self.allowed_headers_input.setPlainText("\n".join(rule.get('AllowedHeaders', [])))
+ self.expose_headers_input.setPlainText("\n".join(rule.get('ExposeHeaders', [])))
+ self.max_age_input.setText(str(rule.get('MaxAgeSeconds', '')))
+ self.save_rule_button.setEnabled(False) # Disable save button when loading rule
+ self.delete_rule_button.setEnabled(True)
+
+ def add_new_rule(self):
+ """Add empty rule to the list"""
+ # Save existing changes if any
+ if self.save_rule_button.isEnabled():
+ if not self._get_rule_from_form(): # Returns None if validation fails
+ return # Don't add new rule if current rule has validation errors
+ self.save_rule()
+
+ # Then add new rule
+ item = QListWidgetItem("New Rule")
+ item.setData(Qt.UserRole, {"AllowedOrigins": [], "AllowedMethods": []})
+ self.rules_list.addItem(item)
+ self.rules_list.setCurrentItem(item)
+ self._clear_form()
+ self._enable_form(True)
+ self.save_rule_button.setEnabled(True)
+ self.delete_rule_button.setEnabled(True)
+
+ def _format_rule_display(self, methods, origins):
+ """Format CORS rules to display in the list"""
+ methods_text = StringUtils.format_list_with_conjunction(methods)
+ origins_text = StringUtils.format_list_with_conjunction(['anywhere' if o == '*' else o for o in origins])
+ return f"{methods_text} on {origins_text}"
+
+ def save_rule(self):
+ """Save current form data to selected rule"""
+ current_item = self.rules_list.currentItem()
+ if current_item:
+ updated_rule = self._get_rule_from_form()
+ if updated_rule:
+ current_item.setData(Qt.UserRole, updated_rule)
+ current_item.setText(self._format_rule_display(
+ updated_rule['AllowedMethods'],
+ updated_rule['AllowedOrigins']
+ ))
+ self.save_rule_button.setEnabled(False)
+
+ def _on_form_changed(self):
+ """Enable save button when form content changes"""
+ if self.rules_list.currentItem():
+ current_rule = self.rules_list.currentItem().data(Qt.UserRole)
+ new_rule = self._get_rule_from_form(validate=False)
+ if new_rule:
+ self.save_rule_button.setEnabled(current_rule != new_rule)
+
+ def _get_rule_from_form(self, validate=True):
+ """Get rule dict from form fields"""
+ origins = [o.strip() for o in self.allowed_origins_input.toPlainText().splitlines() if o.strip()]
+ methods = [method for method, checkbox in self.method_checkboxes.items() if checkbox.isChecked()]
+
+ if validate:
+ if not origins:
+ show_error_dialog("At least one origin is required")
+ return None
+
+ if not methods:
+ show_error_dialog("At least one method must be selected")
+ return None
+
+ rule = {
+ "AllowedOrigins": origins,
+ "AllowedMethods": methods
+ }
+
+ headers = [h.strip() for h in self.allowed_headers_input.toPlainText().splitlines() if h.strip()]
+ if headers:
+ rule["AllowedHeaders"] = headers
+
+ expose = [h.strip() for h in self.expose_headers_input.toPlainText().splitlines() if h.strip()]
+ if expose:
+ rule["ExposeHeaders"] = expose
+
+ if self.max_age_input.text().strip():
+ try:
+ rule["MaxAgeSeconds"] = int(self.max_age_input.text())
+ except ValueError:
+ if validate:
+ show_error_dialog("Max Age must be a number")
+ return None
+
+ return rule
+
+ def _clear_form(self):
+ """Clear all form fields"""
+ self.allowed_origins_input.clear()
+ for checkbox in self.method_checkboxes.values():
+ checkbox.setChecked(False)
+ self.allowed_headers_input.clear()
+ self.expose_headers_input.clear()
+ self.max_age_input.clear()
+ self.save_rule_button.setEnabled(False)
+ self.delete_rule_button.setEnabled(False)
+
+ def delete_rule(self):
+ """Delete selected CORS rule"""
+ current_row = self.rules_list.currentRow()
+ if current_row >= 0:
+ self.rules_list.takeItem(current_row)
+ self._clear_form()
+
+ # Select last rule if any exist
+ new_count = self.rules_list.count()
+ if new_count > 0:
+ last_row = new_count - 1
+ self.rules_list.setCurrentRow(last_row)
+ self.show_rule_details(last_row) # Explicitly load the form
+ else:
+ self._enable_form(False)
+
+ def apply_cors(self):
+ """Apply CORS configuration to bucket"""
+ try:
+ rules = []
+
+ # Update the current rule if one is selected
+ current_item = self.rules_list.currentItem()
+ if current_item:
+ updated_rule = self._get_rule_from_form()
+ if updated_rule:
+ current_item.setData(Qt.UserRole, updated_rule)
+ current_item.setText(f"{StringUtils.format_list_with_conjunction(updated_rule['AllowedMethods'])} on {', '.join(updated_rule['AllowedOrigins'])}")
+
+ # Collect all rules
+ for i in range(self.rules_list.count()):
+ rules.append(self.rules_list.item(i).data(Qt.UserRole))
+
+ if rules:
+ s3_session.resource.meta.client.put_bucket_cors(
+ Bucket=self.bucket_name,
+ CORSConfiguration={
+ 'CORSRules': rules
+ }
+ )
+ else:
+ s3_session.resource.meta.client.delete_bucket_cors(Bucket=self.bucket_name)
+
+ QMessageBox.information(self, "Success", "CORS configuration applied successfully")
+
+ except Exception as e:
+ show_error_dialog(str(e))
+
+ def _enable_form(self, enabled=True):
+ """Enable/disable all form fields"""
+ self.allowed_origins_input.setEnabled(enabled)
+ for checkbox in self.method_checkboxes.values():
+ checkbox.setEnabled(enabled)
+ self.allowed_headers_input.setEnabled(enabled)
+ self.expose_headers_input.setEnabled(enabled)
+ self.max_age_input.setEnabled(enabled)
+ self.save_rule_button.setEnabled(False) # Always start with save disabled
+ self.delete_rule_button.setEnabled(enabled)
\ No newline at end of file
diff --git a/finch/credentials.py b/finch/credentials.py
index 021e5f3..d516d55 100644
--- a/finch/credentials.py
+++ b/finch/credentials.py
@@ -3,9 +3,9 @@
from typing import List
import keyring
-from PyQt5.QtCore import QAbstractTableModel, Qt, pyqtSignal, QItemSelectionModel
+from PyQt5.QtCore import QAbstractTableModel, Qt, pyqtSignal
from PyQt5.QtGui import QIcon
-from PyQt5.QtWidgets import QWidget, QGridLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, \
+from PyQt5.QtWidgets import QWidget, QVBoxLayout, \
QTableView, QToolBar, QAction, QBoxLayout, QAbstractItemView, QHeaderView, QItemDelegate, QApplication, QStyle, \
QStyledItemDelegate
from slugify import slugify
@@ -196,7 +196,7 @@ def __init__(self):
self.credential_toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
add_row_action = QAction(self)
add_row_action.setText("&Create Credential")
- add_row_action.setIcon(QIcon(resource_path('img/plus.svg')))
+ add_row_action.setIcon(QIcon(resource_path('img/new-credential.svg')))
add_row_action.triggered.connect(self.add_row)
self.delete_row_action = QAction(self)
@@ -204,13 +204,7 @@ def __init__(self):
self.delete_row_action.setIcon(QIcon(resource_path('img/trash.svg')))
self.delete_row_action.triggered.connect(self.delete_row)
- # save_action = QAction(self)
- # save_action.setText("&Save Credentials")
- # save_action.setIcon(QtGui.QIcon.fromTheme("media-floppy-symbolic"))
- # save_action.triggered.connect(self.save_credentials)
-
self.credential_toolbar.addAction(add_row_action)
- # self.credential_toolbar.addAction(save_action)
self.table_data = QTableView()
self.table_data.setModel(CredentialsModel(self.temp_credentials_data))
diff --git a/finch/img/about.svg b/finch/img/about.svg
index 2cfb995..d14561b 100644
--- a/finch/img/about.svg
+++ b/finch/img/about.svg
@@ -1,31 +1 @@
-
-
-
-
+
\ No newline at end of file
diff --git a/finch/img/close.svg b/finch/img/close.svg
index a167425..f9a189e 100644
--- a/finch/img/close.svg
+++ b/finch/img/close.svg
@@ -1,4 +1 @@
-
-
+
\ No newline at end of file
diff --git a/finch/img/credentials.svg b/finch/img/credentials.svg
index 3fcb5e0..5f9b0f4 100644
--- a/finch/img/credentials.svg
+++ b/finch/img/credentials.svg
@@ -1,34 +1 @@
-
-
-
-
+
\ No newline at end of file
diff --git a/finch/img/download.svg b/finch/img/download.svg
new file mode 100644
index 0000000..c65b7fd
--- /dev/null
+++ b/finch/img/download.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/finch/img/globe.svg b/finch/img/globe.svg
new file mode 100644
index 0000000..8d97689
--- /dev/null
+++ b/finch/img/globe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/finch/img/new-credential.svg b/finch/img/new-credential.svg
index eae33db..0c047ff 100644
--- a/finch/img/new-credential.svg
+++ b/finch/img/new-credential.svg
@@ -1,33 +1 @@
-
-
-
-
+
\ No newline at end of file
diff --git a/finch/img/new-folder.svg b/finch/img/new-folder.svg
index 6981754..eef92ad 100644
--- a/finch/img/new-folder.svg
+++ b/finch/img/new-folder.svg
@@ -1,18 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/finch/img/plus.svg b/finch/img/plus.svg
index 87d0cde..1367c24 100644
--- a/finch/img/plus.svg
+++ b/finch/img/plus.svg
@@ -1,17 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/finch/img/refresh.svg b/finch/img/refresh.svg
index d7b9cff..8a8369c 100644
--- a/finch/img/refresh.svg
+++ b/finch/img/refresh.svg
@@ -1,17 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/finch/img/save.svg b/finch/img/save.svg
index 61622b5..631a83c 100644
--- a/finch/img/save.svg
+++ b/finch/img/save.svg
@@ -1,21 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/finch/img/search.svg b/finch/img/search.svg
index e5d0a75..727aac3 100644
--- a/finch/img/search.svg
+++ b/finch/img/search.svg
@@ -1,4 +1 @@
-
-
+
\ No newline at end of file
diff --git a/finch/img/settings.svg b/finch/img/settings.svg
new file mode 100644
index 0000000..b97ea61
--- /dev/null
+++ b/finch/img/settings.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/finch/img/tools.svg b/finch/img/tools.svg
new file mode 100644
index 0000000..e395051
--- /dev/null
+++ b/finch/img/tools.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/finch/img/trash.svg b/finch/img/trash.svg
index 422c464..4cb713f 100644
--- a/finch/img/trash.svg
+++ b/finch/img/trash.svg
@@ -1,17 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/finch/img/upload.svg b/finch/img/upload.svg
index 01c9734..17415fa 100644
--- a/finch/img/upload.svg
+++ b/finch/img/upload.svg
@@ -1,35 +1 @@
-
-
-
-
+
\ No newline at end of file