Fix S3 permissions and things

This commit is contained in:
Jonny Ervine 2025-10-06 22:28:27 +08:00
parent 85858d33bd
commit 0bf5f1a29c
17 changed files with 1831 additions and 21 deletions

View File

@ -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`

View File

@ -67,3 +67,5 @@ volumes:
networks:
default:
name: motm-network

View File

@ -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')

View File

@ -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

View File

@ -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)

View File

@ -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

14
motm_app/s3_config.json Normal file
View File

@ -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
}

View File

@ -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
}

390
motm_app/s3_config.py Normal file
View File

@ -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)

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Club - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
@ -43,7 +44,12 @@
<div class="mb-3">
{{ form.logo_url.label(class="form-label") }}
{{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }}
<div class="input-group">
{{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }}
<button type="button" class="btn btn-outline-secondary" onclick="browseS3()" title="Browse S3 Storage">
<i class="fas fa-cloud"></i> Browse S3
</button>
</div>
{% if form.logo_url.errors %}
<div class="text-danger">
{% for error in form.logo_url.errors %}
@ -51,7 +57,7 @@
{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">Enter the full URL to the club's logo image</small>
<small class="form-text text-muted">Enter the full URL to the club's logo image or use the S3 browser to select from your configured storage.</small>
<!-- Logo Preview -->
<div id="logoPreview" class="mt-2" style="display: none;">
@ -71,6 +77,32 @@
</div>
</div>
<!-- S3 Browser Modal -->
<div class="modal fade" id="s3BrowserModal" tabindex="-1" aria-labelledby="s3BrowserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="s3BrowserModalLabel">Browse S3 Storage</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="s3BrowserContent">
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="selectS3FileBtn" onclick="selectS3File()" disabled>Select File</button>
</div>
</div>
</div>
</div>
<div class="mt-3">
<a href="/admin/clubs" class="btn btn-outline-secondary">Back to Club Management</a>
</div>
@ -94,6 +126,205 @@
}
}
// S3 Browser functionality
let selectedS3File = null;
let currentS3Path = '';
function browseS3() {
// Reset state
selectedS3File = null;
currentS3Path = '';
// Show loading state
document.getElementById('s3BrowserContent').innerHTML = `
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
`;
// Show modal
const s3BrowserModal = new bootstrap.Modal(document.getElementById('s3BrowserModal'));
s3BrowserModal.show();
// Load S3 contents from root of assets folder
loadS3Contents('');
}
function loadS3Contents(path) {
fetch(`/admin/api/s3-browser?path=${encodeURIComponent(path)}`)
.then(response => response.json())
.then(data => {
if (data.success) {
displayS3Contents(data);
} else {
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> ${data.message}
</div>
`;
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> Failed to load S3 contents. Please try again.
</div>
`;
});
}
function displayS3Contents(data) {
currentS3Path = data.path;
let html = `
<div class="row mb-3">
<div class="col-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="#" onclick="loadS3Contents('')">assets</a>
</li>
`;
// Build breadcrumb
if (data.path !== '') {
const pathParts = data.path.split('/').filter(p => p);
let currentPath = '';
pathParts.forEach((part, index) => {
currentPath += part + '/';
const isLast = index === pathParts.length - 1;
html += `<li class="breadcrumb-item ${isLast ? 'active' : ''}">`;
if (!isLast) {
html += `<a href="#" onclick="loadS3Contents('${currentPath}')">${part}</a>`;
} else {
html += part;
}
html += '</li>';
});
}
html += `
</ol>
</nav>
</div>
</div>
<div class="row">
`;
// Display folders
if (data.folders.length > 0) {
data.folders.forEach(folder => {
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 folder-card" onclick="loadS3Contents('${folder.path}')" style="cursor: pointer;">
<div class="card-body text-center">
<i class="fas fa-folder fa-3x text-warning mb-2"></i>
<h6 class="card-title">${folder.name}</h6>
</div>
</div>
</div>
`;
});
}
// Display files
if (data.files.length > 0) {
data.files.forEach(file => {
const isImage = /\.(jpg|jpeg|png|gif|svg|webp)$/i.test(file.name);
const fileSize = formatFileSize(file.size);
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 file-card" onclick="selectS3FileItem('${file.path}', '${file.url}')" style="cursor: pointer;">
<div class="card-body text-center">
`;
if (isImage) {
html += `
<img src="${file.url}" alt="${file.name}" class="img-fluid mb-2" style="max-height: 100px; max-width: 100%; object-fit: contain;">
`;
} else {
html += `
<i class="fas fa-file fa-3x text-primary mb-2"></i>
`;
}
html += `
<h6 class="card-title">${file.name}</h6>
<small class="text-muted">${fileSize}</small>
</div>
</div>
</div>
`;
});
}
if (data.folders.length === 0 && data.files.length === 0) {
html += `
<div class="col-12">
<div class="alert alert-info text-center">
<i class="fas fa-info-circle"></i> No files or folders found in this directory.
</div>
</div>
`;
}
html += `
</div>
`;
document.getElementById('s3BrowserContent').innerHTML = html;
}
function selectS3FileItem(filePath, fileUrl) {
// Remove previous selection
document.querySelectorAll('.file-card').forEach(card => {
card.classList.remove('border-primary');
});
// Add selection to clicked card
event.currentTarget.classList.add('border-primary');
// Store selected file
selectedS3File = {
path: filePath,
url: fileUrl
};
// Enable select button
document.getElementById('selectS3FileBtn').disabled = false;
}
function selectS3File() {
if (selectedS3File) {
// Update the logo URL field
const logoUrlField = document.getElementById('logoUrl');
if (logoUrlField) {
logoUrlField.value = selectedS3File.path;
// Trigger preview update
previewLogo();
}
// Close modal
const s3BrowserModal = bootstrap.Modal.getInstance(document.getElementById('s3BrowserModal'));
s3BrowserModal.hide();
}
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Preview logo on page load if URL is already filled
document.addEventListener('DOMContentLoaded', function() {
previewLogo();

View File

@ -178,7 +178,7 @@
<h3>System Management</h3>
</div>
<div class="row">
<div class="col-md-6">
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/database-setup" class="list-group-item">
<h4 class="list-group-item-heading">Database Setup</h4>
@ -186,7 +186,7 @@
</a>
</div>
</div>
<div class="col-md-6">
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/database-status" class="list-group-item">
<h4 class="list-group-item-heading">Database Status</h4>
@ -194,6 +194,22 @@
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/s3-config" class="list-group-item">
<h4 class="list-group-item-heading">S3 Configuration</h4>
<p class="list-group-item-text">Configure S3/MinIO storage</p>
</a>
</div>
</div>
<div class="col-md-3">
<div class="list-group card-custom">
<a href="/admin/s3-status" class="list-group-item">
<h4 class="list-group-item-heading">S3 Status</h4>
<p class="list-group-item-text">View S3/MinIO status</p>
</a>
</div>
</div>
</div>
</div>
</div>

View File

@ -54,6 +54,32 @@
</div>
</div>
<!-- S3 Browser Modal -->
<div class="modal fade" id="s3BrowserModal" tabindex="-1" aria-labelledby="s3BrowserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="s3BrowserModalLabel">Browse S3 Storage</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="s3BrowserContent">
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="selectS3FileBtn" onclick="selectS3File()" disabled>Select File</button>
</div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
@ -89,7 +115,7 @@
<td>{{ club.hockey_club }}</td>
<td>
{% if club.logo_url %}
<img src="{{ club.logo_url }}" alt="{{ club.hockey_club }} logo" style="max-height: 40px; max-width: 60px;" onerror="this.style.display='none'">
<img src="{{ s3_asset_service.get_logo_url(club.logo_url, club.hockey_club) }}" alt="{{ club.hockey_club }} logo" style="max-height: 40px; max-width: 60px;" onerror="this.style.display='none'">
{% else %}
<span class="text-muted">No logo</span>
{% endif %}
@ -143,6 +169,206 @@
}, 5000);
}
// S3 Browser functionality
let selectedS3File = null;
let currentS3Path = '';
function browseS3() {
// Reset state
selectedS3File = null;
currentS3Path = '';
// Show loading state
document.getElementById('s3BrowserContent').innerHTML = `
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
`;
// Show modal
const s3BrowserModal = new bootstrap.Modal(document.getElementById('s3BrowserModal'));
s3BrowserModal.show();
// Load S3 contents from root of assets folder
loadS3Contents('');
}
function loadS3Contents(path) {
fetch(`/admin/api/s3-browser?path=${encodeURIComponent(path)}`)
.then(response => response.json())
.then(data => {
if (data.success) {
displayS3Contents(data);
} else {
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> ${data.message}
</div>
`;
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> Failed to load S3 contents. Please try again.
</div>
`;
});
}
function displayS3Contents(data) {
currentS3Path = data.path;
let html = `
<div class="row mb-3">
<div class="col-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="#" onclick="loadS3Contents('')">assets</a>
</li>
`;
// Build breadcrumb
if (data.path !== '') {
const pathParts = data.path.split('/').filter(p => p);
let currentPath = '';
pathParts.forEach((part, index) => {
currentPath += part + '/';
const isLast = index === pathParts.length - 1;
html += `<li class="breadcrumb-item ${isLast ? 'active' : ''}">`;
if (!isLast) {
html += `<a href="#" onclick="loadS3Contents('${currentPath}')">${part}</a>`;
} else {
html += part;
}
html += '</li>';
});
}
html += `
</ol>
</nav>
</div>
</div>
<div class="row">
`;
// Display folders
if (data.folders.length > 0) {
data.folders.forEach(folder => {
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 folder-card" onclick="loadS3Contents('${folder.path}')" style="cursor: pointer;">
<div class="card-body text-center">
<i class="fas fa-folder fa-3x text-warning mb-2"></i>
<h6 class="card-title">${folder.name}</h6>
</div>
</div>
</div>
`;
});
}
// Display files
if (data.files.length > 0) {
data.files.forEach(file => {
const isImage = /\.(jpg|jpeg|png|gif|svg|webp)$/i.test(file.name);
const fileSize = formatFileSize(file.size);
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 file-card" onclick="selectS3FileItem('${file.path}', '${file.url}')" style="cursor: pointer;">
<div class="card-body text-center">
`;
if (isImage) {
html += `
<img src="${file.url}" alt="${file.name}" class="img-fluid mb-2" style="max-height: 100px; max-width: 100%; object-fit: contain;">
`;
} else {
html += `
<i class="fas fa-file fa-3x text-primary mb-2"></i>
`;
}
html += `
<h6 class="card-title">${file.name}</h6>
<small class="text-muted">${fileSize}</small>
</div>
</div>
</div>
`;
});
}
if (data.folders.length === 0 && data.files.length === 0) {
html += `
<div class="col-12">
<div class="alert alert-info text-center">
<i class="fas fa-info-circle"></i> No files or folders found in this directory.
</div>
</div>
`;
}
html += `
</div>
`;
document.getElementById('s3BrowserContent').innerHTML = html;
}
function selectS3FileItem(filePath, fileUrl) {
// Remove previous selection
document.querySelectorAll('.file-card').forEach(card => {
card.classList.remove('border-primary');
});
// Add selection to clicked card
event.currentTarget.classList.add('border-primary');
// Store selected file
selectedS3File = {
path: filePath,
url: fileUrl
};
// Enable select button
document.getElementById('selectS3FileBtn').disabled = false;
}
function selectS3File() {
if (selectedS3File) {
// Update the logo URL field in the add club form
const logoUrlField = document.querySelector('input[name="logo_url"]');
if (logoUrlField) {
logoUrlField.value = selectedS3File.path;
}
// Close modal
const s3BrowserModal = bootstrap.Modal.getInstance(document.getElementById('s3BrowserModal'));
s3BrowserModal.hide();
// Show success message
showStatus('Logo selected successfully!', 'success');
}
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function previewClubs() {
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
const content = document.getElementById('previewContent');

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Club - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
@ -43,7 +44,12 @@
<div class="mb-3">
{{ form.logo_url.label(class="form-label") }}
{{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }}
<div class="input-group">
{{ form.logo_url(class="form-control", id="logoUrl", onchange="previewLogo()") }}
<button type="button" class="btn btn-outline-secondary" onclick="browseS3()" title="Browse S3 Storage">
<i class="fas fa-cloud"></i> Browse S3
</button>
</div>
{% if form.logo_url.errors %}
<div class="text-danger">
{% for error in form.logo_url.errors %}
@ -51,7 +57,7 @@
{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">Enter the full URL to the club's logo image</small>
<small class="form-text text-muted">Enter the full URL to the club's logo image or use the S3 browser to select from your configured storage.</small>
<!-- Logo Preview -->
<div id="logoPreview" class="mt-2" style="display: none;">
@ -71,6 +77,32 @@
</div>
</div>
<!-- S3 Browser Modal -->
<div class="modal fade" id="s3BrowserModal" tabindex="-1" aria-labelledby="s3BrowserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="s3BrowserModalLabel">Browse S3 Storage</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="s3BrowserContent">
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="selectS3FileBtn" onclick="selectS3File()" disabled>Select File</button>
</div>
</div>
</div>
</div>
<div class="mt-3">
<a href="/admin/clubs" class="btn btn-outline-secondary">Back to Club Management</a>
</div>
@ -94,6 +126,205 @@
}
}
// S3 Browser functionality
let selectedS3File = null;
let currentS3Path = '';
function browseS3() {
// Reset state
selectedS3File = null;
currentS3Path = '';
// Show loading state
document.getElementById('s3BrowserContent').innerHTML = `
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading S3 contents...</p>
</div>
`;
// Show modal
const s3BrowserModal = new bootstrap.Modal(document.getElementById('s3BrowserModal'));
s3BrowserModal.show();
// Load S3 contents from root of assets folder
loadS3Contents('');
}
function loadS3Contents(path) {
fetch(`/admin/api/s3-browser?path=${encodeURIComponent(path)}`)
.then(response => response.json())
.then(data => {
if (data.success) {
displayS3Contents(data);
} else {
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> ${data.message}
</div>
`;
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('s3BrowserContent').innerHTML = `
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> Failed to load S3 contents. Please try again.
</div>
`;
});
}
function displayS3Contents(data) {
currentS3Path = data.path;
let html = `
<div class="row mb-3">
<div class="col-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="#" onclick="loadS3Contents('')">assets</a>
</li>
`;
// Build breadcrumb
if (data.path !== '') {
const pathParts = data.path.split('/').filter(p => p);
let currentPath = '';
pathParts.forEach((part, index) => {
currentPath += part + '/';
const isLast = index === pathParts.length - 1;
html += `<li class="breadcrumb-item ${isLast ? 'active' : ''}">`;
if (!isLast) {
html += `<a href="#" onclick="loadS3Contents('${currentPath}')">${part}</a>`;
} else {
html += part;
}
html += '</li>';
});
}
html += `
</ol>
</nav>
</div>
</div>
<div class="row">
`;
// Display folders
if (data.folders.length > 0) {
data.folders.forEach(folder => {
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 folder-card" onclick="loadS3Contents('${folder.path}')" style="cursor: pointer;">
<div class="card-body text-center">
<i class="fas fa-folder fa-3x text-warning mb-2"></i>
<h6 class="card-title">${folder.name}</h6>
</div>
</div>
</div>
`;
});
}
// Display files
if (data.files.length > 0) {
data.files.forEach(file => {
const isImage = /\.(jpg|jpeg|png|gif|svg|webp)$/i.test(file.name);
const fileSize = formatFileSize(file.size);
html += `
<div class="col-md-3 mb-3">
<div class="card h-100 file-card" onclick="selectS3FileItem('${file.path}', '${file.url}')" style="cursor: pointer;">
<div class="card-body text-center">
`;
if (isImage) {
html += `
<img src="${file.url}" alt="${file.name}" class="img-fluid mb-2" style="max-height: 100px; max-width: 100%; object-fit: contain;">
`;
} else {
html += `
<i class="fas fa-file fa-3x text-primary mb-2"></i>
`;
}
html += `
<h6 class="card-title">${file.name}</h6>
<small class="text-muted">${fileSize}</small>
</div>
</div>
</div>
`;
});
}
if (data.folders.length === 0 && data.files.length === 0) {
html += `
<div class="col-12">
<div class="alert alert-info text-center">
<i class="fas fa-info-circle"></i> No files or folders found in this directory.
</div>
</div>
`;
}
html += `
</div>
`;
document.getElementById('s3BrowserContent').innerHTML = html;
}
function selectS3FileItem(filePath, fileUrl) {
// Remove previous selection
document.querySelectorAll('.file-card').forEach(card => {
card.classList.remove('border-primary');
});
// Add selection to clicked card
event.currentTarget.classList.add('border-primary');
// Store selected file
selectedS3File = {
path: filePath,
url: fileUrl
};
// Enable select button
document.getElementById('selectS3FileBtn').disabled = false;
}
function selectS3File() {
if (selectedS3File) {
// Update the logo URL field
const logoUrlField = document.getElementById('logoUrl');
if (logoUrlField) {
logoUrlField.value = selectedS3File.path;
// Trigger preview update
previewLogo();
}
// Close modal
const s3BrowserModal = bootstrap.Modal.getInstance(document.getElementById('s3BrowserModal'));
s3BrowserModal.hide();
}
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Preview logo on page load if URL is already filled
document.addEventListener('DOMContentLoaded', function() {
previewLogo();

View File

@ -28,6 +28,7 @@
</div>
</div>
{% if is_admin %}
<div class="col-md-6">
<h3>Admin Section</h3>
<div class="list-group">
@ -87,8 +88,25 @@
<h4 class="list-group-item-heading">Database Status</h4>
<p class="list-group-item-text">View current database configuration and status</p>
</a>
<a href="/admin/s3-config" class="list-group-item">
<h4 class="list-group-item-heading">S3 Configuration</h4>
<p class="list-group-item-text">Configure AWS S3 storage for logos and assets</p>
</a>
<a href="/admin/s3-status" class="list-group-item">
<h4 class="list-group-item-heading">S3 Status</h4>
<p class="list-group-item-text">View current S3 configuration and connection status</p>
</a>
</div>
</div>
{% else %}
<div class="col-md-6">
<h3>Admin Access</h3>
<div class="alert alert-info">
<h4 class="alert-heading">Authentication Required</h4>
<p>Admin functions require authentication. Please contact the system administrator for access.</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>

View File

@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>S3 Configuration - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.config-section {
background-color: #f8f9fa;
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 1rem;
}
.help-text {
font-size: 0.875rem;
color: #6c757d;
}
</style>
</head>
<body>
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h1>S3 Configuration</h1>
<p class="lead">Configure AWS S3 storage for logos and assets</p>
<div class="mb-3">
<a href="/admin" class="btn btn-secondary">Back to Admin</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
{{ form.hidden_tag() }}
<!-- Enable S3 Section -->
<div class="config-section">
<h4>Storage Configuration</h4>
<div class="form-check form-switch mb-3">
{{ form.enable_s3(class="form-check-input") }}
{{ form.enable_s3.label(class="form-check-label") }}
<div class="help-text">When enabled, logos and assets will be served from S3. When disabled, local static files will be used.</div>
</div>
<div class="form-check form-switch mb-3">
{{ form.fallback_to_static(class="form-check-input") }}
{{ form.fallback_to_static.label(class="form-check-label") }}
<div class="help-text">If S3 is unavailable, fallback to local static files.</div>
</div>
</div>
<!-- Storage Provider Section -->
<div class="config-section">
<h4>Storage Provider</h4>
<div class="mb-3">
{{ form.storage_provider.label(class="form-label") }}
{{ form.storage_provider(class="form-select") }}
<div class="help-text">Choose between AWS S3 or MinIO (self-hosted S3-compatible storage).</div>
</div>
</div>
<!-- Credentials Section -->
<div class="config-section">
<h4>Access Credentials</h4>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form.aws_access_key_id.label(class="form-label") }}
{{ form.aws_access_key_id(class="form-control", placeholder="AKIA...") }}
<div class="help-text">Your Access Key ID (AWS or MinIO)</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
{{ form.aws_secret_access_key.label(class="form-label") }}
{{ form.aws_secret_access_key(class="form-control", placeholder="Enter secret key") }}
<div class="help-text">Your Secret Access Key (AWS or MinIO)</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form.aws_region.label(class="form-label") }}
{{ form.aws_region(class="form-control") }}
<div class="help-text">Region (AWS) or leave default for MinIO</div>
</div>
</div>
</div>
</div>
<!-- MinIO Configuration Section -->
<div class="config-section" id="minio-config" style="display: none;">
<h4>MinIO Configuration</h4>
<div class="row">
<div class="col-md-8">
<div class="mb-3">
{{ form.minio_endpoint.label(class="form-label") }}
{{ form.minio_endpoint(class="form-control", placeholder="minio.example.com:9000") }}
<div class="help-text">MinIO server endpoint (hostname:port)</div>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<div class="form-check form-switch">
{{ form.minio_use_ssl(class="form-check-input") }}
{{ form.minio_use_ssl.label(class="form-check-label") }}
</div>
<div class="help-text">Enable SSL/TLS for MinIO connection</div>
</div>
</div>
</div>
</div>
<!-- S3 Bucket Configuration -->
<div class="config-section">
<h4>S3 Bucket Configuration</h4>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form.bucket_name.label(class="form-label") }}
{{ form.bucket_name(class="form-control", placeholder="my-motm-assets") }}
<div class="help-text">Name of your S3 bucket</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
{{ form.bucket_prefix.label(class="form-label") }}
{{ form.bucket_prefix(class="form-control") }}
<div class="help-text">Prefix for objects in the bucket (e.g., motm-assets/)</div>
</div>
</div>
</div>
</div>
<!-- URL Configuration -->
<div class="config-section">
<h4>URL Configuration</h4>
<div class="form-check form-switch mb-3">
{{ form.use_signed_urls(class="form-check-input") }}
{{ form.use_signed_urls.label(class="form-check-label") }}
<div class="help-text">Use signed URLs for secure access to private objects. If disabled, objects must be public.</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form.signed_url_expiry.label(class="form-label") }}
{{ form.signed_url_expiry(class="form-control") }}
<div class="help-text">How long signed URLs remain valid (in seconds)</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="d-flex gap-2">
{{ form.test_connection(class="btn btn-info") }}
{{ form.save_config(class="btn btn-primary") }}
{{ form.cancel(class="btn btn-secondary") }}
</div>
</form>
<!-- Current Configuration Display -->
<div class="config-section mt-4">
<h4>Current Configuration</h4>
<div class="row">
<div class="col-md-6">
<strong>S3 Status:</strong>
<span class="badge bg-{{ 'success' if current_config.get('enable_s3') else 'secondary' }}">
{{ 'Enabled' if current_config.get('enable_s3') else 'Disabled' }}
</span>
</div>
<div class="col-md-6">
<strong>Bucket:</strong> {{ current_config.get('bucket_name', 'Not configured') }}
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<strong>Region:</strong> {{ current_config.get('aws_region', 'Not configured') }}
</div>
<div class="col-md-6">
<strong>Signed URLs:</strong>
<span class="badge bg-{{ 'success' if current_config.get('use_signed_urls') else 'secondary' }}">
{{ 'Enabled' if current_config.get('use_signed_urls') else 'Disabled' }}
</span>
</div>
</div>
</div>
<!-- Help Section -->
<div class="config-section mt-4">
<h4>Setup Instructions</h4>
<ol>
<li><strong>Create S3 Bucket:</strong> Create an S3 bucket in your AWS account</li>
<li><strong>Set Permissions:</strong> Configure bucket permissions for your access key</li>
<li><strong>Upload Assets:</strong> Upload your logos and assets to the bucket</li>
<li><strong>Configure Here:</strong> Enter your credentials and bucket details above</li>
<li><strong>Test Connection:</strong> Use the "Test S3 Connection" button to verify</li>
<li><strong>Save Configuration:</strong> Save your settings to enable S3 storage</li>
</ol>
<div class="alert alert-info">
<h6>Asset Organization</h6>
<p>Organize your assets in the S3 bucket as follows:</p>
<ul class="mb-0">
<li><code>assets/images/clubs/</code> - Club logos</li>
<li><code>assets/images/</code> - General images</li>
<li><code>assets/logos/</code> - Alternative logo location</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Show/hide MinIO configuration based on storage provider selection
function toggleMinioConfig() {
const storageProvider = document.getElementById('storage_provider');
const minioConfig = document.getElementById('minio-config');
if (storageProvider.value === 'minio') {
minioConfig.style.display = 'block';
} else {
minioConfig.style.display = 'none';
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
toggleMinioConfig();
// Add event listener for storage provider changes
const storageProviderSelect = document.getElementById('storage_provider');
if (storageProviderSelect) {
storageProviderSelect.addEventListener('change', toggleMinioConfig);
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>S3 Status - HKFC Men's C Team</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h1>S3 Storage Status</h1>
<p class="lead">Current S3 configuration and connection status</p>
<div class="mb-3">
<a href="/admin/s3-config" class="btn btn-primary">Configure S3</a>
<a href="/admin" class="btn btn-secondary">Back to Admin</a>
</div>
<div class="card">
<div class="card-header">
<h5>Configuration Status</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td><strong>S3 Storage:</strong></td>
<td>
<span class="badge bg-{{ 'success' if s3_info.enabled else 'secondary' }}">
{{ 'Enabled' if s3_info.enabled else 'Disabled' }}
</span>
</td>
</tr>
<tr>
<td><strong>Provider:</strong></td>
<td>{{ s3_info.storage_provider.title() if s3_info.get('storage_provider') else 'AWS S3' }}</td>
</tr>
<tr>
<td><strong>Bucket Name:</strong></td>
<td>{{ s3_info.bucket_name }}</td>
</tr>
<tr>
<td><strong>Region:</strong></td>
<td>{{ s3_info.aws_region }}</td>
</tr>
{% if s3_info.get('storage_provider') == 'minio' %}
<tr>
<td><strong>MinIO Endpoint:</strong></td>
<td>{{ s3_info.get('minio_endpoint', 'Not configured') }}</td>
</tr>
{% endif %}
</table>
</div>
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td><strong>Signed URLs:</strong></td>
<td>
<span class="badge bg-{{ 'success' if s3_info.use_signed_urls else 'secondary' }}">
{{ 'Enabled' if s3_info.use_signed_urls else 'Disabled' }}
</span>
</td>
</tr>
<tr>
<td><strong>Fallback:</strong></td>
<td>
<span class="badge bg-{{ 'success' if s3_info.fallback_enabled else 'warning' }}">
{{ 'Enabled' if s3_info.fallback_enabled else 'Disabled' }}
</span>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h5>Connection Status</h5>
</div>
<div class="card-body">
<div class="alert alert-{{ 'success' if s3_info.connection_success else 'danger' }}" role="alert">
<h6 class="alert-heading">
{{ '✅ Connection Successful' if s3_info.connection_success else '❌ Connection Failed' }}
</h6>
<p class="mb-0">{{ s3_info.connection_status }}</p>
</div>
</div>
</div>
{% if s3_info.enabled and s3_info.connection_success %}
<div class="card mt-4">
<div class="card-header">
<h5>Asset Management</h5>
</div>
<div class="card-body">
<p>S3 storage is properly configured and connected. Assets will be served from S3.</p>
<div class="alert alert-info">
<h6>Asset Locations</h6>
<ul class="mb-0">
<li><strong>Club Logos:</strong> <code>{{ s3_info.bucket_name }}/assets/images/clubs/</code></li>
<li><strong>General Images:</strong> <code>{{ s3_info.bucket_name }}/assets/images/</code></li>
<li><strong>Player Images:</strong> <code>{{ s3_info.bucket_name }}/assets/images/players/</code></li>
</ul>
</div>
</div>
</div>
{% elif s3_info.enabled and not s3_info.connection_success %}
<div class="card mt-4">
<div class="card-header">
<h5>Troubleshooting</h5>
</div>
<div class="card-body">
<div class="alert alert-warning">
<h6>S3 is enabled but connection failed</h6>
<p>Check the following:</p>
<ul>
<li>Verify AWS credentials are correct</li>
<li>Ensure the bucket name exists</li>
<li>Check bucket permissions</li>
<li>Verify AWS region is correct</li>
</ul>
</div>
</div>
</div>
{% else %}
<div class="card mt-4">
<div class="card-header">
<h5>Static File Storage</h5>
</div>
<div class="card-body">
<p>S3 storage is disabled. Assets are being served from local static files.</p>
<div class="alert alert-info">
<h6>Local Asset Locations</h6>
<ul class="mb-0">
<li><strong>Club Logos:</strong> <code>/static/images/clubs/</code></li>
<li><strong>General Images:</strong> <code>/static/images/</code></li>
<li><strong>Player Images:</strong> <code>/static/images/players/</code></li>
</ul>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>