57b7810069
base.html passed the cache-busting query string INSIDE the filename argument:
url_for('static', filename='uploads/header_logo.png?v=1')
Flask percent-encodes the '?' to '%3F', producing a request for a file
literally named "header_logo.png%3Fv=1", which does not exist. Every
authenticated page therefore logged:
GET /static/uploads/header_logo.png%3Fv=1 404
The logo silently stayed hidden too, because the element carries
onerror="this.style.display='none'".
Changes:
- base.html / login.html: append "?v=..." OUTSIDE url_for(), matching what
admin/customize_logos.html already did correctly.
- Inject logo_version from a context processor instead of hardcoding it.
It is derived from the newest logo's mtime, so uploading a new logo in
Admin -> Customize Logos is reflected immediately. The previous value was
a literal 1, which defeated cache-busting entirely; login.html used
range(1, 999999) | random, which changed on every request and made the
logo uncacheable.
Verified: /, /content/ and /players/ now render
/static/uploads/header_logo.png?v=<mtime> (HTTP 200), and no %3F requests
appear in the logs.
263 lines
8.4 KiB
Python
263 lines
8.4 KiB
Python
"""
|
|
DigiServer v2 - Application Factory
|
|
Modern Flask application with blueprint architecture
|
|
"""
|
|
import os
|
|
from flask import Flask, render_template
|
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
from dotenv import load_dotenv
|
|
|
|
from app.config import DevelopmentConfig, ProductionConfig, TestingConfig
|
|
from app.extensions import db, bcrypt, login_manager, migrate, cache
|
|
|
|
# Load environment variables
|
|
load_dotenv()
|
|
|
|
|
|
def create_app(config_name=None):
|
|
"""
|
|
Application factory pattern
|
|
|
|
Args:
|
|
config_name: Configuration environment (development, production, testing)
|
|
|
|
Returns:
|
|
Flask application instance
|
|
"""
|
|
# Set instance path to absolute path
|
|
instance_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'instance'))
|
|
app = Flask(__name__, instance_path=instance_path, instance_relative_config=True)
|
|
|
|
# Load configuration
|
|
if config_name == 'production':
|
|
config = ProductionConfig
|
|
elif config_name == 'testing':
|
|
config = TestingConfig
|
|
else:
|
|
config = DevelopmentConfig
|
|
|
|
app.config.from_object(config)
|
|
|
|
# Apply ProxyFix middleware for reverse proxy (Nginx/Caddy)
|
|
# This ensures proper handling of X-Forwarded-* headers
|
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
|
|
|
# ScriptNameFix: reads X-Script-Name header set by the umbrella nginx
|
|
# (e.g. /digiserver) so that url_for() generates correct full paths.
|
|
from app.utils.script_name_fix import ScriptNameFix
|
|
app.wsgi_app = ScriptNameFix(app.wsgi_app)
|
|
|
|
# Initialize extensions
|
|
db.init_app(app)
|
|
bcrypt.init_app(app)
|
|
login_manager.init_app(app)
|
|
migrate.init_app(app, db)
|
|
cache.init_app(app)
|
|
|
|
# Configure Flask-Login
|
|
configure_login_manager(app)
|
|
|
|
# Initialize CORS for player API access
|
|
from app.extensions import cors
|
|
cors.init_app(app, resources={
|
|
r"/api/*": {
|
|
"origins": ["*"],
|
|
"methods": ["GET", "POST", "OPTIONS", "PUT", "DELETE"],
|
|
"allow_headers": ["Content-Type", "Authorization"],
|
|
"supports_credentials": True,
|
|
"max_age": 3600
|
|
}
|
|
})
|
|
|
|
# Register components
|
|
register_blueprints(app)
|
|
register_error_handlers(app)
|
|
register_commands(app)
|
|
register_context_processors(app)
|
|
register_template_filters(app)
|
|
|
|
# Portal SSO: auto-login users arriving via the umbrella nginx gateway
|
|
from app.utils.portal_sso import init_portal_sso
|
|
init_portal_sso(app)
|
|
|
|
# Ensure DB schema exists (idempotent; safe to call even with migrate)
|
|
with app.app_context():
|
|
db.create_all()
|
|
|
|
return app
|
|
|
|
|
|
def register_blueprints(app):
|
|
"""Register application blueprints"""
|
|
from app.blueprints.main import main_bp
|
|
from app.blueprints.auth import auth_bp
|
|
from app.blueprints.admin import admin_bp
|
|
from app.blueprints.players import players_bp
|
|
from app.blueprints.content import content_bp
|
|
from app.blueprints.api import api_bp
|
|
|
|
# Register blueprints (using URL prefixes from blueprint definitions)
|
|
app.register_blueprint(main_bp)
|
|
app.register_blueprint(auth_bp)
|
|
app.register_blueprint(admin_bp)
|
|
app.register_blueprint(players_bp)
|
|
app.register_blueprint(content_bp)
|
|
app.register_blueprint(api_bp)
|
|
|
|
|
|
def configure_login_manager(app):
|
|
"""Configure Flask-Login"""
|
|
from app.models.user import User
|
|
|
|
login_manager.login_view = 'auth.login'
|
|
login_manager.login_message = 'Please log in to access this page.'
|
|
login_manager.login_message_category = 'info'
|
|
|
|
@login_manager.user_loader
|
|
def load_user(user_id):
|
|
return User.query.get(int(user_id))
|
|
|
|
|
|
def register_error_handlers(app):
|
|
"""Register error handlers"""
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(error):
|
|
return render_template('errors/404.html'), 404
|
|
|
|
@app.errorhandler(403)
|
|
def forbidden(error):
|
|
return render_template('errors/403.html'), 403
|
|
|
|
@app.errorhandler(500)
|
|
def internal_error(error):
|
|
db.session.rollback()
|
|
return render_template('errors/500.html'), 500
|
|
|
|
@app.errorhandler(413)
|
|
def request_entity_too_large(error):
|
|
return render_template('errors/413.html'), 413
|
|
|
|
@app.errorhandler(408)
|
|
def request_timeout(error):
|
|
return render_template('errors/408.html'), 408
|
|
|
|
|
|
def register_commands(app):
|
|
"""Register CLI commands"""
|
|
import click
|
|
|
|
@app.cli.command('init-db')
|
|
def init_db():
|
|
"""Initialize the database"""
|
|
db.create_all()
|
|
click.echo('Database initialized.')
|
|
|
|
@app.cli.command('create-admin')
|
|
@click.option('--username', default='admin', help='Admin username')
|
|
@click.option('--password', prompt=True, hide_input=True, help='Admin password')
|
|
def create_admin(username, password):
|
|
"""Create an admin user"""
|
|
from app.models.user import User
|
|
|
|
# Check if user exists
|
|
if User.query.filter_by(username=username).first():
|
|
click.echo(f'User {username} already exists.')
|
|
return
|
|
|
|
# Create admin user
|
|
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
|
admin = User(username=username, password=hashed_password, role='admin')
|
|
db.session.add(admin)
|
|
db.session.commit()
|
|
click.echo(f'Admin user {username} created successfully.')
|
|
|
|
@app.cli.command('seed-db')
|
|
def seed_db():
|
|
"""Seed database with sample data (development only)"""
|
|
if app.config['ENV'] == 'production':
|
|
click.echo('Cannot seed database in production.')
|
|
return
|
|
|
|
click.echo('Seeding database with sample data...')
|
|
# Add your seeding logic here
|
|
click.echo('Database seeded successfully.')
|
|
|
|
|
|
def register_context_processors(app):
|
|
"""Register context processors for templates"""
|
|
from flask_login import current_user
|
|
|
|
@app.context_processor
|
|
def inject_config():
|
|
"""Inject configuration variables into all templates"""
|
|
# Cache-busting token for the uploadable logos.
|
|
#
|
|
# Must be appended OUTSIDE url_for(): passing 'logo.png?v=1' as the
|
|
# filename makes Flask percent-encode the '?' into '%3F', which asks
|
|
# for a file literally named "logo.png%3Fv=1" and 404s.
|
|
#
|
|
# Derived from the newest logo's mtime so that uploading a new logo in
|
|
# Admin → Customize Logos is picked up immediately, rather than reusing
|
|
# a hardcoded value the browser has already cached.
|
|
logo_version = 1
|
|
for _logo_name in ('header_logo.png', 'login_logo.png'):
|
|
try:
|
|
_mtime = int(os.path.getmtime(
|
|
os.path.join(app.config['UPLOAD_FOLDER'], _logo_name)))
|
|
logo_version = max(logo_version, _mtime)
|
|
except OSError:
|
|
# Logo not uploaded yet — keep the current token.
|
|
pass
|
|
|
|
return {
|
|
'server_version': app.config['SERVER_VERSION'],
|
|
'build_date': app.config['BUILD_DATE'],
|
|
'logo_version': logo_version,
|
|
'logo_exists': os.path.exists(
|
|
os.path.join(app.root_path, app.config['UPLOAD_FOLDERLOGO'], 'logo.png')
|
|
)
|
|
}
|
|
|
|
@app.context_processor
|
|
def inject_user_theme():
|
|
"""Inject user theme preference"""
|
|
theme = 'light'
|
|
if current_user.is_authenticated and hasattr(current_user, 'theme'):
|
|
theme = current_user.theme
|
|
return {'theme': theme}
|
|
|
|
|
|
def register_template_filters(app):
|
|
"""Register custom Jinja2 template filters"""
|
|
from datetime import datetime, timezone
|
|
|
|
@app.template_filter('localtime')
|
|
def localtime_filter(dt, format='%Y-%m-%d %H:%M'):
|
|
"""Convert UTC datetime to local time and format it.
|
|
|
|
Args:
|
|
dt: datetime object in UTC
|
|
format: strftime format string
|
|
|
|
Returns:
|
|
Formatted datetime string in local timezone
|
|
"""
|
|
if dt is None:
|
|
return ''
|
|
|
|
# If datetime is naive (no timezone), assume it's UTC
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
|
|
# Convert to local time
|
|
local_dt = dt.astimezone()
|
|
|
|
return local_dt.strftime(format)
|
|
|
|
|
|
# For backwards compatibility and direct running
|
|
if __name__ == '__main__':
|
|
app = create_app()
|
|
app.run(host='0.0.0.0', port=5000, debug=True)
|