from flask import Blueprint, request, jsonify, current_app from werkzeug.utils import secure_filename import os import mimetypes import base64 from bs4 import BeautifulSoup import re import cssutils api_bp = Blueprint('api', __name__, url_prefix='/api') ALLOWED_EXTENSIONS = { 'html', 'htm', 'css', 'js', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'woff', 'woff2', 'ttf', 'eot', 'json', 'map' } def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS def guess_mimetype(filepath): mime_type, _ = mimetypes.guess_type(filepath) return mime_type or 'application/octet-stream' def build_file_index(uploaded_paths): """ Index files by basename so resources referenced as /img.png, img.png, assets/img.png still resolve even if uploads were flattened. """ idx = {} for p in uploaded_paths: base = os.path.basename(p) if base not in idx: idx[base] = p return idx def resolve_uploaded_path(base_path, resource_path, file_index): """ Try: 1) base_path + resource_path (as-is, normalized) 2) base_path + lstrip('/') (for /img.png) 3) basename lookup in file_index (fallback) """ if not resource_path: return None clean = resource_path.split('#', 1)[0].split('?', 1)[0] p1 = os.path.normpath(os.path.join(base_path, clean)) if os.path.exists(p1): return p1 p2 = os.path.normpath(os.path.join(base_path, clean.lstrip('/'))) if os.path.exists(p2): return p2 base = os.path.basename(clean) if base in file_index and os.path.exists(file_index[base]): return file_index[base] return None def embed_resource(base_path, resource_path, file_index): """Convert resource to base64 data URI.""" if not resource_path: return resource_path if resource_path.startswith(('http://', 'https://', 'data:', '//')): return resource_path full_path = resolve_uploaded_path(base_path, resource_path, file_index) if not full_path: current_app.logger.warning(f"File not found for embed: {resource_path}") return resource_path try: with open(full_path, 'rb') as f: data = f.read() mime = guess_mimetype(full_path) b64 = base64.b64encode(data).decode('utf-8') return f"data:{mime};base64,{b64}" except Exception as e: current_app.logger.error(f"Failed to embed {full_path}: {e}") return resource_path def embed_css_resources(base_path, css_content, file_index): """Embed CSS url() resources.""" def repl(match): url = match.group(1).strip("'\"") data_uri = embed_resource(base_path, url, file_index) return f'url({data_uri})' return re.sub(r'url\s*\(\s*["\']?([^"\')]+)["\']?\s*\)', repl, css_content) def resolve_css_imports(css_file_path, file_index): """Recursively resolve @import statements in CSS.""" cssutils.log.setLevel(30) visited = set() def parse_css_recursively(css_path): if css_path in visited: return '' visited.add(css_path) base_path = os.path.dirname(os.path.abspath(css_path)) try: sheet = cssutils.parseFile(css_path) css_text = sheet.cssText.decode('utf-8') for rule in sheet.cssRules.rulesOfType(cssutils.css.CSSImportRule): import_path = rule.href if not import_path or import_path.startswith(('http', 'data:')): continue import_full = resolve_uploaded_path(base_path, import_path, file_index) if import_full and os.path.exists(import_full): imported_css = parse_css_recursively(import_full) css_text = ( imported_css + f'\n/* ===== IMPORTED: {os.path.basename(import_full)} ===== */\n' + css_text ) return css_text except Exception as e: current_app.logger.warning(f"CSS parse error {css_path}: {e}") try: with open(css_path, 'r', encoding='utf-8', errors='ignore') as f: return f.read() except: return f"/* ERROR: Could not read {os.path.basename(css_path)} */" return parse_css_recursively(css_file_path) def make_aio_html(input_file, file_index): """Generate basic AIO HTML with embedded resources.""" base_path = os.path.dirname(os.path.abspath(input_file)) with open(input_file, 'r', encoding='utf-8', errors='ignore') as f: soup = BeautifulSoup(f.read(), 'html.parser') for script in soup.find_all('script', src=True): script['src'] = embed_resource(base_path, script['src'], file_index) for elem in soup.find_all(attrs={'src': True}): elem['src'] = embed_resource(base_path, elem['src'], file_index) for style in soup.find_all('style'): if style.string: style.string = embed_css_resources(base_path, style.string, file_index) return str(soup) def make_aio_html_advanced(input_file, file_index): """Advanced AIO: resolves @import CSS chains + embeds everything.""" base_path = os.path.dirname(os.path.abspath(input_file)) with open(input_file, 'r', encoding='utf-8', errors='ignore') as f: soup = BeautifulSoup(f.read(), 'html.parser') css_files = [] for link in soup.find_all('link', rel='stylesheet'): href = link.get('href') if href and not href.startswith(('http', 'data:')): full_css = resolve_uploaded_path(base_path, href, file_index) if full_css and os.path.exists(full_css): css_files.append(full_css) else: current_app.logger.warning(f"CSS file not found: {href}") all_css_content = '' for css_path in css_files: resolved = resolve_css_imports(css_path, file_index) resolved = embed_css_resources(os.path.dirname(os.path.abspath(css_path)), resolved, file_index) all_css_content += f'\n/* ===== {os.path.basename(css_path)} ===== */\n{resolved}\n' if all_css_content.strip(): style_tag = soup.new_tag('style') style_tag.string = all_css_content if soup.head: soup.head.insert(0, style_tag) else: soup.insert(0, style_tag) for link in soup.find_all('link', rel='stylesheet'): href = link.get('href') if href and not href.startswith(('http', 'data:')): link.decompose() for script in soup.find_all('script', src=True): script['src'] = embed_resource(base_path, script['src'], file_index) for elem in soup.find_all(attrs={'src': True}): elem['src'] = embed_resource(base_path, elem['src'], file_index) for style in soup.find_all('style'): if style.string: style.string = embed_css_resources(base_path, style.string, file_index) return str(soup) @api_bp.route('/upload', methods=['POST']) def upload_files(): return _upload_and_generate(make_aio_html) @api_bp.route('/upload-advanced', methods=['POST']) def upload_advanced(): return _upload_and_generate(make_aio_html_advanced) def _upload_and_generate(generator_func): if 'files' not in request.files: return jsonify({'error': 'No files provided'}), 400 files = request.files.getlist('files') uploaded_paths = [] upload_folder = current_app.config.get('UPLOAD_FOLDER', '/tmp/uploads') os.makedirs(upload_folder, exist_ok=True) for file in files: if not file or file.filename == '': continue if not allowed_file(file.filename): continue filename = secure_filename(os.path.basename(file.filename)) filepath = os.path.join(upload_folder, filename) file.save(filepath) uploaded_paths.append(filepath) if not uploaded_paths: return jsonify({'error': 'No valid files uploaded'}), 400 html_path = next((p for p in uploaded_paths if p.lower().endswith(('.html', '.htm'))), None) if not html_path: return jsonify({'error': 'No HTML file found. Please upload an HTML file.'}), 400 file_index = build_file_index(uploaded_paths) try: aio_content = generator_func(html_path, file_index) return jsonify({ 'success': True, 'message': 'AIO HTML generated successfully', 'aio_html': aio_content, 'size': len(aio_content), 'original': os.path.basename(html_path), 'features': 'CSS @import resolved, all assets embedded' if 'advanced' in generator_func.__name__ else 'Basic embedding' }) except Exception as e: current_app.logger.error(f"AIO generation failed: {e}") return jsonify({'error': f'Generation failed: {str(e)}'}), 500 @api_bp.route('/health', methods=['GET']) def health_check(): return jsonify({ 'status': 'OK', 'version': '2.0.1', 'features': ['Basic AIO', 'Advanced @import CSS', 'Asset embedding (basename fallback)'], 'upload_folder': current_app.config.get('UPLOAD_FOLDER') })