diff --git a/motm_app/.dockerignore b/motm_app/.dockerignore index f98815d..f7b22f8 100644 --- a/motm_app/.dockerignore +++ b/motm_app/.dockerignore @@ -77,4 +77,4 @@ database_config.ini POSTGRESQL_SETUP.md SETUP_GUIDE.md VIRTUAL_ENV_GUIDE.md -README.md +README.md \ No newline at end of file diff --git a/motm_app/CONTAINER_DEPLOYMENT.md b/motm_app/CONTAINER_DEPLOYMENT.md index 1780700..34873de 100644 --- a/motm_app/CONTAINER_DEPLOYMENT.md +++ b/motm_app/CONTAINER_DEPLOYMENT.md @@ -186,3 +186,5 @@ For issues or questions about container deployment, please check: 1. Application logs: `docker-compose logs motm-app` 2. Database logs: `docker-compose logs postgres` 3. Container status: `docker-compose ps` + + diff --git a/motm_app/docker-compose.yml b/motm_app/docker-compose.yml index 3013396..d223aeb 100644 --- a/motm_app/docker-compose.yml +++ b/motm_app/docker-compose.yml @@ -67,3 +67,5 @@ volumes: networks: default: name: motm-network + + diff --git a/motm_app/forms.py b/motm_app/forms.py index 7349032..cb8bb10 100644 --- a/motm_app/forms.py +++ b/motm_app/forms.py @@ -148,3 +148,39 @@ class ClubSelectionForm(FlaskForm): select_all = SubmitField('Select All') select_none = SubmitField('Select None') cancel = SubmitField('Cancel') + + +class S3ConfigForm(FlaskForm): + """Form for S3 configuration.""" + + # Enable/disable S3 + enable_s3 = BooleanField('Enable S3 Storage', default=False) + + # Storage provider selection + storage_provider = SelectField('Storage Provider', + choices=[('aws', 'AWS S3'), ('minio', 'MinIO')], + default='aws') + + # AWS credentials + aws_access_key_id = StringField('Access Key ID') + aws_secret_access_key = PasswordField('Secret Access Key') + aws_region = StringField('Region', default='us-east-1') + + # MinIO specific configuration + minio_endpoint = StringField('MinIO Endpoint', + render_kw={'placeholder': 'minio.example.com:9000'}) + minio_use_ssl = BooleanField('Use SSL for MinIO', default=True) + + # S3 bucket configuration + bucket_name = StringField('Bucket Name') + bucket_prefix = StringField('Bucket Prefix', default='assets/') + + # URL configuration + use_signed_urls = BooleanField('Use Signed URLs', default=True) + signed_url_expiry = IntegerField('Signed URL Expiry (seconds)', default=3600) + fallback_to_static = BooleanField('Fallback to Static Files', default=True) + + # Action buttons + test_connection = SubmitField('Test Connection') + save_config = SubmitField('Save Configuration') + cancel = SubmitField('Cancel') diff --git a/motm_app/init.sql b/motm_app/init.sql index 22ad091..73cfb3a 100644 --- a/motm_app/init.sql +++ b/motm_app/init.sql @@ -9,3 +9,5 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- The application will handle table creation through SQLAlchemy -- This file is here for any additional database setup that might be needed + + diff --git a/motm_app/main.py b/motm_app/main.py index 193c460..4bc82cf 100644 --- a/motm_app/main.py +++ b/motm_app/main.py @@ -27,13 +27,14 @@ from flask_basicauth import BasicAuth from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms import DateField from wtforms.validators import InputRequired, Email, Length, EqualTo -from forms import motmForm, adminSettingsForm2, goalsAssistsForm, DatabaseSetupForm, PlayerForm, ClubForm, TeamForm, DataImportForm, ClubSelectionForm +from forms import motmForm, adminSettingsForm2, goalsAssistsForm, DatabaseSetupForm, PlayerForm, ClubForm, TeamForm, DataImportForm, ClubSelectionForm, S3ConfigForm from db_config import sql_write, sql_write_static, sql_read, sql_read_static from sqlalchemy import text from tables import matchSquadTable from readSettings import mySettings from fixture_scraper import FixtureScraper, get_next_hkfc_c_fixture, get_opponent_club_name, get_opponent_club_info, match_opponent_to_club from club_scraper import ClubScraper, get_hk_hockey_clubs, expand_club_abbreviation +from s3_config import s3_config_manager, s3_asset_service # Custom authentication class that uses database class DatabaseBasicAuth(BasicAuth): @@ -194,18 +195,24 @@ def motm_vote(randomUrlSuffix): nextClub = nextInfo[0]['nextclub'] nextTeam = nextInfo[0]['nextteam'] nextFixture = nextInfo[0]['nextfixture'] - hkfcLogo = nextInfo[0]['hkfclogo'] - oppoLogo = nextInfo[0]['oppologo'] - currMotM = nextInfo[0]['currmotm'] - currDotD = nextInfo[0]['currdotd'] - oppo = nextTeam + # Get HKFC logo from clubs table using signed URLs (with authentication) + hkfcLogo = s3_asset_service.get_asset_url('images/hkfc_logo.png') # Default fallback + sql_hkfc_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = 'Hong Kong Football Club'") + hkfc_logo_result = sql_read(sql_hkfc_logo) + if hkfc_logo_result and hkfc_logo_result[0]['logo_url']: + hkfcLogo = s3_asset_service.get_logo_url(hkfc_logo_result[0]['logo_url'], 'Hong Kong Football Club') - # Get opponent club logo from clubs table + # Get opponent club logo from clubs table using signed URLs (with authentication) + oppoLogo = s3_asset_service.get_asset_url('images/default_logo.png') # Default fallback if nextClub: sql_club_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = :club_name") club_logo_result = sql_read(sql_club_logo, {'club_name': nextClub}) if club_logo_result and club_logo_result[0]['logo_url']: - oppoLogo = club_logo_result[0]['logo_url'] + oppoLogo = s3_asset_service.get_logo_url(club_logo_result[0]['logo_url'], nextClub) + + currMotM = nextInfo[0]['currmotm'] + currDotD = nextInfo[0]['currdotd'] + oppo = nextTeam # Get match date from admin settings if nextInfo and nextInfo[0]['nextdate']: nextDate = nextInfo[0]['nextdate'] @@ -230,8 +237,8 @@ def motm_vote(randomUrlSuffix): return render_template('error.html', message="Player data not found. Please set up current MOTM and DotD players in admin settings.") # Use default player images since playerPictureURL column doesn't exist - motmURL = '/static/images/default_player.png' - dotdURL = '/static/images/default_player.png' + motmURL = s3_asset_service.get_asset_url('images/default_player.png') + dotdURL = s3_asset_service.get_asset_url('images/default_player.png') # Get match comments sql5 = text("SELECT comment FROM _motmcomments WHERE matchDate = :match_date ORDER BY RANDOM() LIMIT 1") @@ -264,8 +271,21 @@ def match_comments(): _oppo = row[0]['nextclub'] commentDate = row[0]['nextdate'].strftime('%Y-%m-%d') _matchDate = row[0]['nextdate'].strftime('%Y_%m_%d') - hkfcLogo = row[0]['hkfclogo'] - oppoLogo = row[0]['oppologo'] + + # Get HKFC logo from clubs table using signed URLs (with authentication) + hkfcLogo = s3_asset_service.get_asset_url('images/hkfc_logo.png') # Default fallback + sql_hkfc_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = 'Hong Kong Football Club'") + hkfc_logo_result = sql_read(sql_hkfc_logo) + if hkfc_logo_result and hkfc_logo_result[0]['logo_url']: + hkfcLogo = s3_asset_service.get_logo_url(hkfc_logo_result[0]['logo_url'], 'Hong Kong Football Club') + + # Get opponent club logo from clubs table using signed URLs (with authentication) + oppoLogo = s3_asset_service.get_asset_url('images/default_logo.png') # Default fallback + if _oppo: + sql_club_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = :club_name") + club_logo_result = sql_read(sql_club_logo, {'club_name': _oppo}) + if club_logo_result and club_logo_result[0]['logo_url']: + oppoLogo = s3_asset_service.get_logo_url(club_logo_result[0]['logo_url'], _oppo) if request.method == 'POST': _comment = request.form['matchComment'] if _comment != 'Optional comments added here': @@ -608,7 +628,7 @@ def club_management(): """Admin page for managing clubs""" sql = text("SELECT id, hockey_club, logo_url FROM clubs ORDER BY hockey_club") clubs = sql_read(sql) - return render_template('club_management.html', clubs=clubs) + return render_template('club_management.html', clubs=clubs, s3_asset_service=s3_asset_service) @app.route('/admin/clubs/add', methods=['GET', 'POST']) @@ -1819,5 +1839,201 @@ def database_status(): return render_template('database_status.html', db_info=db_info) +# ==================== S3 CONFIGURATION SECTION ==================== + +@app.route('/admin/s3-config', methods=['GET', 'POST']) +@basic_auth.required +def s3_config(): + """Admin page for S3 configuration and asset management""" + form = S3ConfigForm() + + if request.method == 'POST': + if form.test_connection.data: + # Test S3 connection + form_data = { + 'enable_s3': form.enable_s3.data, + 'storage_provider': form.storage_provider.data, + 'aws_access_key_id': form.aws_access_key_id.data, + 'aws_secret_access_key': form.aws_secret_access_key.data, + 'aws_region': form.aws_region.data, + 'minio_endpoint': form.minio_endpoint.data, + 'minio_use_ssl': form.minio_use_ssl.data, + 'bucket_name': form.bucket_name.data, + 'bucket_prefix': form.bucket_prefix.data, + 'use_signed_urls': form.use_signed_urls.data, + 'signed_url_expiry': form.signed_url_expiry.data, + 'fallback_to_static': form.fallback_to_static.data + } + + success, message = s3_config_manager.test_connection(form_data) + if success: + flash(f'✅ {message}', 'success') + else: + flash(f'❌ {message}', 'error') + + elif form.save_config.data: + # Save S3 configuration + form_data = { + 'enable_s3': form.enable_s3.data, + 'storage_provider': form.storage_provider.data, + 'aws_access_key_id': form.aws_access_key_id.data, + 'aws_secret_access_key': form.aws_secret_access_key.data, + 'aws_region': form.aws_region.data, + 'minio_endpoint': form.minio_endpoint.data, + 'minio_use_ssl': form.minio_use_ssl.data, + 'bucket_name': form.bucket_name.data, + 'bucket_prefix': form.bucket_prefix.data, + 'use_signed_urls': form.use_signed_urls.data, + 'signed_url_expiry': form.signed_url_expiry.data, + 'fallback_to_static': form.fallback_to_static.data + } + + try: + success = s3_config_manager.save_config(form_data) + if success: + flash('✅ S3 configuration saved successfully!', 'success') + else: + flash('❌ Failed to save S3 configuration', 'error') + except Exception as e: + flash(f'❌ Error saving configuration: {str(e)}', 'error') + + elif form.cancel.data: + return redirect(url_for('admin_dashboard')) + + # Load current configuration for display + current_config = s3_config_manager.get_config_dict() + + # Populate form with current configuration (only for GET requests or after POST processing) + for field_name, value in current_config.items(): + if hasattr(form, field_name): + getattr(form, field_name).data = value + + return render_template('s3_config.html', form=form, current_config=current_config) + + +@app.route('/admin/s3-status') +@basic_auth.required +def s3_status(): + """Admin page showing current S3 status and configuration""" + try: + current_config = s3_config_manager.get_config_dict() + + # Test current configuration + success, message = s3_config_manager.test_connection() + + s3_info = { + 'enabled': current_config.get('enable_s3', False), + 'storage_provider': current_config.get('storage_provider', 'aws'), + 'bucket_name': current_config.get('bucket_name', 'Not configured'), + 'aws_region': current_config.get('aws_region', 'Not configured'), + 'minio_endpoint': current_config.get('minio_endpoint', 'Not configured'), + 'use_signed_urls': current_config.get('use_signed_urls', True), + 'fallback_enabled': current_config.get('fallback_to_static', True), + 'connection_status': message, + 'connection_success': success + } + + except Exception as e: + s3_info = { + 'enabled': False, + 'storage_provider': 'aws', + 'bucket_name': 'Not configured', + 'aws_region': 'Not configured', + 'minio_endpoint': 'Not configured', + 'use_signed_urls': True, + 'fallback_enabled': True, + 'connection_status': f'Error: {str(e)}', + 'connection_success': False + } + + return render_template('s3_status.html', s3_info=s3_info) + + +@app.route('/admin/api/s3-browser') +@basic_auth.required +def s3_browser(): + """API endpoint to browse S3 bucket contents""" + try: + config = s3_config_manager.config + + if not config.get('enable_s3', False): + return jsonify({ + 'success': False, + 'message': 'S3 storage is not enabled' + }) + + # Get path parameter (default to root of assets folder) + path = request.args.get('path', '') + if not path.endswith('/') and path != '': + path += '/' + + # List objects in S3 + bucket_name = config.get('bucket_name', '') + bucket_prefix = config.get('bucket_prefix', 'assets/') + prefix = bucket_prefix + path + + s3_client = s3_asset_service.s3_client + if not s3_client: + return jsonify({ + 'success': False, + 'message': 'S3 client not available' + }) + + # List objects + response = s3_client.list_objects_v2( + Bucket=bucket_name, + Prefix=prefix, + Delimiter='/' + ) + + # Process folders + folders = [] + if 'CommonPrefixes' in response: + for folder in response['CommonPrefixes']: + folder_name = folder['Prefix'].replace(prefix, '').rstrip('/') + if folder_name: # Skip empty folder names + folders.append({ + 'name': folder_name, + 'path': path + folder_name + '/', + 'type': 'folder' + }) + + # Process files + files = [] + if 'Contents' in response: + for obj in response['Contents']: + file_key = obj['Key'].replace(prefix, '') + if file_key and not file_key.endswith('/'): # Skip folders and empty keys + # Generate public URL for the file (for preview images) + file_url = s3_asset_service.get_asset_url_public(file_key) + + files.append({ + 'name': file_key, + 'path': path + file_key, + 'url': file_url, + 'size': obj['Size'], + 'last_modified': obj['LastModified'].isoformat(), + 'type': 'file' + }) + + # Sort results + folders.sort(key=lambda x: x['name'].lower()) + files.sort(key=lambda x: x['name'].lower()) + + return jsonify({ + 'success': True, + 'path': path, + 'folders': folders, + 'files': files, + 'parent_path': '/'.join(path.rstrip('/').split('/')[:-1]) + '/' if path != '' else '' + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'message': f'Error browsing S3: {str(e)}' + }) + + if __name__ == "__main__": app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/motm_app/requirements.txt b/motm_app/requirements.txt index 9ddf81f..2a7018c 100644 --- a/motm_app/requirements.txt +++ b/motm_app/requirements.txt @@ -24,5 +24,8 @@ pymysql>=1.1.0 psycopg2-binary>=2.9.0 PyMySQL>=1.1.0 +# AWS S3 support +boto3>=1.34.0 + # Legacy support (can be removed after migration) flask-mysql diff --git a/motm_app/s3_config.json b/motm_app/s3_config.json new file mode 100644 index 0000000..704855e --- /dev/null +++ b/motm_app/s3_config.json @@ -0,0 +1,14 @@ +{ + "enable_s3": true, + "storage_provider": "minio", + "aws_access_key_id": "5MoE0Vz8F9vVgulClesUV3GReh2nIiXG", + "aws_secret_access_key": "0h[c8lSHUE'<", + "aws_region": "us-east-1", + "minio_endpoint": "s3.ervine.cloud:443", + "minio_use_ssl": true, + "bucket_name": "hockey-app", + "bucket_prefix": "assets/", + "use_signed_urls": true, + "signed_url_expiry": 3600, + "fallback_to_static": true +} \ No newline at end of file diff --git a/motm_app/s3_config.json.orig b/motm_app/s3_config.json.orig new file mode 100644 index 0000000..eafd819 --- /dev/null +++ b/motm_app/s3_config.json.orig @@ -0,0 +1,14 @@ +{ + "enable_s3": true, + "storage_provider": "minio", + "aws_access_key_id": "AKIARLJ7D6ZPRRLQHWD7", + "aws_secret_access_key": "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", + "aws_region": "us-east-1", + "minio_endpoint": "s3.ervine.cloud:443", + "minio_use_ssl": true, + "bucket_name": "hockey-app", + "bucket_prefix": "assets/", + "use_signed_urls": true, + "signed_url_expiry": 3600, + "fallback_to_static": true +} \ No newline at end of file diff --git a/motm_app/s3_config.py b/motm_app/s3_config.py new file mode 100644 index 0000000..c5b1ca1 --- /dev/null +++ b/motm_app/s3_config.py @@ -0,0 +1,390 @@ +# encoding=utf-8 +""" +S3 Configuration and Service Module +Handles S3 credentials and asset retrieval for the MOTM application. +""" + +import os +import json +import boto3 +from botocore.exceptions import ClientError, NoCredentialsError +from datetime import datetime, timedelta +from typing import Optional, Dict, Any + + +class S3ConfigManager: + """Manages S3 configuration settings.""" + + def __init__(self): + self.config_file = 's3_config.json' + self.config = self.load_config() + + def load_config(self) -> Dict[str, Any]: + """Load S3 configuration from file.""" + if os.path.exists(self.config_file): + try: + with open(self.config_file, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return self.get_default_config() + return self.get_default_config() + + def get_default_config(self) -> Dict[str, Any]: + """Get default S3 configuration.""" + return { + 'aws_access_key_id': '', + 'aws_secret_access_key': '', + 'aws_region': 'us-east-1', + 'bucket_name': '', + 'bucket_prefix': 'assets/', + 'enable_s3': False, + 'use_signed_urls': True, + 'signed_url_expiry': 3600, # 1 hour in seconds + 'fallback_to_static': True, + 'storage_provider': 'aws', # 'aws' or 'minio' + 'minio_endpoint': '', # MinIO endpoint URL + 'minio_use_ssl': True # Whether to use SSL for MinIO + } + + def save_config(self, config_data: Dict[str, Any]) -> bool: + """Save S3 configuration to file.""" + try: + # Update environment variables + os.environ['AWS_ACCESS_KEY_ID'] = config_data.get('aws_access_key_id', '') + os.environ['AWS_SECRET_ACCESS_KEY'] = config_data.get('aws_secret_access_key', '') + os.environ['AWS_DEFAULT_REGION'] = config_data.get('aws_region', 'us-east-1') + + with open(self.config_file, 'w') as f: + json.dump(config_data, f, indent=2) + + self.config = config_data + return True + except IOError: + return False + + def get_config_dict(self) -> Dict[str, Any]: + """Get current configuration as dictionary.""" + return self.config.copy() + + def test_connection(self, config_data: Optional[Dict[str, Any]] = None) -> tuple[bool, str]: + """Test S3 connection with provided or current configuration.""" + if config_data: + test_config = config_data + else: + test_config = self.config + + if not test_config.get('enable_s3', False): + return True, "S3 is disabled - using local static files" + + try: + # Determine storage provider + storage_provider = test_config.get('storage_provider', 'aws') + + if storage_provider == 'minio': + # Create MinIO client + minio_endpoint = test_config.get('minio_endpoint', '') + if not minio_endpoint: + return False, "MinIO endpoint is required when using MinIO" + + # Parse endpoint to remove protocol if present + if minio_endpoint.startswith('http://'): + endpoint_host = minio_endpoint[7:] + use_ssl = False + elif minio_endpoint.startswith('https://'): + endpoint_host = minio_endpoint[8:] + use_ssl = True + else: + endpoint_host = minio_endpoint + use_ssl = test_config.get('minio_use_ssl', True) + + # Construct the full endpoint URL + endpoint_url = f"{'https' if use_ssl else 'http'}://{endpoint_host}" + + s3_client = boto3.client( + 's3', + aws_access_key_id=test_config.get('aws_access_key_id', ''), + aws_secret_access_key=test_config.get('aws_secret_access_key', ''), + region_name=test_config.get('aws_region', 'us-east-1'), + endpoint_url=endpoint_url, + use_ssl=use_ssl, + verify=True # Enable SSL certificate verification + ) + provider_name = "MinIO" + else: + # Create AWS S3 client + s3_client = boto3.client( + 's3', + aws_access_key_id=test_config.get('aws_access_key_id', ''), + aws_secret_access_key=test_config.get('aws_secret_access_key', ''), + region_name=test_config.get('aws_region', 'us-east-1') + ) + provider_name = "AWS S3" + + # Test connection by listing bucket + bucket_name = test_config.get('bucket_name', '') + if not bucket_name: + return False, "Bucket name is required when S3 is enabled" + + s3_client.head_bucket(Bucket=bucket_name) + return True, f"Successfully connected to {provider_name} bucket: {bucket_name}" + + except ClientError as e: + error_code = e.response['Error']['Code'] + provider_name = "MinIO" if test_config.get('storage_provider', 'aws') == 'minio' else "AWS S3" + if error_code == '404': + return False, f"{provider_name} bucket '{bucket_name}' not found" + elif error_code == '403': + return False, f"Access denied to {provider_name} bucket. Check credentials and permissions" + else: + return False, f"{provider_name} error: {e.response['Error']['Message']}" + except NoCredentialsError: + return False, "AWS credentials not found" + except Exception as e: + provider_name = "MinIO" if test_config.get('storage_provider', 'aws') == 'minio' else "AWS S3" + return False, f"{provider_name} connection error: {str(e)}" + + +class S3AssetService: + """Service for retrieving assets from S3.""" + + def __init__(self, config_manager: S3ConfigManager): + self.config_manager = config_manager + self._s3_client = None + + def _get_public_url(self, bucket_name: str, s3_key: str) -> str: + """Generate public URL for S3 or MinIO.""" + config = self.config_manager.config + storage_provider = config.get('storage_provider', 'aws') + + if storage_provider == 'minio': + minio_endpoint = config.get('minio_endpoint', '') + use_ssl = config.get('minio_use_ssl', True) + protocol = 'https' if use_ssl else 'http' + + # Parse endpoint to remove protocol if present + if minio_endpoint.startswith('http://'): + minio_endpoint = minio_endpoint[7:] + elif minio_endpoint.startswith('https://'): + minio_endpoint = minio_endpoint[8:] + + return f"{protocol}://{minio_endpoint}/{bucket_name}/{s3_key}" + else: + return f"https://{bucket_name}.s3.{config.get('aws_region', 'us-east-1')}.amazonaws.com/{s3_key}" + + @property + def s3_client(self): + """Lazy-loaded S3 client.""" + if self._s3_client is None and self.config_manager.config.get('enable_s3', False): + config = self.config_manager.config + storage_provider = config.get('storage_provider', 'aws') + + if storage_provider == 'minio': + # Create MinIO client + minio_endpoint = config.get('minio_endpoint', '') + use_ssl = config.get('minio_use_ssl', True) + + # Parse endpoint to remove protocol if present + if minio_endpoint.startswith('http://'): + endpoint_host = minio_endpoint[7:] + use_ssl = False + elif minio_endpoint.startswith('https://'): + endpoint_host = minio_endpoint[8:] + use_ssl = True + else: + endpoint_host = minio_endpoint + + # Construct the full endpoint URL + endpoint_url = f"{'https' if use_ssl else 'http'}://{endpoint_host}" + + self._s3_client = boto3.client( + 's3', + aws_access_key_id=config.get('aws_access_key_id', ''), + aws_secret_access_key=config.get('aws_secret_access_key', ''), + region_name=config.get('aws_region', 'us-east-1'), + endpoint_url=endpoint_url, + use_ssl=use_ssl, + verify=True # Enable SSL certificate verification + ) + else: + # Create AWS S3 client + self._s3_client = boto3.client( + 's3', + aws_access_key_id=config.get('aws_access_key_id', ''), + aws_secret_access_key=config.get('aws_secret_access_key', ''), + region_name=config.get('aws_region', 'us-east-1') + ) + return self._s3_client + + def get_logo_url(self, logo_path: str, club_name: str = '') -> str: + """Get logo URL from S3 or fallback to static.""" + config = self.config_manager.config + + if not config.get('enable_s3', False): + return self._get_static_logo_url(logo_path) + + bucket_name = config.get('bucket_name', '') + bucket_prefix = config.get('bucket_prefix', 'motm-assets/') + + if not bucket_name: + return self._get_static_logo_url(logo_path) + + # Clean up the logo path + if logo_path.startswith('/static/'): + s3_key = bucket_prefix + logo_path.replace('/static/', '') + elif logo_path.startswith('logos/'): + s3_key = bucket_prefix + logo_path + else: + s3_key = bucket_prefix + 'logos/' + logo_path + + try: + if config.get('use_signed_urls', True): + # Generate signed URL + expiry = config.get('signed_url_expiry', 3600) + signed_url = self.s3_client.generate_presigned_url( + 'get_object', + Params={'Bucket': bucket_name, 'Key': s3_key}, + ExpiresIn=expiry + ) + return signed_url + else: + # Return public URL + return self._get_public_url(bucket_name, s3_key) + + except ClientError: + # Fallback to static if S3 fails + if config.get('fallback_to_static', True): + return self._get_static_logo_url(logo_path) + return '' + + def get_logo_url_public(self, logo_path: str, club_name: str = '') -> str: + """Get logo URL from S3 using public URLs (no authentication).""" + config = self.config_manager.config + + if not config.get('enable_s3', False): + return self._get_static_logo_url(logo_path) + + bucket_name = config.get('bucket_name', '') + bucket_prefix = config.get('bucket_prefix', 'assets/') + + if not bucket_name: + return self._get_static_logo_url(logo_path) + + # Clean up the logo path + if logo_path.startswith('/static/'): + s3_key = bucket_prefix + logo_path.replace('/static/', '') + elif logo_path.startswith('logos/'): + s3_key = bucket_prefix + logo_path + else: + s3_key = bucket_prefix + 'logos/' + logo_path + + try: + # Always return public URL (no authentication) + return self._get_public_url(bucket_name, s3_key) + + except Exception: + # Fallback to static if S3 fails + if config.get('fallback_to_static', True): + return self._get_static_logo_url(logo_path) + return '' + + def get_asset_url(self, asset_path: str) -> str: + """Get any asset URL from S3 or fallback to static.""" + config = self.config_manager.config + + if not config.get('enable_s3', False): + return f"/static/{asset_path}" + + bucket_name = config.get('bucket_name', '') + bucket_prefix = config.get('bucket_prefix', 'motm-assets/') + + if not bucket_name: + return f"/static/{asset_path}" + + # Clean up the asset path + if asset_path.startswith('/static/'): + s3_key = bucket_prefix + asset_path.replace('/static/', '') + else: + s3_key = bucket_prefix + asset_path + + try: + if config.get('use_signed_urls', True): + # Generate signed URL + expiry = config.get('signed_url_expiry', 3600) + signed_url = self.s3_client.generate_presigned_url( + 'get_object', + Params={'Bucket': bucket_name, 'Key': s3_key}, + ExpiresIn=expiry + ) + return signed_url + else: + # Return public URL + return self._get_public_url(bucket_name, s3_key) + + except ClientError: + # Fallback to static if S3 fails + if config.get('fallback_to_static', True): + return f"/static/{asset_path}" + return '' + + def get_asset_url_public(self, asset_path: str) -> str: + """Get any asset URL from S3 using public URLs (no authentication).""" + config = self.config_manager.config + + if not config.get('enable_s3', False): + return f"/static/{asset_path}" + + bucket_name = config.get('bucket_name', '') + bucket_prefix = config.get('bucket_prefix', 'assets/') + + if not bucket_name: + return f"/static/{asset_path}" + + # Clean up the asset path + if asset_path.startswith('/static/'): + s3_key = bucket_prefix + asset_path.replace('/static/', '') + else: + s3_key = bucket_prefix + asset_path + + try: + # Always return public URL (no authentication) + return self._get_public_url(bucket_name, s3_key) + + except Exception: + # Fallback to static if S3 fails + if config.get('fallback_to_static', True): + return f"/static/{asset_path}" + return '' + + def upload_asset(self, file_path: str, s3_key: str) -> tuple[bool, str]: + """Upload an asset to S3.""" + config = self.config_manager.config + + if not config.get('enable_s3', False): + return False, "S3 is disabled" + + bucket_name = config.get('bucket_name', '') + bucket_prefix = config.get('bucket_prefix', 'motm-assets/') + + if not bucket_name: + return False, "Bucket name not configured" + + full_s3_key = bucket_prefix + s3_key + + try: + self.s3_client.upload_file(file_path, bucket_name, full_s3_key) + return True, f"Successfully uploaded {s3_key} to S3" + except ClientError as e: + return False, f"Upload failed: {e.response['Error']['Message']}" + except Exception as e: + return False, f"Upload error: {str(e)}" + + def _get_static_logo_url(self, logo_path: str) -> str: + """Get static logo URL as fallback.""" + if logo_path.startswith('/static/'): + return logo_path + return f"/static/images/{logo_path}" + + +# Global instances +s3_config_manager = S3ConfigManager() +s3_asset_service = S3AssetService(s3_config_manager) diff --git a/motm_app/templates/add_club.html b/motm_app/templates/add_club.html index b999b3f..d7e189c 100644 --- a/motm_app/templates/add_club.html +++ b/motm_app/templates/add_club.html @@ -5,6 +5,7 @@
Loading S3 contents...
+Loading S3 contents...
+Loading S3 contents...
+View current database configuration and status
+ +Configure AWS S3 storage for logos and assets
+ + +View current S3 configuration and connection status
+Admin functions require authentication. Please contact the system administrator for access.
+Configure AWS S3 storage for logos and assets
+ +Organize your assets in the S3 bucket as follows:
+assets/images/clubs/ - Club logosassets/images/ - General imagesassets/logos/ - Alternative logo locationCurrent S3 configuration and connection status
+ +| S3 Storage: | ++ + {{ 'Enabled' if s3_info.enabled else 'Disabled' }} + + | +
| Provider: | +{{ s3_info.storage_provider.title() if s3_info.get('storage_provider') else 'AWS S3' }} | +
| Bucket Name: | +{{ s3_info.bucket_name }} | +
| Region: | +{{ s3_info.aws_region }} | +
| MinIO Endpoint: | +{{ s3_info.get('minio_endpoint', 'Not configured') }} | +
| Signed URLs: | ++ + {{ 'Enabled' if s3_info.use_signed_urls else 'Disabled' }} + + | +
| Fallback: | ++ + {{ 'Enabled' if s3_info.fallback_enabled else 'Disabled' }} + + | +
{{ s3_info.connection_status }}
+S3 storage is properly configured and connected. Assets will be served from S3.
+{{ s3_info.bucket_name }}/assets/images/clubs/{{ s3_info.bucket_name }}/assets/images/{{ s3_info.bucket_name }}/assets/images/players/Check the following:
+S3 storage is disabled. Assets are being served from local static files.
+/static/images/clubs//static/images//static/images/players/