修复偏移问题,修复代理问题

This commit is contained in:
2026-04-15 00:22:12 +08:00
parent 570af7c225
commit f0f9d658eb
33 changed files with 1621 additions and 1763 deletions

View File

@@ -21,6 +21,12 @@ main_bp = Blueprint('main', __name__)
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def get_gcode_dir():
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
if conf and conf.value and os.path.exists(conf.value):
return conf.value
return current_app.config['UPLOAD_FOLDER']
# Guest User Middleware
@admin_bp.before_request
def require_admin():
@@ -39,6 +45,7 @@ def settings():
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')
gcode_upload_folder = request.form.get('gcode_upload_folder', '').strip()
# update or create config entries
config_items = [
@@ -48,7 +55,12 @@ def settings():
('default_infill', default_infill),
('default_support', default_support),
('default_support_pattern', default_support_pattern),
('default_quality', default_quality)
('default_quality', default_quality),
('gcode_upload_folder', gcode_upload_folder),
('default_guest_stl_quota_mb', request.form.get('default_guest_stl_quota_mb', '0')),
('default_guest_gcode_quota_mb', request.form.get('default_guest_gcode_quota_mb', '0')),
('default_user_stl_quota_mb', request.form.get('default_user_stl_quota_mb', '0')),
('default_user_gcode_quota_mb', request.form.get('default_user_gcode_quota_mb', '0'))
]
for key, val in config_items:
conf = SystemConfig.query.filter_by(key=key).first()
@@ -69,7 +81,73 @@ def settings():
@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)
user_quotas = {}
for u in all_users:
sq = SystemConfig.query.filter_by(key=f"user_{u.id}_stl_quota_mb").first()
gq = SystemConfig.query.filter_by(key=f"user_{u.id}_gcode_quota_mb").first()
user_quotas[u.id] = {
'stl': sq.value if sq else '0',
'gcode': gq.value if gq else '0'
}
return render_template('admin/users.html', users=all_users, user_quotas=user_quotas)
@admin_bp.route('/user/add', methods=['POST'])
def add_user():
username = request.form.get('username')
password = request.form.get('password')
is_admin = request.form.get('is_admin') == 'on'
if User.query.filter_by(username=username).first():
flash("Username already exists", "danger")
return redirect(url_for('admin.users'))
u = User(username=username, is_guest=False, is_admin=is_admin)
u.password_hash = generate_password_hash(password)
db.session.add(u)
db.session.commit()
# Save quotas
stl_quota = request.form.get('stl_quota_mb', '0')
gcode_quota = request.form.get('gcode_quota_mb', '0')
if stl_quota != '0':
db.session.add(SystemConfig(key=f"user_{u.id}_stl_quota_mb", value=stl_quota))
if gcode_quota != '0':
db.session.add(SystemConfig(key=f"user_{u.id}_gcode_quota_mb", value=gcode_quota))
db.session.commit()
flash("User created.", "success")
return redirect(url_for('admin.users'))
@admin_bp.route('/user/<int:user_id>/quota', methods=['POST'])
def update_quota(user_id):
stl_quota = request.form.get('stl_quota_mb', '0')
gcode_quota = request.form.get('gcode_quota_mb', '0')
def set_conf(k, v):
c = SystemConfig.query.filter_by(key=k).first()
if not c:
c = SystemConfig(key=k)
db.session.add(c)
c.value = str(v)
set_conf(f"user_{user_id}_stl_quota_mb", stl_quota)
set_conf(f"user_{user_id}_gcode_quota_mb", gcode_quota)
db.session.commit()
flash("Quotas updated.", "success")
return redirect(url_for('admin.users'))
@admin_bp.route('/user/<int:user_id>/password', methods=['POST'])
def reset_password(user_id):
user = User.query.get_or_404(user_id)
if user.is_guest:
flash("Cannot set password for guests.", "danger")
return redirect(url_for('admin.users'))
pwd = request.form.get('password')
user.password_hash = generate_password_hash(pwd)
db.session.commit()
flash("Password updated.", "success")
return redirect(url_for('admin.users'))
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
def delete_user(user_id):
@@ -82,7 +160,7 @@ def delete_user(user_id):
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)
gcode_path = os.path.join(get_gcode_dir(), gcode_filename)
proxy_path = stl_path + '.proxy.stl'
if os.path.exists(stl_path):

View File

@@ -13,6 +13,7 @@ from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_t
from app import i18n_dict
# import trimesh.repair
from app.utils.stl_simplifier import simplify_stl
from app.routes.admin_routes import get_gcode_dir
main_bp = Blueprint('main', __name__)
@@ -21,6 +22,53 @@ main_bp = Blueprint('main', __name__)
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def get_quota_info(user, file_type):
# Returns (quota_mb, current_size_bytes)
if user.is_admin:
quota_mb = 0.0
else:
conf = SystemConfig.query.filter_by(key=f"user_{user.id}_{file_type}_quota_mb").first()
quota_mb = float(conf.value) if conf else 0.0
if quota_mb == 0.0:
if user.is_guest:
def_conf = SystemConfig.query.filter_by(key=f"default_guest_{file_type}_quota_mb").first()
else:
def_conf = SystemConfig.query.filter_by(key=f"default_user_{file_type}_quota_mb").first()
quota_mb = float(def_conf.value) if def_conf else 0.0
user_files = PrintFile.query.filter_by(user_id=user.id).all()
current_size = 0
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'uploads')
gcode_dir = get_gcode_dir()
for pf in user_files:
if file_type == 'stl' and not pf.original_filename.lower().endswith(('.gcode', '.gco', '.g')):
path = os.path.join(upload_dir, pf.filename)
if os.path.exists(path):
current_size += os.path.getsize(path)
elif file_type == 'gcode':
g_filename = pf.filename.rsplit('.', 1)[0] + '.gcode'
path = os.path.join(gcode_dir, g_filename)
if os.path.exists(path):
current_size += os.path.getsize(path)
else:
p2 = os.path.join(upload_dir, g_filename)
if os.path.exists(p2): current_size += os.path.getsize(p2)
return quota_mb, current_size
def check_quota(user, file_type, size_bytes):
if user.is_admin:
return True
quota_mb, current_size = get_quota_info(user, file_type)
if quota_mb <= 0.0:
return True
if current_size + size_bytes > quota_mb * 1024 * 1024:
return False
return True
# Guest User Middleware
@main_bp.before_app_request
def assign_guest_cookie():
@@ -48,8 +96,68 @@ def set_guest_cookie(response):
# --- Main Routes ---
@main_bp.route('/')
@login_required
def index():
return render_template('slice/index.html')
user_files = PrintFile.query.filter((PrintFile.user_id == current_user.id) | (current_user.is_admin)).all() if current_user.is_admin else PrintFile.query.filter_by(user_id=current_user.id).all()
stl_count = 0
stl_size = 0
gcode_count = 0
gcode_size = 0
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'uploads')
gcode_dir = get_gcode_dir()
for f in user_files:
is_external_gcode = f.original_filename.lower().endswith(('.gcode', '.gco', '.g'))
if is_external_gcode:
gcode_count += 1
gcode_path = os.path.join(gcode_dir, f.filename.replace('.stl', '.gcode'))
if os.path.exists(gcode_path):
gcode_size += os.path.getsize(gcode_path)
else:
stl_count += 1
stl_path = os.path.join(upload_dir, f.filename)
if os.path.exists(stl_path):
stl_size += os.path.getsize(stl_path)
if f.status == 'sliced':
gcode_count += 1
gcode_filename = f.filename.rsplit('.', 1)[0] + '.gcode'
gcode_path = os.path.join(gcode_dir, gcode_filename)
if os.path.exists(gcode_path):
gcode_size += os.path.getsize(gcode_path)
else:
gcode_fallback = os.path.join(upload_dir, gcode_filename)
if os.path.exists(gcode_fallback):
gcode_size += os.path.getsize(gcode_fallback)
def format_size(size_bytes):
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.2f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.2f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
stl_quota_mb, stl_used_bytes = get_quota_info(current_user, 'stl')
gcode_quota_mb, gcode_used_bytes = get_quota_info(current_user, 'gcode')
return render_template('slice/index.html',
stl_count=stl_count,
stl_size_str=format_size(stl_size),
gcode_count=gcode_count,
gcode_size_str=format_size(gcode_size),
stl_used_bytes=stl_used_bytes,
stl_quota_mb=stl_quota_mb,
gcode_used_bytes=gcode_used_bytes,
gcode_quota_mb=gcode_quota_mb,
format_size=format_size
)
@main_bp.route('/set_language/<lang>')
def set_language(lang):
@@ -78,6 +186,15 @@ def files():
if file.filename == '':
continue
if file and file.filename.lower().endswith('.stl'):
file.seek(0, os.SEEK_END)
size_bytes = file.tell()
file.seek(0, os.SEEK_SET)
if not check_quota(current_user, 'stl', size_bytes):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': 'STL Storage Quota Exceeded'}), 400
flash('STL Storage Quota Exceeded', 'danger')
continue
original_filename = file.filename # Do not use secure_filename to keep Chinese characters
ext = os.path.splitext(original_filename)[1].lower()
if not ext:
@@ -223,6 +340,9 @@ def get_quality_presets():
@main_bp.route('/plater')
@login_required
def plater():
quota_mb, current_size = get_quota_info(current_user, 'gcode')
quota_exceeded = (quota_mb > 0 and current_size >= quota_mb * 1024 * 1024)
w, h, hd = get_bed_dimensions()
presets = get_quality_presets()
@@ -237,7 +357,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('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)
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, quota_exceeded=quota_exceeded)
@main_bp.route('/file/<int:file_id>')
@login_required
@@ -263,6 +383,9 @@ def serve_proxy_file(file_id):
@main_bp.route('/api/merge_and_slice', methods=['POST'])
@login_required
def merge_and_slice():
quota_mb, current_size = get_quota_info(current_user, 'gcode')
if quota_mb > 0 and current_size >= quota_mb * 1024 * 1024:
return jsonify({'success': False, 'error': 'GCode Storage Quota Exceeded. Please delete some files first.'})
data = request.json
pieces = data.get('pieces', [])
quality = data.get('quality', 'base_global_standard.inst.cfg')

View File

@@ -7,6 +7,8 @@ import requests
from urllib.parse import urlparse
from app.models import SystemConfig, db
from app.utils.octoprint_client import OctoPrintClient
from app.models import PrintFile
import os
printer_bp = Blueprint('printer', __name__, url_prefix='/printer')
@@ -35,20 +37,69 @@ def status():
return render_template('printer/status.html', status=status_data, job=job_data, error=error)
def get_gcode_dir():
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
if conf and conf.value and os.path.exists(conf.value):
return conf.value
return current_app.config['UPLOAD_FOLDER']
@printer_bp.route('/prepare')
@login_required
def prepare():
client = get_octo_client()
from app.models import PrintFile
import os
# Query only the sliced GCode files belonging to the current user
user_files = PrintFile.query.filter_by(user_id=current_user.id, status='sliced').order_by(PrintFile.created_at.desc()).all()
files = []
error = None
gcode_dir = get_gcode_dir()
client = get_octo_client()
octo_files_dict = {}
if client:
try:
res = client.get_files()
files = res.get('files', [])
octo_resp = client.get_files()
for item in octo_resp.get('files', []):
octo_files_dict[item.get('name')] = item
except Exception as e:
error = str(e)
else:
pass
for f in user_files:
gcode_filename = f.filename.rsplit('.', 1)[0] + '.gcode'
gcode_path = os.path.join(gcode_dir, gcode_filename)
size = 0
if os.path.exists(gcode_path):
size = os.path.getsize(gcode_path)
# Upload to OctoPrint if not found but exists locally
if client and gcode_filename not in octo_files_dict and size > 0:
try:
resp = client.upload_file('local', gcode_path, gcode_filename)
uploaded_loc = resp.get('files', {}).get('local', {})
if gcode_filename in uploaded_loc:
octo_files_dict[gcode_filename] = uploaded_loc[gcode_filename]
except Exception as e:
pass
octo_info = octo_files_dict.get(gcode_filename, {})
analysis = octo_info.get('gcodeAnalysis', None)
files.append({
'id': f.id,
'name': f.original_filename.rsplit('.', 1)[0] + '.gcode',
'type': 'machinecode',
'size': size,
'origin': 'local',
'path': gcode_filename,
'gcodeAnalysis': analysis
})
error = None
if not get_octo_client():
error = "OctoPrint is not configured."
return render_template('printer/prepare.html', files=files, error=error)
@printer_bp.route('/api/print_file', methods=['POST'])
@@ -98,6 +149,54 @@ def api_command():
return jsonify({"success": False, "error": str(e)})
return jsonify({"success": False, "error": "Invalid client or command"})
@printer_bp.route('/api/upload_gcode', methods=['POST'])
@login_required
def upload_gcode():
from app.models import PrintFile
import os
import uuid
from werkzeug.utils import secure_filename
if 'file' not in request.files:
return jsonify({"success": False, "error": "No file part"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"success": False, "error": "No selected file"}), 400
if not file.filename.lower().endswith(('.gcode', '.gco', '.g')):
return jsonify({"success": False, "error": "Only standard .gcode files are supported."}), 400
sec_name = secure_filename(file.filename)
random_prefix = uuid.uuid4().hex[:8]
# We save pseudo-STL filename so that our conventional parser works (replaces .ext with .gcode)
# i.e., "some_file.gcode" -> pseudo .stl tracker
pseudo_stl_filename = f"{random_prefix}_{sec_name.rsplit('.', 1)[0]}.stl"
gcode_filename = f"{random_prefix}_{sec_name.rsplit('.', 1)[0]}.gcode"
gcode_path = os.path.join(get_gcode_dir(), gcode_filename)
file.save(gcode_path)
print_file = PrintFile(
user_id=current_user.id,
original_filename=file.filename, # keep original GCode name
filename=pseudo_stl_filename,
file_type='stl',
status='sliced'
)
db.session.add(print_file)
db.session.commit()
client = get_octo_client()
if client:
try:
client.upload_file('local', gcode_path, gcode_filename)
except Exception:
pass
return jsonify({"success": True})
@printer_bp.route('/octo_config', methods=['GET', 'POST'])
@login_required
def octo_config():
@@ -141,9 +240,9 @@ 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', defaults={'path': ''}, websocket=True, strict_slashes=False)
@printer_bp.route('/proxy/<path:path>', websocket=True)
@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], strict_slashes=False)
@printer_bp.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
@login_required
def octo_proxy(path):
@@ -159,7 +258,7 @@ def octo_proxy(path):
# print("----- REQUEST HEADERS -----")
# for k, v in request.headers:
# print(f"{k}: {v}")
# print("----- END HEADERS -----")
# print("----- END REQUEST HEADERS -----")
# --- WebSocket Proxy Logic ---
if request.headers.get('Upgrade', '').lower() == 'websocket':
@@ -278,17 +377,26 @@ def octo_proxy(path):
# 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 = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length', 'origin', 'referer']}
# NGINX equivalent proxy headers
# print(f"Proxying to: {target_url}")
# Spoof Host, Origin, and Referer to match the backend URL completely
# This prevents Tornado's strict Origin vs Host CSRF/CORS validation from failing
# headers['Host'] = parsed_base.netloc
headers['Host'] = request.host
if 'Origin' in request.headers:
headers['Origin'] = base_url
if 'Referer' in request.headers:
headers['Referer'] = f"{base_url}/{path}"
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-Forwarded-Proto'] = request.scheme
headers['X-Script-Name'] = "/printer/proxy"
headers['X-Forwarded-Host'] = request.host
headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
@@ -299,6 +407,27 @@ def octo_proxy(path):
if request.headers.get('Connection'):
headers['Connection'] = request.headers.get('Connection')
# OctoPrint requires an X-CSRF-Token header matching the csrf_token_* cookie for POST/PUT/DELETE
if request.method not in ['GET', 'HEAD', 'OPTIONS'] and 'X-CSRF-Token' not in request.headers:
for cookie_name, cookie_value in request.cookies.items():
if cookie_name.startswith('csrf_token_'):
headers['X-CSRF-Token'] = cookie_value
break
# if path == 'api/login':
# print("----- SEND HEADERS -----")
# for k, v in headers.items():
# if k in request.headers.keys():
# print(f"{k} :(from request): {request.headers[k]} (to send): {v}")
# else:
# print(f"{k} :(from request): None (to send): {v}")
# for k,v in request.headers.items():
# if k not in headers.keys():
# print(f"{k} :(from request): {request.headers[k]} (to send): None")
# print("----- END SEND HEADERS -----")
try:
# proxy_connect_timeout 60s, proxy_read_timeout 600s
resp = requests.request(
@@ -316,7 +445,15 @@ def octo_proxy(path):
# Strip headers that might break the iframe or framing
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'x-frame-options', 'content-security-policy']
response_headers = [(name, value) for (name, value) in resp.headers.items() if name.lower() not in excluded_headers]
# We must use raw headers to prevent requests from joining multiple Set-Cookie headers with a comma
# Joining Set-Cookie with a comma breaks standard cookie parsing in the browser due to commas in dates
response_headers = [(name, value) for name, value in resp.raw.headers.items()
if name.lower() not in excluded_headers and name.lower() != 'set-cookie']
for cookie in resp.raw.headers.get_all('set-cookie', []):
# ensure we preserve the proxy path override
response_headers.append(('Set-Cookie', cookie))
def generate():
for chunk in resp.iter_content(chunk_size=8192):