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 @@ - - - - - - - - image/svg+xml - - Gnome Symbolic Icon Theme - - - - - - - Gnome Symbolic Icon Theme - - - - - - - - - - - - - - + \ 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 @@ - - - - - - - - image/svg+xml - - Gnome Symbolic Icon Theme - - - - - - - Gnome Symbolic Icon Theme - - - - - - - - - - - - - - - - - + \ 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 @@ - - - - - - - - image/svg+xml - - Gnome Symbolic Icon Theme - - - - - - - Gnome Symbolic Icon Theme - - - - - - - - - - - - - - - - + \ 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 @@ - - - - - - - image/svg+xml - - Gnome Symbolic Icon Theme - - - - Gnome Symbolic Icon Theme - - - - - + \ 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 @@ - - - - - - - image/svg+xml - - Gnome Symbolic Icon Theme - - - - Gnome Symbolic Icon Theme - - - - + \ 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 @@ - - - - - - - image/svg+xml - - Gnome Symbolic Icon Theme - - - - Gnome Symbolic Icon Theme - - - - + \ 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 @@ - - - - - - - image/svg+xml - - Gnome Symbolic Icon Theme - - - - Gnome Symbolic Icon Theme - - - - - - - - + \ 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 @@ - - - - - - - image/svg+xml - - Gnome Symbolic Icon Theme - - - - Gnome Symbolic Icon Theme - - - - + \ 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 @@ - - - - - - - - image/svg+xml - - Gnome Symbolic Icon Theme - - - - - - - Gnome Symbolic Icon Theme - - - - - - - - - - - - - - - - - - + \ No newline at end of file