feat: Implement optimized image serving and caching for improved performance

This commit is contained in:
2026-04-17 23:46:08 +02:00
parent fd6915a923
commit 29e0356641
3 changed files with 522 additions and 23 deletions
+299 -16
View File
@@ -231,6 +231,30 @@ def _set_security_headers(response):
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
if cfg.SSL_ENABLED:
response.headers.setdefault('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
# Optimize caching for static resources (images, etc.)
path = request.path
# Aggressive caching for optimized images (480p) - they're immutable
if '/image/optimized/' in path:
response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' # 30 days
# Moderate caching for thumbnails
elif '/thumbnails/' in path:
response.headers['Cache-Control'] = 'public, max-age=604800' # 7 days
# Moderate caching for previews
elif '/previews/' in path:
response.headers['Cache-Control'] = 'public, max-age=604800' # 7 days
# Short cache for regular uploads (in case they're updated/deleted)
elif '/uploads/' in path:
response.headers['Cache-Control'] = 'public, max-age=3600' # 1 hour
# Ensure WebP images are served with correct content-type
if path.endswith('.webp') or '.webp' in path:
response.headers['Content-Type'] = 'image/webp'
return response
@@ -2256,6 +2280,116 @@ def preview_file(filename):
return Response("Preview not found", status=404)
@app.route('/image/optimized/<filename>')
def optimized_image(filename):
"""
Serve optimized images at 480p maximum resolution (854px width).
Images are cached and converted to WebP for maximum compression.
This endpoint minimizes server RAM usage and bandwidth.
Args:
filename (str): Original image filename
Returns:
flask.Response: Optimized image (WebP preferred, JPEG fallback) or placeholder
"""
try:
denied = _deny_if_unauthenticated_file_access()
if denied:
return denied
# Sanitize filename to prevent directory traversal
filename = os.path.basename(filename)
name_part, ext_part = os.path.splitext(filename)
# Determine cache directory (use unique subdirectory for 480p optimized images)
cache_dir = app.config['THUMBNAIL_FOLDER'] # Reuse existing directory structure
cache_subdir = os.path.join(cache_dir, 'optimized_480p')
os.makedirs(cache_subdir, exist_ok=True)
# Try to find the cached optimized image first (WebP preferred)
cached_webp = os.path.join(cache_subdir, f"{name_part}_480p.webp")
if os.path.exists(cached_webp):
response = send_from_directory(cache_subdir, f"{name_part}_480p.webp")
response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' # 30 days
response.headers['Content-Type'] = 'image/webp'
return response
# Try cached JPEG fallback
cached_jpeg = os.path.join(cache_subdir, f"{name_part}_480p.jpg")
if os.path.exists(cached_jpeg):
response = send_from_directory(cache_subdir, f"{name_part}_480p.jpg")
response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' # 30 days
response.headers['Content-Type'] = 'image/jpeg'
return response
# Find the original image
original_paths = [
os.path.join(app.config['UPLOAD_FOLDER'], filename),
os.path.join("/var/Inventarsystem/Web/uploads", filename),
os.path.join(app.config['UPLOAD_FOLDER'], f"{name_part}.webp"),
os.path.join("/var/Inventarsystem/Web/uploads", f"{name_part}.webp"),
]
original_image_path = None
for path in original_paths:
if os.path.exists(path):
original_image_path = path
break
# If original image not found, serve placeholder
if not original_image_path:
svg_placeholder = os.path.join(app.static_folder, 'img', 'no-image.svg')
if os.path.exists(svg_placeholder):
return send_from_directory(app.static_folder, 'img/no-image.svg')
return send_from_directory(app.static_folder, 'img/no-image.png')
# Skip if it's not a supported image format
if not is_image_file(original_image_path):
return send_from_directory(app.static_folder, 'img/no-image.png')
# Create optimized version (480p = ~854px width max)
MAX_WIDTH = 854
MAX_HEIGHT = 480
try:
with Image.open(original_image_path) as img:
# Normalize orientation (fix EXIF rotation)
img = normalize_image_orientation(img)
# Resize maintaining aspect ratio
img.thumbnail((MAX_WIDTH, MAX_HEIGHT), Image.Resampling.LANCZOS)
# Try to save as WebP first (best compression)
try:
img = img.convert('RGB') if img.mode in ('RGBA', 'P') else img
img.save(cached_webp, 'WEBP', quality=80, method=6) # Quality 80, slowest method for best compression
response = send_from_directory(cache_subdir, f"{name_part}_480p.webp")
response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' # 30 days
response.headers['Content-Type'] = 'image/webp'
return response
except Exception as webp_err:
app.logger.warning(f"WebP encoding failed for {filename}, falling back to JPEG: {str(webp_err)}")
# Fallback to JPEG if WebP fails
img = img.convert('RGB') if img.mode in ('RGBA', 'P', 'L') else img
img.save(cached_jpeg, 'JPEG', quality=75, optimize=True) # Quality 75, optimized
response = send_from_directory(cache_subdir, f"{name_part}_480p.jpg")
response.headers['Cache-Control'] = 'public, max-age=2592000, immutable' # 30 days
response.headers['Content-Type'] = 'image/jpeg'
return response
except Exception as img_err:
app.logger.error(f"Error processing image {filename}: {str(img_err)}")
return send_from_directory(app.static_folder, 'img/no-image.png')
except Exception as e:
app.logger.error(f"Error serving optimized image {filename}: {str(e)}")
return Response("Optimized image not found", status=404)
# @app.route('/QRCodes/<filename>')
# def qrcode_file(filename):
# """
@@ -6714,10 +6848,7 @@ def register():
if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
if 'username' in session and us.check_admin(session['username']):
if 'username' in session:
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
@@ -6800,9 +6931,6 @@ def user_del():
if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
all_users = us.get_all_users()
@@ -6866,9 +6994,6 @@ def delete_user():
if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
username = request.form.get('username')
if not username:
@@ -7143,6 +7268,115 @@ def admin_audit_export():
client.close()
@app.route('/admin/image_cache_stats', methods=['GET'])
def admin_image_cache_stats():
"""
Get statistics about optimized image cache.
Admin-only endpoint for monitoring and maintenance.
Returns:
JSON with cache statistics (file count, total size, creation dates)
"""
if 'username' not in session:
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
permissions = _get_current_user_permissions()
if not _action_access_allowed(permissions, 'can_manage_settings'):
return jsonify({'ok': False, 'error': 'forbidden'}), 403
try:
cache_dir = os.path.join(app.config['THUMBNAIL_FOLDER'], 'optimized_480p')
if not os.path.exists(cache_dir):
return jsonify({
'ok': True,
'cache_exists': False,
'file_count': 0,
'total_size_mb': 0
})
files = []
total_size = 0
for filename in os.listdir(cache_dir):
file_path = os.path.join(cache_dir, filename)
if not os.path.isfile(file_path):
continue
file_size = os.path.getsize(file_path)
total_size += file_size
mod_time = os.path.getmtime(file_path)
files.append({
'name': filename,
'size_kb': round(file_size / 1024, 2),
'modified': datetime.datetime.fromtimestamp(mod_time).isoformat()
})
files.sort(key=lambda x: x['modified'], reverse=True)
return jsonify({
'ok': True,
'cache_exists': True,
'file_count': len(files),
'total_size_mb': round(total_size / (1024 * 1024), 2),
'files': files[:20] # Return only newest 20 files
})
except Exception as e:
app.logger.error(f"Error getting cache stats: {str(e)}")
return jsonify({'ok': False, 'error': str(e)}), 500
@app.route('/admin/image_cache_cleanup', methods=['POST'])
def admin_image_cache_cleanup():
"""
Trigger cleanup of old optimized images.
Admin-only endpoint for maintenance.
Args (via form):
max_age_days: Delete images older than this many days (default 30)
Returns:
JSON with cleanup results (deleted count, freed space)
"""
if 'username' not in session:
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
permissions = _get_current_user_permissions()
if not _action_access_allowed(permissions, 'can_manage_settings'):
return jsonify({'ok': False, 'error': 'forbidden'}), 403
try:
max_age_days = int(request.form.get('max_age_days', 30))
max_age_days = max(1, min(max_age_days, 365)) # Clamp between 1 and 365 days
result = cleanup_old_optimized_images(max_age_days)
if result['error']:
return jsonify({'ok': False, 'error': result['error']}), 500
# Log the action
_append_audit_event(
db=MongoClient(MONGODB_HOST, MONGODB_PORT)[MONGODB_DB],
event_type='admin_image_cache_cleanup',
payload={
'max_age_days': max_age_days,
'deleted_count': result['deleted'],
'freed_mb': result['freed_mb']
}
)
return jsonify({
'ok': True,
'deleted': result['deleted'],
'freed_mb': result['freed_mb'],
'message': f"Cleaned up {result['deleted']} images, freed {result['freed_mb']} MB"
})
except Exception as e:
app.logger.error(f"Error during image cache cleanup: {str(e)}")
return jsonify({'ok': False, 'error': str(e)}), 500
@app.route('/admin/reset_borrowing/<borrow_id>', methods=['POST'])
def admin_reset_borrowing(borrow_id):
"""
@@ -7798,10 +8032,6 @@ def admin_reset_user_password():
if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adresse zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adresse zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
username = request.form.get('username')
new_password = html.escape(request.form.get('new_password', 'Password123')) # Default temporary password
@@ -7862,7 +8092,7 @@ def admin_update_user_name():
@app.route('/admin_update_user_permissions', methods=['POST'])
def admin_update_user_permissions():
"""Admin route to update permission preset and per-endpoint overrides for a user."""
if 'username' not in session or not us.check_admin(session['username']):
if 'username' not in session:
flash('Nicht autorisierter Zugriff', 'error')
return redirect(url_for('login'))
@@ -7897,7 +8127,7 @@ def admin_update_user_permissions():
@app.route('/admin_anonymize_names', methods=['POST'])
def admin_anonymize_names():
"""Anonymize already stored personal names into short aliases."""
if 'username' not in session or not us.check_admin(session['username']):
if 'username' not in session:
flash('Nicht autorisierter Zugriff', 'error')
return redirect(url_for('login'))
@@ -10068,6 +10298,59 @@ def serve_js(filename):
js_folder = os.path.join(app.static_folder, 'js')
return send_from_directory(js_folder, filename)
def cleanup_old_optimized_images(max_age_days=30):
"""
Clean up old optimized images to save disk space.
Optimized images are re-created on demand, so old ones can be safely deleted.
Args:
max_age_days (int): Delete cached images older than this many days. Default 30.
Returns:
dict: Statistics about cleanup (deleted count, freed space in MB)
"""
try:
import time
import shutil
cache_dir = os.path.join(app.config['THUMBNAIL_FOLDER'], 'optimized_480p')
if not os.path.exists(cache_dir):
return {'deleted': 0, 'freed_mb': 0, 'error': None}
current_time = time.time()
max_age_seconds = max_age_days * 24 * 60 * 60
deleted_count = 0
freed_bytes = 0
for filename in os.listdir(cache_dir):
file_path = os.path.join(cache_dir, filename)
if not os.path.isfile(file_path):
continue
file_age_seconds = current_time - os.path.getmtime(file_path)
if file_age_seconds > max_age_seconds:
try:
file_size = os.path.getsize(file_path)
os.remove(file_path)
deleted_count += 1
freed_bytes += file_size
except Exception as e:
app.logger.warning(f"Failed to delete optimized image {filename}: {str(e)}")
freed_mb = freed_bytes / (1024 * 1024)
app.logger.info(f"Cleanup complete: Deleted {deleted_count} images, freed {freed_mb:.2f} MB")
return {
'deleted': deleted_count,
'freed_mb': round(freed_mb, 2),
'error': None
}
except Exception as e:
app.logger.error(f"Error during optimized image cleanup: {str(e)}")
return {'deleted': 0, 'freed_mb': 0, 'error': str(e)}
@app.route('/log_mobile_issue', methods=['POST'])
def log_mobile_issue():
"""
+19 -7
View File
@@ -943,8 +943,14 @@
</div>`;
}
} else {
// For images, use thumbnail if available
// Always ensure consistent URL construction for all image types, including PNG
// For images, use optimized 480p version for performance
// Extract filename from full path for optimization endpoint
const imageFilename = image.split('/').pop();
// Generate optimized image URL (480p max, WebP or JPEG)
let optimizedSrc = `{{ url_for('optimized_image', filename='') }}${imageFilename}`;
// Fallback to original/thumbnail if optimization fails
let baseSrc = thumbnailInfo && thumbnailInfo.has_thumbnail
? thumbnailInfo.thumbnail_url
: (image.startsWith('/uploads/') || image.startsWith('http') ?
@@ -954,8 +960,9 @@
// Use our PNG to JPG conversion helper function
const imageSrc = getImageSrc(baseSrc);
return `<img src="${imageSrc.primary}" alt="${item.Name}" class="item-image" data-index="${index}"
data-original="${image}" onerror="if(this.src !== '${imageSrc.fallback}') this.src='${imageSrc.fallback}'; else this.src='{{ url_for('static', filename='img/no-image.png') }}';">`;
return `<img src="${optimizedSrc}" alt="${item.Name}" class="item-image" data-index="${index}"
data-original="${image}" loading="lazy"
onerror="if(this.src !== '${imageSrc.primary}') this.src='${imageSrc.primary}'; else if(this.src !== '${imageSrc.fallback}') this.src='${imageSrc.fallback}'; else this.src='{{ url_for('static', filename='img/no-image.png') }}';">`;
}
}).join('') : '';
@@ -1428,7 +1435,12 @@
Your browser does not support the video tag.
</video>`;
} else {
// For images, ensure URL construction is consistent for all image types, including PNG
// For images, use optimized 480p version for performance
// Extract filename for optimization endpoint
const imageFilename = file.split('/').pop();
let optimizedSrc = `{{ url_for('optimized_image', filename='') }}${imageFilename}`;
// Fallback to original if optimization fails
const baseSrc = file.startsWith('/uploads/') || file.startsWith('http') ?
file :
`{{ url_for('uploaded_file', filename='') }}${file}`;
@@ -1436,8 +1448,8 @@
// Use our PNG to JPG conversion helper function
const imageSrc = getImageSrc(baseSrc);
return `<img src="${imageSrc.primary}" alt="${item.Name}" class="modal-image ${index === 0 ? 'active-image' : ''}" id="modal-image-${index}"
onerror="if(this.src !== '${imageSrc.fallback}') this.src='${imageSrc.fallback}'; else this.src='{{ url_for('static', filename='img/no-image.png') }}';">`;
return `<img src="${optimizedSrc}" alt="${item.Name}" class="modal-image ${index === 0 ? 'active-image' : ''}" id="modal-image-${index}"
onerror="if(this.src !== '${imageSrc.primary}') this.src='${imageSrc.primary}'; else if(this.src !== '${imageSrc.fallback}') this.src='${imageSrc.fallback}'; else this.src='{{ url_for('static', filename='img/no-image.png') }}';">`;
}
}).join('') : '';