diff --git a/app/__init__.py b/app/__init__.py index d6f8b82..e0e0d3b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -22,7 +22,7 @@ i18n_dict = {} def load_i18n(app): global i18n_dict - i18n_dir = os.path.join(app.root_path, '..', 'assets', 'i18n') + i18n_dir = os.path.join(app.root_path, 'assets', 'i18n') if os.path.exists(i18n_dir): for f in os.listdir(i18n_dir): if f.endswith('.json'): @@ -50,9 +50,9 @@ def _t(key): return key # fallback to the key itself def create_app(): - app = Flask(__name__, static_url_path='/assets', static_folder='../assets') + app = Flask(__name__, static_url_path='/assets', static_folder='assets') app.config['SECRET_KEY'] = 'your-secret-key-change-it-in-production' - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///aio_3d.db' + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///../instance/aio_3d.db' app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'connect_args': {'timeout': 15}} app.config['UPLOAD_FOLDER'] = os.path.abspath(os.path.join(app.root_path, '..', 'uploads')) @@ -74,8 +74,10 @@ def create_app(): from . import models db.create_all() - from .routes import main_bp, auth_bp, admin_bp - from .printer_routes import printer_bp + from .routes.main_routes import main_bp + from .routes.auth_routes import auth_bp + from .routes.admin_routes import admin_bp + from .routes.printer_routes import printer_bp app.register_blueprint(main_bp) app.register_blueprint(auth_bp) app.register_blueprint(admin_bp) diff --git a/app/assets/THIRDPARTY_LICENSES.md b/app/assets/THIRDPARTY_LICENSES.md new file mode 100644 index 0000000..1036664 --- /dev/null +++ b/app/assets/THIRDPARTY_LICENSES.md @@ -0,0 +1,23 @@ +# Third-Party Licenses + +This project uses the following third-party libraries, bundled in the `assets` folder. + +## Frontend Libraries + +1. **Bootstrap v5.3** (CSS/JS) + - License: MIT + - Source: https://getbootstrap.com/ + +2. **Bootstrap Icons** + - License: MIT + - Source: https://icons.getbootstrap.com/ + +3. **Three.js** (including Extra Controls / Loaders) + - License: MIT + - Source: https://threejs.org/ + +4. **GCode Preview** + - License: MIT + - Source: https://github.com/remcoder/gcode-preview + +These libraries and their copyright notices belong to their respective creators. See individual source files or official repos for exact license texts. diff --git a/assets/css/README-LICENSE.txt b/app/assets/css/README-LICENSE.txt similarity index 100% rename from assets/css/README-LICENSE.txt rename to app/assets/css/README-LICENSE.txt diff --git a/assets/css/bootstrap-icons.css b/app/assets/css/bootstrap-icons.css similarity index 100% rename from assets/css/bootstrap-icons.css rename to app/assets/css/bootstrap-icons.css diff --git a/assets/css/bootstrap.min.css b/app/assets/css/bootstrap.min.css similarity index 100% rename from assets/css/bootstrap.min.css rename to app/assets/css/bootstrap.min.css diff --git a/assets/css/bootstrap.min.css.map b/app/assets/css/bootstrap.min.css.map similarity index 100% rename from assets/css/bootstrap.min.css.map rename to app/assets/css/bootstrap.min.css.map diff --git a/assets/css/fonts/bootstrap-icons.woff b/app/assets/css/fonts/bootstrap-icons.woff similarity index 100% rename from assets/css/fonts/bootstrap-icons.woff rename to app/assets/css/fonts/bootstrap-icons.woff diff --git a/assets/css/fonts/bootstrap-icons.woff.1 b/app/assets/css/fonts/bootstrap-icons.woff.1 similarity index 100% rename from assets/css/fonts/bootstrap-icons.woff.1 rename to app/assets/css/fonts/bootstrap-icons.woff.1 diff --git a/assets/css/fonts/bootstrap-icons.woff2 b/app/assets/css/fonts/bootstrap-icons.woff2 similarity index 100% rename from assets/css/fonts/bootstrap-icons.woff2 rename to app/assets/css/fonts/bootstrap-icons.woff2 diff --git a/assets/css/fonts/bootstrap-icons.woff2.1 b/app/assets/css/fonts/bootstrap-icons.woff2.1 similarity index 100% rename from assets/css/fonts/bootstrap-icons.woff2.1 rename to app/assets/css/fonts/bootstrap-icons.woff2.1 diff --git a/assets/i18n/en.json b/app/assets/i18n/en.json similarity index 97% rename from assets/i18n/en.json rename to app/assets/i18n/en.json index a728871..2744666 100644 --- a/assets/i18n/en.json +++ b/app/assets/i18n/en.json @@ -42,7 +42,7 @@ "GCode Preview": "GCode Preview", "Delete": "Delete", "No files uploaded yet.": "No files uploaded yet.", - "Drag & Drop STL file here or Click to Select": "Drag & Drop STL file here or Click to Select", + "Drag & Drop STL files here or Click to Select": "Drag & Drop STL files here or Click to Select", "Uploading...": "Uploading...", "Simplifying": "Simplifying", "Simplifying...": "Simplifying...", diff --git a/assets/i18n/zh-cn.json b/app/assets/i18n/zh-cn.json similarity index 98% rename from assets/i18n/zh-cn.json rename to app/assets/i18n/zh-cn.json index 1d055e1..852710b 100644 --- a/assets/i18n/zh-cn.json +++ b/app/assets/i18n/zh-cn.json @@ -38,7 +38,7 @@ "GCode Preview": "GCode 预览", "Delete": "删除", "No files uploaded yet.": "还没有上传文件。", - "Drag & Drop STL file here or Click to Select": "将 STL 文件拖放到此处或点击选择", + "Drag & Drop STL files here or Click to Select": "将 STL 文件拖放到此处或点击选择", "Uploading...": "上传中...", "Simplifying": "简化中", "Simplifying...": "正在简化...", diff --git a/assets/js/OrbitControls.js b/app/assets/js/OrbitControls.js similarity index 100% rename from assets/js/OrbitControls.js rename to app/assets/js/OrbitControls.js diff --git a/assets/js/OrbitControls.js.1 b/app/assets/js/OrbitControls.js.1 similarity index 100% rename from assets/js/OrbitControls.js.1 rename to app/assets/js/OrbitControls.js.1 diff --git a/assets/js/README-LICENSE.txt b/app/assets/js/README-LICENSE.txt similarity index 100% rename from assets/js/README-LICENSE.txt rename to app/assets/js/README-LICENSE.txt diff --git a/assets/js/STLLoader.js b/app/assets/js/STLLoader.js similarity index 100% rename from assets/js/STLLoader.js rename to app/assets/js/STLLoader.js diff --git a/assets/js/STLLoader.js.1 b/app/assets/js/STLLoader.js.1 similarity index 100% rename from assets/js/STLLoader.js.1 rename to app/assets/js/STLLoader.js.1 diff --git a/assets/js/TransformControls.js b/app/assets/js/TransformControls.js similarity index 100% rename from assets/js/TransformControls.js rename to app/assets/js/TransformControls.js diff --git a/assets/js/bootstrap.bundle.min.js b/app/assets/js/bootstrap.bundle.min.js similarity index 100% rename from assets/js/bootstrap.bundle.min.js rename to app/assets/js/bootstrap.bundle.min.js diff --git a/assets/js/gcode-preview.min.js b/app/assets/js/gcode-preview.min.js similarity index 100% rename from assets/js/gcode-preview.min.js rename to app/assets/js/gcode-preview.min.js diff --git a/assets/js/three.min.js b/app/assets/js/three.min.js similarity index 100% rename from assets/js/three.min.js rename to app/assets/js/three.min.js diff --git a/assets/js/three.min.js.1 b/app/assets/js/three.min.js.1 similarity index 100% rename from assets/js/three.min.js.1 rename to app/assets/js/three.min.js.1 diff --git a/app/routes/admin_routes.py b/app/routes/admin_routes.py new file mode 100644 index 0000000..eaa31e6 --- /dev/null +++ b/app/routes/admin_routes.py @@ -0,0 +1,131 @@ +import json +import trimesh +import uuid +import os +import configparser +from datetime import datetime +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, session, make_response, send_file, abort, jsonify +from flask_login import login_user, logout_user, login_required, current_user +from werkzeug.security import generate_password_hash, check_password_hash +from werkzeug.utils import secure_filename +from app.models import db, User, PrintFile, SystemConfig +from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task +from app import i18n_dict +# import trimesh.repair +from app.utils.stl_simplifier import simplify_stl + + +main_bp = Blueprint('main', __name__) + + +auth_bp = Blueprint('auth', __name__, url_prefix='/auth') +admin_bp = Blueprint('admin', __name__, url_prefix='/admin') + +# Guest User Middleware +@admin_bp.before_request +def require_admin(): + if not current_user.is_authenticated or not current_user.is_admin: + flash('Admin access required', 'danger') + return redirect(url_for('main.index')) + +@admin_bp.route('/settings', methods=['GET', 'POST']) +def settings(): + if request.method == 'POST': + # concurrent_slices = request.form.get('concurrent_slices') + offset_x = request.form.get('offset_x', '0') + offset_y = request.form.get('offset_y', '0') + proxy_skip_size_mb = request.form.get('proxy_skip_size_mb', '5.0') + default_infill = request.form.get('default_infill', '20') + default_support = request.form.get('default_support', 'false') + default_support_pattern = request.form.get('default_support_pattern', 'tree') + default_quality = request.form.get('default_quality', 'base_global_standard.inst.cfg') + + # update or create config entries + config_items = [ + ('offset_x', offset_x), + ('offset_y', offset_y), + ('proxy_skip_size_mb', proxy_skip_size_mb), + ('default_infill', default_infill), + ('default_support', default_support), + ('default_support_pattern', default_support_pattern), + ('default_quality', default_quality) + ] + for key, val in config_items: + conf = SystemConfig.query.filter_by(key=key).first() + if not conf: + conf = SystemConfig(key=key) + db.session.add(conf) + conf.value = val + db.session.commit() + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True}), 200 + flash('Settings updated successfully', 'success') + return redirect(url_for('admin.settings')) + + configs = {c.key: c.value for c in SystemConfig.query.all()} + presets = get_quality_presets() + return render_template('admin/settings.html', configs=configs, presets=presets) + +@admin_bp.route('/users') +def users(): + all_users = User.query.order_by(User.created_at.desc()).all() + return render_template('admin/users.html', users=all_users) + +@admin_bp.route('/user//delete', methods=['POST']) +def delete_user(user_id): + user = User.query.get_or_404(user_id) + if user.id == current_user.id: + flash('You cannot delete yourself.', 'danger') + return redirect(url_for('admin.users')) + + print_files = PrintFile.query.filter_by(user_id=user.id).all() + for print_file in print_files: + stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename) + gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode' + gcode_path = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename) + proxy_path = stl_path + '.proxy.stl' + + if os.path.exists(stl_path): + try: os.remove(stl_path) + except: pass + if os.path.exists(proxy_path): + try: os.remove(proxy_path) + except: pass + if os.path.exists(gcode_path): + try: os.remove(gcode_path) + except: pass + + db.session.delete(print_file) + + db.session.delete(user) + db.session.commit() + flash(f'User {user.username} and all their files have been deleted.', 'success') + return redirect(url_for('admin.users')) + +def get_bed_dimensions(): + try: + path = os.path.join(current_app.root_path, '..', 'print_config', 'printers', 'creality_ender3v3se.def.json') + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + w = data['overrides']['machine_width']['default_value'] + h = data['overrides']['machine_depth']['default_value'] + hd = data['overrides']['machine_height']['default_value'] + return w, h, hd + except: + return 200, 200, 200 + +def get_quality_presets(): + try: + + path = os.path.join(current_app.root_path, '..', 'print_config', 'quality', 'creality', 'presets') + files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')] + presets = [] + for f in files: + # name = f.replace('.inst.cfg', '').replace('base_', '').replace('_', ' ') + name = f.replace('.inst.cfg', '') + presets.append((f, name)) + presets.sort(key=lambda x: x[1]) + return presets + except: + return [] + diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py new file mode 100644 index 0000000..7974ff5 --- /dev/null +++ b/app/routes/auth_routes.py @@ -0,0 +1,46 @@ +import json +import trimesh +import uuid +import os +import configparser +from datetime import datetime +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, session, make_response, send_file, abort, jsonify +from flask_login import login_user, logout_user, login_required, current_user +from werkzeug.security import generate_password_hash, check_password_hash +from werkzeug.utils import secure_filename +from app.models import db, User, PrintFile, SystemConfig +from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task +from app import i18n_dict +# import trimesh.repair +from app.utils.stl_simplifier import simplify_stl + + +main_bp = Blueprint('main', __name__) + + +auth_bp = Blueprint('auth', __name__, url_prefix='/auth') +admin_bp = Blueprint('admin', __name__, url_prefix='/admin') + +# Guest User Middleware +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + user = User.query.filter_by(username=username, is_guest=False).first() + if user and check_password_hash(user.password_hash, password): + login_user(user) + return redirect(url_for('main.index')) + flash('Invalid username or password', 'danger') + return render_template('auth/login.html') + +@auth_bp.route('/logout') +@login_required +def logout(): + logout_user() + response = make_response(redirect(url_for('main.index'))) + response.delete_cookie('guest_id') # Optionally clear guest cookie + return response + +# --- Admin Routes --- + diff --git a/app/routes.py b/app/routes/main_routes.py similarity index 65% rename from app/routes.py rename to app/routes/main_routes.py index caf57a9..297b511 100644 --- a/app/routes.py +++ b/app/routes/main_routes.py @@ -8,11 +8,11 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_login import login_user, logout_user, login_required, current_user from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.utils import secure_filename -from .models import db, User, PrintFile, SystemConfig -from .tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task +from app.models import db, User, PrintFile, SystemConfig +from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task from app import i18n_dict # import trimesh.repair -from stl_simplifier import simplify_stl +from app.utils.stl_simplifier import simplify_stl main_bp = Blueprint('main', __name__) @@ -49,7 +49,7 @@ def set_guest_cookie(response): @main_bp.route('/') def index(): - return render_template('index.html') + return render_template('slice/index.html') @main_bp.route('/set_language/') def set_language(lang): @@ -66,72 +66,55 @@ def set_language(lang): def files(): if request.method == 'POST': if 'file' not in request.files: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'error': 'No file part'}), 400 flash('No file part', 'danger') return redirect(request.url) - file = request.files['file'] - if file.filename == '': - flash('No selected file', 'danger') - return redirect(request.url) - if file and file.filename.lower().endswith('.stl'): - original_filename = file.filename # Do not use secure_filename to keep Chinese characters - ext = os.path.splitext(original_filename)[1].lower() - if not ext: - ext = '.stl' - timestamp = datetime.now().strftime('%Y%m%d%H%M%S') - unique_filename = f"{timestamp}_{uuid.uuid4().hex}{ext}" - filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename) - file.save(filepath) - - # try: - # mesh = trimesh.load(filepath) - # # Check for overlapping faces or if the mesh is not watertight - # # which can cause issues in CuraEngine - # needs_repair = False - # if not mesh.is_watertight or (len(mesh.faces) > 0 and len(mesh.intersecting_faces[0]) > 0): - # # needs_repair = True - # pass + + uploaded_files = request.files.getlist('file') + success_count = 0 + + for file in uploaded_files: + if file.filename == '': + continue + if file and file.filename.lower().endswith('.stl'): + original_filename = file.filename # Do not use secure_filename to keep Chinese characters + ext = os.path.splitext(original_filename)[1].lower() + if not ext: + ext = '.stl' + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + unique_filename = f"{timestamp}_{uuid.uuid4().hex}_{success_count}{ext}" + filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename) + file.save(filepath) - # if needs_repair: - # # Attempt automatic repair - - # trimesh.repair.fix_normals(mesh) - # trimesh.repair.fix_inversion(mesh) - # trimesh.repair.fix_winding(mesh) - # trimesh.repair.fill_holes(mesh) - - # # Re-check after repair - # if not mesh.is_watertight or (len(mesh.faces) > 0 and len(mesh.intersecting_faces[0]) > 0): - # if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - # return jsonify({'success': False, 'error': 'Mesh has overlapping faces or is not watertight, and automatic repair failed! Please repair the model manually before slicing.'}), 400 - # else: - # flash('Mesh has overlapping faces or is not watertight, and automatic repair failed! Please repair the model manually.', 'danger') - # os.remove(filepath) - # return redirect(request.url) - # else: - # # Repair succeeded, rewrite file - # mesh.export(filepath) - # except Exception as e: - # pass + print_file = PrintFile( + filename=unique_filename, + original_filename=original_filename, + file_type='stl', + user_id=current_user.id, + status='simplifying' # Set to simplifying while proxy is generated + ) + db.session.add(print_file) + db.session.commit() + + # Start background simplification + simplify_stl_task(print_file.id, filepath) + success_count += 1 + + if success_count > 0: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True, 'count': success_count}) + flash(f'{success_count} file(s) uploaded successfully!', 'success') + else: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'error': 'No valid files uploaded'}), 400 + flash('No valid files uploaded', 'danger') - print_file = PrintFile( - filename=unique_filename, - original_filename=original_filename, - file_type='stl', - user_id=current_user.id, - status='simplifying' # Set to simplifying while proxy is generated - ) - db.session.add(print_file) - db.session.commit() - - # Start background simplification - simplify_stl_task(print_file.id, filepath) - - flash('File uploaded successfully!', 'success') - return redirect(url_for('main.files')) + return redirect(url_for('main.files')) # Order by newest first user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all() - return render_template('files.html', files=user_files) + return render_template('slice/files.html', files=user_files) @main_bp.route('/api/files_status') @login_required @@ -182,7 +165,7 @@ def preview_gcode(file_id): configs = {c.key: c.value for c in SystemConfig.query.all()} offset_x = float(configs.get('offset_x', '0.0')) offset_y = float(configs.get('offset_y', '0.0')) - return render_template('gcode_preview.html', file=print_file, content=content, line_count=line_count, machine_width=w, machine_depth=h, machine_height=hd, offset_x=offset_x, offset_y=offset_y) + return render_template('slice/gcode_preview.html', file=print_file, content=content, line_count=line_count, machine_width=w, machine_depth=h, machine_height=hd, offset_x=offset_x, offset_y=offset_y) @main_bp.route('/delete_file/', methods=['POST']) @login_required @@ -210,106 +193,6 @@ def delete_file(file_id): # --- Auth Routes --- -@auth_bp.route('/login', methods=['GET', 'POST']) -def login(): - if request.method == 'POST': - username = request.form.get('username') - password = request.form.get('password') - user = User.query.filter_by(username=username, is_guest=False).first() - if user and check_password_hash(user.password_hash, password): - login_user(user) - return redirect(url_for('main.index')) - flash('Invalid username or password', 'danger') - return render_template('login.html') - -@auth_bp.route('/logout') -@login_required -def logout(): - logout_user() - response = make_response(redirect(url_for('main.index'))) - response.delete_cookie('guest_id') # Optionally clear guest cookie - return response - -# --- Admin Routes --- - -@admin_bp.before_request -def require_admin(): - if not current_user.is_authenticated or not current_user.is_admin: - flash('Admin access required', 'danger') - return redirect(url_for('main.index')) - -@admin_bp.route('/settings', methods=['GET', 'POST']) -def settings(): - if request.method == 'POST': - # concurrent_slices = request.form.get('concurrent_slices') - offset_x = request.form.get('offset_x', '0') - offset_y = request.form.get('offset_y', '0') - proxy_skip_size_mb = request.form.get('proxy_skip_size_mb', '5.0') - default_infill = request.form.get('default_infill', '20') - default_support = request.form.get('default_support', 'false') - default_support_pattern = request.form.get('default_support_pattern', 'tree') - default_quality = request.form.get('default_quality', 'base_global_standard.inst.cfg') - - # update or create config entries - config_items = [ - ('offset_x', offset_x), - ('offset_y', offset_y), - ('proxy_skip_size_mb', proxy_skip_size_mb), - ('default_infill', default_infill), - ('default_support', default_support), - ('default_support_pattern', default_support_pattern), - ('default_quality', default_quality) - ] - for key, val in config_items: - conf = SystemConfig.query.filter_by(key=key).first() - if not conf: - conf = SystemConfig(key=key) - db.session.add(conf) - conf.value = val - db.session.commit() - flash('Settings updated successfully', 'success') - return redirect(url_for('admin.settings')) - - configs = {c.key: c.value for c in SystemConfig.query.all()} - presets = get_quality_presets() - return render_template('admin_settings.html', configs=configs, presets=presets) - -@admin_bp.route('/users') -def users(): - all_users = User.query.order_by(User.created_at.desc()).all() - return render_template('admin_users.html', users=all_users) - -@admin_bp.route('/user//delete', methods=['POST']) -def delete_user(user_id): - user = User.query.get_or_404(user_id) - if user.id == current_user.id: - flash('You cannot delete yourself.', 'danger') - return redirect(url_for('admin.users')) - - print_files = PrintFile.query.filter_by(user_id=user.id).all() - for print_file in print_files: - stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename) - gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode' - gcode_path = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename) - proxy_path = stl_path + '.proxy.stl' - - if os.path.exists(stl_path): - try: os.remove(stl_path) - except: pass - if os.path.exists(proxy_path): - try: os.remove(proxy_path) - except: pass - if os.path.exists(gcode_path): - try: os.remove(gcode_path) - except: pass - - db.session.delete(print_file) - - db.session.delete(user) - db.session.commit() - flash(f'User {user.username} and all their files have been deleted.', 'success') - return redirect(url_for('admin.users')) - def get_bed_dimensions(): try: path = os.path.join(current_app.root_path, '..', 'print_config', 'printers', 'creality_ender3v3se.def.json') @@ -354,7 +237,7 @@ def plater(): user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all() models = [{'id': f.id, 'name': f.original_filename, 'status': f.status, 'url': url_for('main.serve_proxy_file', file_id=f.id), 'transform_matrix': f.transform_matrix} for f in user_files] - return render_template('plater.html', w=w, h=h, hd=hd, presets=presets, last_quality=default_quality, models=models, offset_x=offset_x, offset_y=offset_y, default_infill=default_infill, default_support=default_support, default_support_pattern=default_support_pattern) + return render_template('slice/plater.html', w=w, h=h, hd=hd, presets=presets, last_quality=default_quality, models=models, offset_x=offset_x, offset_y=offset_y, default_infill=default_infill, default_support=default_support, default_support_pattern=default_support_pattern) @main_bp.route('/file/') @login_required diff --git a/app/printer_routes.py b/app/routes/printer_routes.py similarity index 51% rename from app/printer_routes.py rename to app/routes/printer_routes.py index 6c10a6d..9a06872 100644 --- a/app/printer_routes.py +++ b/app/routes/printer_routes.py @@ -1,8 +1,12 @@ from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app, Response from flask_login import login_required, current_user +from websockets.sync.client import connect as ws_connect +import websockets.exceptions +import threading import requests -from .models import SystemConfig, db -from .octoprint_client import OctoPrintClient +from urllib.parse import urlparse +from app.models import SystemConfig, db +from app.utils.octoprint_client import OctoPrintClient printer_bp = Blueprint('printer', __name__, url_prefix='/printer') @@ -118,6 +122,8 @@ def octo_config(): conf_key.value = apikey db.session.commit() + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': True}), 200 flash("OctoPrint settings updated", "success") return redirect(url_for('printer.octo_config')) @@ -135,6 +141,8 @@ def octo_embed(): embed_url = url_for('printer.octo_proxy') if url and url.value else None return render_template('printer/octo_embed.html', embed_url=embed_url) +@printer_bp.route('/proxy', defaults={'path': ''}, websocket=True) +@printer_bp.route('/proxy/', websocket=True) @printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) @printer_bp.route('/proxy/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) @login_required @@ -146,29 +154,153 @@ def octo_proxy(path): if not url_config or not url_config.value: return "OctoPrint URL not configured", 404 - from urllib.parse import urlparse base_url = url_config.value.rstrip('/') + + # print("----- REQUEST HEADERS -----") + # for k, v in request.headers: + # print(f"{k}: {v}") + # print("----- END HEADERS -----") + + # --- WebSocket Proxy Logic --- + if request.headers.get('Upgrade', '').lower() == 'websocket': + from flask_sock import Server, ConnectionClosed + + # Check if environment supports WebSockets + try: + ws = Server(request.environ) + except Exception as e: + env_keys = sorted(list(request.environ.keys())) + print(f"FAILED. ENV KEYS: {env_keys}") + return f"WebSocket Upgrade Failed: {str(e)}", 400 + + def handle_ws(): + if base_url.startswith('https://'): + ws_base = base_url.replace('https://', 'wss://', 1) + else: + ws_base = base_url.replace('http://', 'ws://', 1) + + target_url = f"{ws_base}/{path}" + if request.query_string: + target_url = f"{target_url}?{request.query_string.decode('utf-8')}" + print(f"WS Proxy Query String: {request.query_string.decode('utf-8')}") + # Copy most headers, especially Origin to pass CORS + ws_headers = {} + for k, v in request.headers: + if k.lower() not in ['host', 'connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'content-length']: + ws_headers[k] = v + + # Match Tornado's expectations for Origin to avoid 400 Bad Request + parsed_base = urlparse(base_url) + ws_headers['Host'] = parsed_base.netloc + if 'Origin' in request.headers: + ws_headers['Origin'] = base_url + if 'Referer' in request.headers: + ws_headers['Referer'] = f"{base_url}/{path}" + + ws_headers['X-Real-IP'] = request.remote_addr + forwarded_for = request.headers.get('X-Forwarded-For', '') + ws_headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr + ws_headers['X-Forwarded-Proto'] = request.scheme + ws_headers['X-Forwarded-Host'] = request.host + ws_headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80')) + ws_headers['X-Script-Name'] = '/printer/proxy' + + print(f"WS Proxy Connecting to: {target_url}") + try: + remote_ws = ws_connect(target_url, additional_headers=ws_headers) + print("WS Proxy connected to remote.") + except Exception as e: + import traceback + traceback.print_exc() + print(f"Remote WS Connection Error: {e}") + ws.close(1011, str(e)) + return + + def recv_loop(): + print("WS recv_loop started") + try: + for message in remote_ws: + ws.send(message) + except Exception as e: + print("WS recv error:", e) + finally: + try: remote_ws.close() + except: pass + try: ws.close() + except: pass + print("WS recv_loop ended") + + t = threading.Thread(target=recv_loop) + t.daemon = True + t.start() + + print("WS Entering client receive loop") + try: + while True: + data = ws.receive() + if data is None: + break + remote_ws.send(data) + except Exception as e: + print("WS send error:", e) + finally: + try: remote_ws.close() + except: pass + try: ws.close() + except: pass + print("WS client loop ended") + + try: + handle_ws() + except ConnectionClosed: + print("WS Connection Closed") + except Exception as e: + print("WS Error in handle_ws:", e) + finally: + try: ws.close() + except: pass + + class WebSocketResponse(Response): + def __call__(self, *args, **kwargs): + print("WS Response __call__") + if getattr(ws, 'mode', 'werkzeug') == 'werkzeug': + return super().__call__(*args, **kwargs) + return [] + + return WebSocketResponse() + + # --- Standard HTTP Proxy Logic --- + # from urllib.parse import urlparse target_url = f"{base_url}/{path}" if request.query_string: target_url = f"{target_url}?{request.query_string.decode('utf-8')}" - # Build headers for reverse proxy, masking origin/referer to avoid CSRF + # Build headers for reverse proxy based on nginx config reference parsed_base = urlparse(base_url) headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length']} - headers['Host'] = parsed_base.netloc - if 'Origin' in headers: - headers['Origin'] = base_url - if 'Referer' in headers: - headers['Referer'] = f"{base_url}/{path}" - - headers['X-Forwarded-For'] = request.remote_addr + # NGINX equivalent proxy headers + headers['Host'] = request.host + headers['X-Real-IP'] = request.remote_addr + headers['X-Real-Port'] = str(request.environ.get('REMOTE_PORT', '')) + + forwarded_for = request.headers.get('X-Forwarded-For', '') + headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr + + headers['X-Forwarded-Protocol'] = request.scheme + headers['X-Script-Name'] = "/printer/proxy" headers['X-Forwarded-Host'] = request.host - headers['X-Forwarded-Proto'] = request.scheme - headers['X-Script-Name'] = '/printer/proxy' + headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80')) + headers['REMOTE-HOST'] = request.remote_addr + if request.headers.get('Upgrade'): + headers['Upgrade'] = request.headers.get('Upgrade') + if request.headers.get('Connection'): + headers['Connection'] = request.headers.get('Connection') + try: + # proxy_connect_timeout 60s, proxy_read_timeout 600s resp = requests.request( method=request.method, url=target_url, @@ -176,7 +308,8 @@ def octo_proxy(path): data=request.get_data(), cookies=request.cookies, allow_redirects=False, - stream=True + stream=True, + timeout=(60, 600) ) except requests.exceptions.RequestException as e: return f"Proxy connection error: {str(e)}", 502 @@ -191,3 +324,4 @@ def octo_proxy(path): yield chunk return Response(generate(), resp.status_code, response_headers) + diff --git a/app/templates/admin_settings.html b/app/templates/admin/settings.html similarity index 80% rename from app/templates/admin_settings.html rename to app/templates/admin/settings.html index 61b7e23..0a8e887 100644 --- a/app/templates/admin_settings.html +++ b/app/templates/admin/settings.html @@ -9,7 +9,7 @@
{{ _('CuraEngine Configurations') }}

-
+
@@ -70,8 +70,44 @@
- +
+ + {% endblock %} diff --git a/app/templates/admin_users.html b/app/templates/admin/users.html similarity index 89% rename from app/templates/admin_users.html rename to app/templates/admin/users.html index d603345..44a28ae 100644 --- a/app/templates/admin_users.html +++ b/app/templates/admin/users.html @@ -32,7 +32,7 @@ {{ user.created_at.strftime('%Y-%m-%d %H:%M') }} -
+
diff --git a/app/templates/login.html b/app/templates/auth/login.html similarity index 100% rename from app/templates/login.html rename to app/templates/auth/login.html diff --git a/app/templates/base.html b/app/templates/base.html index 84a155a..aad6b06 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -21,8 +21,18 @@ .card { border: none; border-radius: 0.75rem; overflow: hidden; } .card-header { border-bottom: 1px solid rgba(0,0,0,.05); background-color: transparent; } - .toast-container { margin-bottom: 20px; margin-right: 20px; } - .toast { border-radius: 0.5rem; box-shadow: 0 0.5rem 1rem rgba(0,0,0,.15); opacity: 0.95; } + .toast-container { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 1055; width: auto; max-width: 90%; pointer-events: none; } + .toast { border-radius: 0.5rem; box-shadow: 0 0.5rem 1rem rgba(0,0,0,.25); opacity: 1 !important; pointer-events: auto; } + + /* 页面切换动画 Page Transition */ + @keyframes pageFadeInSlide { + 0% { opacity: 0; transform: translateY(10px); } + 100% { opacity: 1; transform: translateY(0); } + } + main { animation: pageFadeInSlide 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; } + + /* 提升 Accordion 折叠栏动画更平滑 */ + .collapsing { transition: height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important; } @@ -150,12 +160,12 @@
-
+
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} {% set toast_class = 'bg-success text-white' if category == 'success' else 'bg-danger text-white' if category == 'danger' else 'bg-warning text-dark' if category == 'warning' else 'bg-primary text-white' %} -