Fix S3 permissions and things
This commit is contained in:
parent
85858d33bd
commit
0bf5f1a29c
@ -186,3 +186,5 @@ For issues or questions about container deployment, please check:
|
|||||||
1. Application logs: `docker-compose logs motm-app`
|
1. Application logs: `docker-compose logs motm-app`
|
||||||
2. Database logs: `docker-compose logs postgres`
|
2. Database logs: `docker-compose logs postgres`
|
||||||
3. Container status: `docker-compose ps`
|
3. Container status: `docker-compose ps`
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -67,3 +67,5 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: motm-network
|
name: motm-network
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -148,3 +148,39 @@ class ClubSelectionForm(FlaskForm):
|
|||||||
select_all = SubmitField('Select All')
|
select_all = SubmitField('Select All')
|
||||||
select_none = SubmitField('Select None')
|
select_none = SubmitField('Select None')
|
||||||
cancel = SubmitField('Cancel')
|
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')
|
||||||
|
|||||||
@ -9,3 +9,5 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|||||||
|
|
||||||
-- The application will handle table creation through SQLAlchemy
|
-- The application will handle table creation through SQLAlchemy
|
||||||
-- This file is here for any additional database setup that might be needed
|
-- This file is here for any additional database setup that might be needed
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
242
motm_app/main.py
242
motm_app/main.py
@ -27,13 +27,14 @@ from flask_basicauth import BasicAuth
|
|||||||
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
||||||
from wtforms import DateField
|
from wtforms import DateField
|
||||||
from wtforms.validators import InputRequired, Email, Length, EqualTo
|
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 db_config import sql_write, sql_write_static, sql_read, sql_read_static
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from tables import matchSquadTable
|
from tables import matchSquadTable
|
||||||
from readSettings import mySettings
|
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 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 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
|
# Custom authentication class that uses database
|
||||||
class DatabaseBasicAuth(BasicAuth):
|
class DatabaseBasicAuth(BasicAuth):
|
||||||
@ -194,18 +195,24 @@ def motm_vote(randomUrlSuffix):
|
|||||||
nextClub = nextInfo[0]['nextclub']
|
nextClub = nextInfo[0]['nextclub']
|
||||||
nextTeam = nextInfo[0]['nextteam']
|
nextTeam = nextInfo[0]['nextteam']
|
||||||
nextFixture = nextInfo[0]['nextfixture']
|
nextFixture = nextInfo[0]['nextfixture']
|
||||||
hkfcLogo = nextInfo[0]['hkfclogo']
|
# Get HKFC logo from clubs table using signed URLs (with authentication)
|
||||||
oppoLogo = nextInfo[0]['oppologo']
|
hkfcLogo = s3_asset_service.get_asset_url('images/hkfc_logo.png') # Default fallback
|
||||||
currMotM = nextInfo[0]['currmotm']
|
sql_hkfc_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = 'Hong Kong Football Club'")
|
||||||
currDotD = nextInfo[0]['currdotd']
|
hkfc_logo_result = sql_read(sql_hkfc_logo)
|
||||||
oppo = nextTeam
|
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:
|
if nextClub:
|
||||||
sql_club_logo = text("SELECT logo_url FROM clubs WHERE hockey_club = :club_name")
|
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})
|
club_logo_result = sql_read(sql_club_logo, {'club_name': nextClub})
|
||||||
if club_logo_result and club_logo_result[0]['logo_url']:
|
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
|
# Get match date from admin settings
|
||||||
if nextInfo and nextInfo[0]['nextdate']:
|
if nextInfo and nextInfo[0]['nextdate']:
|
||||||
nextDate = 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.")
|
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
|
# Use default player images since playerPictureURL column doesn't exist
|
||||||
motmURL = '/static/images/default_player.png'
|
motmURL = s3_asset_service.get_asset_url('images/default_player.png')
|
||||||
dotdURL = '/static/images/default_player.png'
|
dotdURL = s3_asset_service.get_asset_url('images/default_player.png')
|
||||||
|
|
||||||
# Get match comments
|
# Get match comments
|
||||||
sql5 = text("SELECT comment FROM _motmcomments WHERE matchDate = :match_date ORDER BY RANDOM() LIMIT 1")
|
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']
|
_oppo = row[0]['nextclub']
|
||||||
commentDate = row[0]['nextdate'].strftime('%Y-%m-%d')
|
commentDate = row[0]['nextdate'].strftime('%Y-%m-%d')
|
||||||
_matchDate = 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':
|
if request.method == 'POST':
|
||||||
_comment = request.form['matchComment']
|
_comment = request.form['matchComment']
|
||||||
if _comment != 'Optional comments added here':
|
if _comment != 'Optional comments added here':
|
||||||
@ -608,7 +628,7 @@ def club_management():
|
|||||||
"""Admin page for managing clubs"""
|
"""Admin page for managing clubs"""
|
||||||
sql = text("SELECT id, hockey_club, logo_url FROM clubs ORDER BY hockey_club")
|
sql = text("SELECT id, hockey_club, logo_url FROM clubs ORDER BY hockey_club")
|
||||||
clubs = sql_read(sql)
|
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'])
|
@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)
|
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__":
|
if __name__ == "__main__":
|
||||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
|
|||||||
@ -24,5 +24,8 @@ pymysql>=1.1.0
|
|||||||
psycopg2-binary>=2.9.0
|
psycopg2-binary>=2.9.0
|
||||||
PyMySQL>=1.1.0
|
PyMySQL>=1.1.0
|
||||||
|
|
||||||
|
# AWS S3 support
|
||||||
|
boto3>=1.34.0
|
||||||
|
|
||||||
# Legacy support (can be removed after migration)
|
# Legacy support (can be removed after migration)
|
||||||
flask-mysql
|
flask-mysql
|
||||||
|
|||||||
14
motm_app/s3_config.json
Normal file
14
motm_app/s3_config.json
Normal 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
|
||||||
|
}
|
||||||
14
motm_app/s3_config.json.orig
Normal file
14
motm_app/s3_config.json.orig
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"enable_s3": true,
|
||||||
|
"storage_provider": "minio",
|
||||||
|
"aws_access_key_id": "AKIARLJ7D6ZPRRLQHWD7",
|
||||||
|
"aws_secret_access_key": "Ih8C5I8z7Or/+JGMzT0Pqjuqm7ig9Qells8qsd8q",
|
||||||
|
"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
390
motm_app/s3_config.py
Normal 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)
|
||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Add Club - HKFC Men's C Team</title>
|
<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://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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
@ -43,7 +44,12 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.logo_url.label(class="form-label") }}
|
{{ 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 %}
|
{% if form.logo_url.errors %}
|
||||||
<div class="text-danger">
|
<div class="text-danger">
|
||||||
{% for error in form.logo_url.errors %}
|
{% for error in form.logo_url.errors %}
|
||||||
@ -51,7 +57,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 -->
|
<!-- Logo Preview -->
|
||||||
<div id="logoPreview" class="mt-2" style="display: none;">
|
<div id="logoPreview" class="mt-2" style="display: none;">
|
||||||
@ -71,6 +77,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="mt-3">
|
||||||
<a href="/admin/clubs" class="btn btn-outline-secondary">Back to Club Management</a>
|
<a href="/admin/clubs" class="btn btn-outline-secondary">Back to Club Management</a>
|
||||||
</div>
|
</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
|
// Preview logo on page load if URL is already filled
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
previewLogo();
|
previewLogo();
|
||||||
|
|||||||
@ -178,7 +178,7 @@
|
|||||||
<h3>System Management</h3>
|
<h3>System Management</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-3">
|
||||||
<div class="list-group card-custom">
|
<div class="list-group card-custom">
|
||||||
<a href="/admin/database-setup" class="list-group-item">
|
<a href="/admin/database-setup" class="list-group-item">
|
||||||
<h4 class="list-group-item-heading">Database Setup</h4>
|
<h4 class="list-group-item-heading">Database Setup</h4>
|
||||||
@ -186,7 +186,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-3">
|
||||||
<div class="list-group card-custom">
|
<div class="list-group card-custom">
|
||||||
<a href="/admin/database-status" class="list-group-item">
|
<a href="/admin/database-status" class="list-group-item">
|
||||||
<h4 class="list-group-item-heading">Database Status</h4>
|
<h4 class="list-group-item-heading">Database Status</h4>
|
||||||
@ -194,6 +194,22 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -54,6 +54,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for category, message in messages %}
|
{% for category, message in messages %}
|
||||||
@ -89,7 +115,7 @@
|
|||||||
<td>{{ club.hockey_club }}</td>
|
<td>{{ club.hockey_club }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if club.logo_url %}
|
{% 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 %}
|
{% else %}
|
||||||
<span class="text-muted">No logo</span>
|
<span class="text-muted">No logo</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -143,6 +169,206 @@
|
|||||||
}, 5000);
|
}, 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() {
|
function previewClubs() {
|
||||||
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||||
const content = document.getElementById('previewContent');
|
const content = document.getElementById('previewContent');
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Edit Club - HKFC Men's C Team</title>
|
<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://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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
@ -43,7 +44,12 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.logo_url.label(class="form-label") }}
|
{{ 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 %}
|
{% if form.logo_url.errors %}
|
||||||
<div class="text-danger">
|
<div class="text-danger">
|
||||||
{% for error in form.logo_url.errors %}
|
{% for error in form.logo_url.errors %}
|
||||||
@ -51,7 +57,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 -->
|
<!-- Logo Preview -->
|
||||||
<div id="logoPreview" class="mt-2" style="display: none;">
|
<div id="logoPreview" class="mt-2" style="display: none;">
|
||||||
@ -71,6 +77,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="mt-3">
|
||||||
<a href="/admin/clubs" class="btn btn-outline-secondary">Back to Club Management</a>
|
<a href="/admin/clubs" class="btn btn-outline-secondary">Back to Club Management</a>
|
||||||
</div>
|
</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
|
// Preview logo on page load if URL is already filled
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
previewLogo();
|
previewLogo();
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h3>Admin Section</h3>
|
<h3>Admin Section</h3>
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
@ -87,8 +88,25 @@
|
|||||||
<h4 class="list-group-item-heading">Database Status</h4>
|
<h4 class="list-group-item-heading">Database Status</h4>
|
||||||
<p class="list-group-item-text">View current database configuration and status</p>
|
<p class="list-group-item-text">View current database configuration and status</p>
|
||||||
</a>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
255
motm_app/templates/s3_config.html
Normal file
255
motm_app/templates/s3_config.html
Normal 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>
|
||||||
154
motm_app/templates/s3_status.html
Normal file
154
motm_app/templates/s3_status.html
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user