Compare commits

...

2 Commits

Author SHA1 Message Date
6981553101 修复偏移问题,修复代理问题 2026-04-15 00:22:38 +08:00
f0f9d658eb 修复偏移问题,修复代理问题 2026-04-15 00:22:12 +08:00
33 changed files with 1641 additions and 1767 deletions

View File

@@ -52,6 +52,8 @@ def _t(key):
def create_app():
app = Flask(__name__, static_url_path='/assets', static_folder='assets')
app.config['SECRET_KEY'] = 'your-secret-key-change-it-in-production'
app.config['SESSION_COOKIE_NAME'] = 'aio_session' # Prevent collision with OctoPrint's 'session' cookie
app.config['REMEMBER_COOKIE_NAME'] = 'aio_remember'
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'))

222
app/assets/i18n/de.json Normal file
View File

@@ -0,0 +1,222 @@
{
"Language": "Sprache",
"English": "English",
"Chinese": "中文",
"Guest": "Gast",
"Login": "Anmelden",
"Logout": "Abmelden",
"Home": "Startseite",
"New Slice": "Neuer Slice",
"My Files": "Meine Dateien",
"Admin Options": "Admin-Optionen",
"System Settings": "Systemeinstellungen",
"User Management": "Benutzerverwaltung",
"Dashboard": "Dashboard",
"Total Prints": "Gesamte Drucke",
"You have sliced": "Sie haben",
"files": "Dateien gesliced.",
"Upload & Slice STL": "STL Hochladen & Slicen",
"Select STL File": "STL-Datei auswählen",
"Quality Profile": "Qualitätsprofil",
"Upload & Slice": "Hochladen & Slicen",
"3D Preview Area": "3D-Vorschaubereich",
"Upload a file to display": "Eine Datei zur Anzeige hochladen",
"Date Uploaded": "Hochladedatum",
"Original Name": "Originalname",
"Status": "Status",
"Actions": "Aktionen",
"Waiting": "Wartend",
"Merging": "Zusammenführen",
"Waiting in queue for slicing": "Wartet in der Warteschlange aufs Slicen",
"Slicing": "Slicen",
"Sliced": "Gesliced",
"Uploaded": "Hochgeladen",
"Failed": "Fehlgeschlagen",
"This model has already been sliced. The existing GCode will be overwritten. Continue?": "Dieses Modell wurde bereits gesliced. Der existierende GCode wird überschrieben. Fortfahren?",
"Upload STL": "STL hochladen",
"Download GCode": "GCode Herunterladen",
"GCode Preview": "GCode Vorschau",
"Delete": "Löschen",
"No files uploaded yet.": "Noch keine Dateien hochgeladen.",
"Drag & Drop STL files here or Click to Select": "STL-Dateien hierher ziehen & ablegen oder zum Auswählen klicken",
"Uploading...": "Lädt hoch...",
"Simplifying": "Vereinfache",
"Simplifying...": "Vereinfache...",
"Proxy Skip Size (MB)": "Proxy-Überspringgröße (MB)",
"Files smaller than this will not generate a simplified proxy.": "Dateien, die kleiner sind, werden keine vereinfachte Proxy generieren.",
"Upload Complete!": "Hochladen abgeschlossen!",
"Upload error.": "Fehler beim Hochladen.",
"Upload failed.": "Hochladen fehlgeschlagen.",
"Please upload a valid .stl file!": "Bitte laden Sie eine gültige .stl Datei hoch!",
"Slicing queued!": "Slicen in die Warteschlange eingereiht!",
"Draft Quality": "Entwurfsqualität",
"Standard Quality": "Standardqualität",
"High Quality": "Hohe Qualität",
"Dynamic Quality": "Dynamische Qualität",
"Low Quality": "Niedrige Qualität",
"Super Quality": "Super Qualität",
"Ultra Quality": "Ultra Qualität",
"Plater": "Druckplatte",
"Layer Progress:": "Schichtfortschritt:",
"Loading and Parsing GCode Data...": "Lade und verarbeite GCode-Daten...",
"Failed to load GCode preview.": "Fehler beim Laden der GCode-Vorschau.",
"Outer Wall": "Außenwand",
"Inner Wall": "Innenwand",
"Infill": "Füllung",
"Skin/TopBottom": "Hülle/ObenUnten",
"Travel (Move)": "Bewegung (Reise)",
"Skirt": "Skirt",
"Support Interface": "Stützstruktur-Grenzschicht",
"Back": "Zurück",
"Layer": "Schicht",
"Plater / Build Plate": "Druckbettleiste",
"Translate (W)": "Verschieben (W)",
"Rotate (E)": "Drehen (E)",
"Scale (R)": "Skalieren (R)",
"Scale": "Skalieren",
"Uniform Scale": "Gleichmäßige Skalierung",
"Lay Flat": "Flach legen",
"Remove Selected (Del)": "Ausgewähltes entfernen (Del)",
"Available Models": "Verfügbare Modelle",
"No STL models uploaded yet. Go upload some first.": "Noch keine STL-Modelle hochgeladen. Laden Sie zuerst einige hoch.",
"Other Settings": "Andere Einstellungen",
"Infill Density": "Fülldichte",
"Support": "Stützstruktur",
"None": "Keine",
"Touching Buildplate": "Nur Druckbett berührend",
"Everywhere": "Überall",
"Support Type": "Stützstruktur-Typ",
"Tree": "Baum",
"Lines": "Linien",
"Grid": "Gitter",
"Triangles": "Dreiecke",
"Concentric": "Konzentrisch",
"Zig Zag": "Zickzack",
"Cross": "Kreuz",
"Gyroid": "Gyroid",
"Honeycomb": "Wabe",
"Octagon": "Achteck",
"Clear Board": "Druckbett leeren",
"Merge & Slice": "Zusammenführen & Slicen",
"Error loading STL model file.": "Fehler beim Laden der STL-Datei.",
"Please add at least one model to the build plate.": "Bitte fügen Sie mindestens ein Modell zur Druckplatte hinzu.",
"One or more models are outside the print area. Please adjust them before slicing.": "Mindestens ein Modell liegt außerhalb des Druckbereichs. Bitte anpassen.",
"Error:": "Fehler:",
"ID": "ID",
"Username": "Benutzername",
"Role": "Rolle",
"Created At": "Erstellt am",
"Admin": "Admin",
"User": "Benutzer",
"WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?": "WARNUNG: Sind Sie sicher, dass Sie diesen Benutzer UND ALLE seine Dateien löschen wollen?",
"CuraEngine Configurations": "CuraEngine-Konfigurationen",
"Plater Origin Offset X (mm)": "Druckbett Ursprung Offset X (mm)",
"Adjust the X-axis compilation offset for combined files on the build plate.": "X-Achsen-Offset für kombinierte Dateien anpassen.",
"Plater Origin Offset Y (mm)": "Druckbett Ursprung Offset Y (mm)",
"Adjust the Y-axis compilation offset for combined files on the build plate.": "Y-Achsen-Offset für kombinierte Dateien anpassen.",
"Default Plater Settings": "Standard-Druckbetteinstellungen",
"Default Infill Density (%)": "Standard-Fülldichte (%)",
"Default Support": "Standard-Stützstruktur",
"Default Support Type": "Standard-Stützstruktur-Typ",
"Default Quality Profile": "Standard-Qualitätsprofil",
"Save Settings": "Einstellungen speichern",
"You are editing a composite model. The existing composite will be updated and re-sliced. Continue?": "Sie bearbeiten ein kombiniiges Modell. Fortfahren?",
"Select": "Auswählen",
"OctoPrint settings updated": "OctoPrint-Einstellungen aktualisiert",
"Settings updated successfully": "Einstellungen erfolgreich aktualisiert",
"Print starting! Going to dashboard...": "Druck startet! Gehe zum Dashboard...",
"Are you sure you want to delete this file?": "Sind Sie sicher, dass Sie diese Datei löschen wollen?",
"OctoPrint Base URL": "OctoPrint Basis-URL",
"Temperatures": "Temperaturen",
"Notice": "Hinweis",
"Please upload valid .stl files!": "Bitte gültige .stl hochladen!",
"Print Time:": "Druckzeit:",
"System Configuration": "Systemkonfiguration",
"Command": "Befehl",
"Time Left:": "Verbleibende Zeit:",
"Yes": "Ja",
"Current State": "Aktueller Zustand",
"Error updating settings": "Fehler beim Aktualisieren der Einstellungen",
"Available Files on Printer": "Verfügbare Dateien auf dem Drucker",
"Control failed: ": "Steuerung fehlgeschlagen: ",
"Loading webcam stream...": "Lade Webcam-Stream...",
"Live Webcam": "Live-Webcam",
"No printable files found. Go slice some G-Code first!": "Keine druckbaren Dateien gefunden. Erst G-Code slicen!",
"Save Connection Settings": "Verbindungseinstellungen speichern",
"Validation Failed": "Validierung fehlgeschlagen",
"Cancel Print": "Drucken abbrechen",
"Send this file to print immediately?": "Diese Datei sofort zum Druck senden?",
"Print Now": "Jetzt drucken",
"OctoPrint Panel (Embedded)": "OctoPrint-Panel (Eingebettet)",
"Are you sure you want to perform this action?": "Sind Sie sicher, dass Sie die Aktion ausführen wollen?",
"Control": "Steuerung",
"Cancel": "Abbrechen",
"sent.": "gesendet.",
"The local IP address or hostname of your OctoPrint server.": "Lokale IP-Adresse oder Hostname Ihres OctoPrint-Servers.",
"OctoPrint Panel": "OctoPrint-Panel",
"General Operations": "Allgemeine Operationen",
"Confirm": "Bestätigen",
"Basic Control": "Basiskontrolle",
"Size:": "Größe:",
"Prepare Print": "Druck vorbereiten",
"API Key / Application Key": "API-Schlüssel / Anwendungsschlüssel",
"Configuration Required:": "Konfiguration erforderlich:",
"Paste API Key here": "API-Schlüssel hier einfügen",
"Tool/Nozzle": "Werkzeug/Düse",
"Pause/Resume": "Pause/Fortsetzen",
"Can be found in OctoPrint Settings -> Application Keys or API.": "Befindet sich in den OctoPrint-Einstellungen -> Anwendungsschlüssel oder API.",
"Preview": "Vorschau",
"Pause": "Pause",
"Bed": "Druckbett",
"Printer Status": "Druckerstatus",
"Printer": "Drucker",
"Admin / OctoPrint": "Admin / OctoPrint",
"Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.": "Absoluter Pfad zum Speichern von GCode. Leer lassen für Systemstandard.",
"Custom GCode Output Folder": "Benutzerdefinierter GCode-Ausgabeordner",
"Go to Configuration": "Zur Konfiguration springen",
"OK": "OK",
"Connection Settings": "Verbindungseinstellungen",
"OctoPrint Configuration": "OctoPrint-Konfiguration",
"The OctoPrint URL is not set. Please go to the ": "Die OctoPrint-URL ist nicht gesetzt. Gehen Sie zu ",
"Go to Print": "Zum Druck",
"Time:": "Zeit:",
"Are you sure you want to cancel the print?": "Wollen Sie den Druck wirklich abbrechen?",
"Uploading and linking GCode...": "GCode hochladen und verknüpfen...",
"Active Print Job": "Aktiver Druckauftrag",
"Upload External GCode": "Externen GCode hochladen",
"Slicer": "Slicer",
"System Config": "Systemkonfiguration",
"Home All Axes": "Alle Achsen auf Nullposition",
"Pause/Resume Print": "Druck Pausieren/Fortsetzen",
"Slice": "Slicen",
"Network error": "Netzwerkfehler",
"Printer Control": "Drucker-Steuerung",
"Error saving settings": "Fehler beim Speichern der Einstellungen",
"page to set it up.": "Seite zum Einrichten.",
"Network Error: ": "Netzwerkfehler: ",
"3D Model Files (STL)": "3D-Modelldateien (STL)",
"You have uploaded": "Sie haben hochgeladen",
"Total Space Used": "Verwendeter Speicherplatz",
"Sliced Files (GCode)": "Geslicte Dateien (GCode)",
"You have sliced or uploaded": "Sie haben geslict oder hochgeladen",
"Default Storage Quotas (MB)": "Standard Speicherquoten (MB)",
"Guest STL Quota": "Gast STL Quoten",
"Unlimited": "Unbegrenzt",
"Guest GCode Quota": "Gast GCode Quoten",
"New User STL Quota": "Neuer Benutzer STL Quoten",
"New User GCode Quota": "Neuer Benutzer GCode Quoten",
"Quota": "Quote",
"GCode Storage Quota Exceeded. Please delete some files first.": "GCode Speicherplatz überschritten. Bitte löschen Sie zuerst einige Dateien.",
"Edit Quota": "Quota bearbeiten",
"Edit Quota for": "Quota bearbeiten für",
"Reset Password": "Passwort zurücksetzen",
"Reset Password for": "Passwort zurücksetzen für",
"Save": "Speichern",
"STL Quota": "STL Quote",
"GCode Quota": "GCode Quote",
"New Password": "Neues Passwort",
"Add User": "Benutzer hinzufügen",
"Password": "Passwort",
"Is Admin": "Ist Administrator",
"Create User": "Benutzer erstellen"
}

View File

@@ -113,5 +113,110 @@
"Default Support": "Default Support",
"Default Support Type": "Default Support Type",
"Default Quality Profile": "Default Quality Profile",
"Save Settings": "Save Settings"
"Save Settings": "Save Settings",
"You are editing a composite model. The existing composite will be updated and re-sliced. Continue?": "You are editing a composite model. The existing composite will be updated and re-sliced. Continue?",
"Select": "Select",
"OctoPrint settings updated": "OctoPrint settings updated",
"Settings updated successfully": "Settings updated successfully",
"This model has already been sliced. The existing GCode will be overwritten. Continue?": "This model has already been sliced. The existing GCode will be overwritten. Continue?",
"Print starting! Going to dashboard...": "Print starting! Going to dashboard...",
"Are you sure you want to delete this file?": "Are you sure you want to delete this file?",
"OctoPrint Base URL": "OctoPrint Base URL",
"Temperatures": "Temperatures",
"Notice": "Notice",
"Please upload valid .stl files!": "Please upload valid .stl files!",
"Print Time:": "Print Time:",
"System Configuration": "System Configuration",
"Command": "Command",
"Time Left:": "Time Left:",
"Yes": "Yes",
"Current State": "Current State",
"Error updating settings": "Error updating settings",
"Available Files on Printer": "Available Files on Printer",
"Control failed: ": "Control failed: ",
"Loading webcam stream...": "Loading webcam stream...",
"Live Webcam": "Live Webcam",
"No printable files found. Go slice some G-Code first!": "No printable files found. Go slice some G-Code first!",
"Save Connection Settings": "Save Connection Settings",
"Upload error.": "Upload error.",
"Validation Failed": "Validation Failed",
"Cancel Print": "Cancel Print",
"Send this file to print immediately?": "Send this file to print immediately?",
"Print Now": "Print Now",
"OctoPrint Panel (Embedded)": "OctoPrint Panel (Embedded)",
"Are you sure you want to perform this action?": "Are you sure you want to perform this action?",
"Control": "Control",
"Cancel": "Cancel",
"sent.": "sent.",
"The local IP address or hostname of your OctoPrint server.": "The local IP address or hostname of your OctoPrint server.",
"OctoPrint Panel": "OctoPrint Panel",
"General Operations": "General Operations",
"Confirm": "Confirm",
"Basic Control": "Basic Control",
"Size:": "Size:",
"Prepare Print": "Prepare Print",
"API Key / Application Key": "API Key / Application Key",
"Configuration Required:": "Configuration Required:",
"Paste API Key here": "Paste API Key here",
"Tool/Nozzle": "Tool/Nozzle",
"Pause/Resume": "Pause/Resume",
"Can be found in OctoPrint Settings -> Application Keys or API.": "Can be found in OctoPrint Settings -> Application Keys or API.",
"Preview": "Preview",
"Pause": "Pause",
"Bed": "Bed",
"Printer Status": "Printer Status",
"Printer": "Printer",
"Admin / OctoPrint": "Admin / OctoPrint",
"Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.": "Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.",
"Custom GCode Output Folder": "Custom GCode Output Folder",
"Go to Configuration": "Go to Configuration",
"OK": "OK",
"Connection Settings": "Connection Settings",
"OctoPrint Configuration": "OctoPrint Configuration",
"The OctoPrint URL is not set. Please go to the ": "The OctoPrint URL is not set. Please go to the ",
"Go to Print": "Go to Print",
"Time:": "Time:",
"Are you sure you want to cancel the print?": "Are you sure you want to cancel the print?",
"Uploading and linking GCode...": "Uploading and linking GCode...",
"Active Print Job": "Active Print Job",
"Upload External GCode": "Upload External GCode",
"Slicer": "Slicer",
"System Config": "System Config",
"Home All Axes": "Home All Axes",
"Pause/Resume Print": "Pause/Resume Print",
"Slice": "Slice",
"Upload failed.": "Upload failed.",
"Network error": "Network error",
"Upload Complete!": "Upload Complete!",
"Printer Control": "Printer Control",
"Error saving settings": "Error saving settings",
"page to set it up.": "page to set it up.",
"Network Error: ": "Network Error: ",
"Upload STL": "Upload STL",
"Please upload a valid .stl file!": "Please upload a valid .stl file!",
"3D Model Files (STL)": "3D Model Files (STL)",
"You have uploaded": "You have uploaded",
"Total Space Used": "Total Space Used",
"Sliced Files (GCode)": "Sliced Files (GCode)",
"You have sliced or uploaded": "You have sliced or uploaded",
"Default Storage Quotas (MB)": "Default Storage Quotas (MB)",
"Guest STL Quota": "Guest STL Quota",
"Unlimited": "Unlimited",
"Guest GCode Quota": "Guest GCode Quota",
"New User STL Quota": "New User STL Quota",
"New User GCode Quota": "New User GCode Quota",
"Quota": "Quota",
"GCode Storage Quota Exceeded. Please delete some files first.": "GCode Storage Quota Exceeded. Please delete some files first.",
"Edit Quota": "Edit Quota",
"Edit Quota for": "Edit Quota for",
"Reset Password": "Reset Password",
"Reset Password for": "Reset Password for",
"Save": "Save",
"STL Quota": "STL Quota",
"GCode Quota": "GCode Quota",
"New Password": "New Password",
"Add User": "Add User",
"Password": "Password",
"Is Admin": "Is Admin",
"Create User": "Create User"
}

View File

@@ -119,5 +119,104 @@
"Default Support": "默认支撑类型",
"Default Support Type": "默认支撑图案",
"Default Quality Profile": "默认质量配置",
"Save Settings": "保存设置"
"Save Settings": "保存设置",
"You are editing a composite model. The existing composite will be updated and re-sliced. Continue?": "您正在编辑合并模型。现有的组合将被更新并重新切片。继续吗?",
"Select": "选择",
"OctoPrint settings updated": "OctoPrint 设置已更新",
"Settings updated successfully": "设置更新成功",
"Print starting! Going to dashboard...": "打印开始!前往仪表板...",
"Are you sure you want to delete this file?": "您确定要删除此文件吗?",
"OctoPrint Base URL": "OctoPrint 基础 URL",
"Temperatures": "温度",
"Notice": "注意",
"Please upload valid .stl files!": "请上传有效的 .stl 文件!",
"Print Time:": "打印时间:",
"System Configuration": "系统配置",
"Command": "命令",
"Time Left:": "剩余时间:",
"Yes": "是",
"Current State": "当前状态",
"Error updating settings": "更新设置时出错",
"Available Files on Printer": "打印机上的可用文件",
"Control failed: ": "控制失败: ",
"Loading webcam stream...": "正在加载摄像头流...",
"Live Webcam": "实时摄像头",
"No printable files found. Go slice some G-Code first!": "未找到可打印文件。请先切片一些 G-Code",
"Save Connection Settings": "保存连接设置",
"Validation Failed": "验证失败",
"Cancel Print": "取消打印",
"Send this file to print immediately?": "立即发送此文件进行打印?",
"Print Now": "立即打印",
"OctoPrint Panel (Embedded)": "OctoPrint 面板 (嵌入)",
"Are you sure you want to perform this action?": "您确定要执行此操作吗?",
"Control": "控制",
"Cancel": "取消",
"sent.": "已发送。",
"The local IP address or hostname of your OctoPrint server.": "您的 OctoPrint 服务器的本地 IP 地址或主机名。",
"OctoPrint Panel": "OctoPrint 面板",
"General Operations": "通用操作",
"Confirm": "确认",
"Basic Control": "基本控制",
"Size:": "大小:",
"Prepare Print": "准备打印",
"API Key / Application Key": "API 密钥 / 应用程序密钥",
"Configuration Required:": "需要配置:",
"Paste API Key here": "在此处粘贴 API 密钥",
"Tool/Nozzle": "工具/喷嘴",
"Pause/Resume": "暂停/恢复",
"Can be found in OctoPrint Settings -> Application Keys or API.": "可以在 OctoPrint 设置 -> 应用程序密钥或 API 中找到。",
"Preview": "预览",
"Pause": "暂停",
"Bed": "热床",
"Printer Status": "打印机状态",
"Printer": "打印机",
"Admin / OctoPrint": "管理员 / OctoPrint",
"Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.": "保存本地切片 GCode 文件的绝对路径(例如 OctoPrint 的 uploads 文件夹:\"/home/pi/.octoprint/uploads\")。留空以使用系统默认值。",
"Custom GCode Output Folder": "自定义 GCode 输出文件夹",
"Go to Configuration": "前往配置",
"OK": "确定",
"Connection Settings": "连接设置",
"OctoPrint Configuration": "OctoPrint 配置",
"The OctoPrint URL is not set. Please go to the ": "未设置 OctoPrint URL。请前往",
"Go to Print": "前往打印",
"Time:": "时间:",
"Are you sure you want to cancel the print?": "您确定要取消打印吗?",
"Uploading and linking GCode...": "正在上传并链接 GCode...",
"Active Print Job": "当前打印任务",
"Upload External GCode": "上传外部 GCode",
"Slicer": "切片软件",
"System Config": "系统配置",
"Home All Axes": "全部轴归零",
"Pause/Resume Print": "暂停/恢复打印",
"Slice": "切片",
"Network error": "网络错误",
"Printer Control": "打印机控制",
"Error saving settings": "保存设置时出错",
"page to set it up.": "页面进行设置。",
"Network Error: ": "网络错误: ",
"3D Model Files (STL)": "3D 模型文件 (STL)",
"You have uploaded": "您已上传",
"Total Space Used": "占用空间",
"Sliced Files (GCode)": "已切片文件 (GCode)",
"You have sliced or uploaded": "您已切片或上传",
"Default Storage Quotas (MB)": "默认存储配额 (MB)",
"Guest STL Quota": "访客 STL 配额",
"Unlimited": "无限制",
"Guest GCode Quota": "访客 GCode 配额",
"New User STL Quota": "新用户 STL 配额",
"New User GCode Quota": "新用户 GCode 配额",
"Quota": "限额",
"GCode Storage Quota Exceeded. Please delete some files first.": "GCode 存储配额已超限,请先删除部分旧文件。",
"Edit Quota": "编辑配额",
"Edit Quota for": "编辑配额用于",
"Reset Password": "重置密码",
"Reset Password for": "重置密码 用户:",
"Save": "保存",
"STL Quota": "STL 配额",
"GCode Quota": "GCode 配额",
"New Password": "新密码",
"Add User": "添加用户",
"Password": "密码",
"Is Admin": "设为管理员",
"Create User": "创建用户"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,371 +0,0 @@
( function () {
/**
* Description: A THREE loader for STL ASCII files, as created by Solidworks and other CAD programs.
*
* Supports both binary and ASCII encoded files, with automatic detection of type.
*
* The loader returns a non-indexed buffer geometry.
*
* Limitations:
* Binary decoding supports "Magics" color format (http://en.wikipedia.org/wiki/STL_(file_format)#Color_in_binary_STL).
* There is perhaps some question as to how valid it is to always assume little-endian-ness.
* ASCII decoding assumes file is UTF-8.
*
* Usage:
* const loader = new STLLoader();
* loader.load( './models/stl/slotted_disk.stl', function ( geometry ) {
* scene.add( new THREE.Mesh( geometry ) );
* });
*
* For binary STLs geometry might contain colors for vertices. To use it:
* // use the same code to load STL as above
* if (geometry.hasColors) {
* material = new THREE.MeshPhongMaterial({ opacity: geometry.alpha, vertexColors: true });
* } else { .... }
* const mesh = new THREE.Mesh( geometry, material );
*
* For ASCII STLs containing multiple solids, each solid is assigned to a different group.
* Groups can be used to assign a different color by defining an array of materials with the same length of
* geometry.groups and passing it to the Mesh constructor:
*
* const mesh = new THREE.Mesh( geometry, material );
*
* For example:
*
* const materials = [];
* const nGeometryGroups = geometry.groups.length;
*
* const colorMap = ...; // Some logic to index colors.
*
* for (let i = 0; i < nGeometryGroups; i++) {
*
* const material = new THREE.MeshPhongMaterial({
* color: colorMap[i],
* wireframe: false
* });
*
* }
*
* materials.push(material);
* const mesh = new THREE.Mesh(geometry, materials);
*/
class STLLoader extends THREE.Loader {
constructor( manager ) {
super( manager );
}
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new THREE.FileLoader( this.manager );
loader.setPath( this.path );
loader.setResponseType( 'arraybuffer' );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
parse( data ) {
function isBinary( data ) {
const reader = new DataView( data );
const face_size = 32 / 8 * 3 + 32 / 8 * 3 * 3 + 16 / 8;
const n_faces = reader.getUint32( 80, true );
const expect = 80 + 32 / 8 + n_faces * face_size;
if ( expect === reader.byteLength ) {
return true;
} // An ASCII STL data must begin with 'solid ' as the first six bytes.
// However, ASCII STLs lacking the SPACE after the 'd' are known to be
// plentiful. So, check the first 5 bytes for 'solid'.
// Several encodings, such as UTF-8, precede the text with up to 5 bytes:
// https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
// Search for "solid" to start anywhere after those prefixes.
// US-ASCII ordinal values for 's', 'o', 'l', 'i', 'd'
const solid = [ 115, 111, 108, 105, 100 ];
for ( let off = 0; off < 5; off ++ ) {
// If "solid" text is matched to the current offset, declare it to be an ASCII STL.
if ( matchDataViewAt( solid, reader, off ) ) return false;
} // Couldn't find "solid" text at the beginning; it is binary STL.
return true;
}
function matchDataViewAt( query, reader, offset ) {
// Check if each byte in query matches the corresponding byte from the current offset
for ( let i = 0, il = query.length; i < il; i ++ ) {
if ( query[ i ] !== reader.getUint8( offset + i, false ) ) return false;
}
return true;
}
function parseBinary( data ) {
const reader = new DataView( data );
const faces = reader.getUint32( 80, true );
let r,
g,
b,
hasColors = false,
colors;
let defaultR, defaultG, defaultB, alpha; // process STL header
// check for default color in header ("COLOR=rgba" sequence).
for ( let index = 0; index < 80 - 10; index ++ ) {
if ( reader.getUint32( index, false ) == 0x434F4C4F
/*COLO*/
&& reader.getUint8( index + 4 ) == 0x52
/*'R'*/
&& reader.getUint8( index + 5 ) == 0x3D
/*'='*/
) {
hasColors = true;
colors = new Float32Array( faces * 3 * 3 );
defaultR = reader.getUint8( index + 6 ) / 255;
defaultG = reader.getUint8( index + 7 ) / 255;
defaultB = reader.getUint8( index + 8 ) / 255;
alpha = reader.getUint8( index + 9 ) / 255;
}
}
const dataOffset = 84;
const faceLength = 12 * 4 + 2;
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array( faces * 3 * 3 );
const normals = new Float32Array( faces * 3 * 3 );
for ( let face = 0; face < faces; face ++ ) {
const start = dataOffset + face * faceLength;
const normalX = reader.getFloat32( start, true );
const normalY = reader.getFloat32( start + 4, true );
const normalZ = reader.getFloat32( start + 8, true );
if ( hasColors ) {
const packedColor = reader.getUint16( start + 48, true );
if ( ( packedColor & 0x8000 ) === 0 ) {
// facet has its own unique color
r = ( packedColor & 0x1F ) / 31;
g = ( packedColor >> 5 & 0x1F ) / 31;
b = ( packedColor >> 10 & 0x1F ) / 31;
} else {
r = defaultR;
g = defaultG;
b = defaultB;
}
}
for ( let i = 1; i <= 3; i ++ ) {
const vertexstart = start + i * 12;
const componentIdx = face * 3 * 3 + ( i - 1 ) * 3;
vertices[ componentIdx ] = reader.getFloat32( vertexstart, true );
vertices[ componentIdx + 1 ] = reader.getFloat32( vertexstart + 4, true );
vertices[ componentIdx + 2 ] = reader.getFloat32( vertexstart + 8, true );
normals[ componentIdx ] = normalX;
normals[ componentIdx + 1 ] = normalY;
normals[ componentIdx + 2 ] = normalZ;
if ( hasColors ) {
colors[ componentIdx ] = r;
colors[ componentIdx + 1 ] = g;
colors[ componentIdx + 2 ] = b;
}
}
}
geometry.setAttribute( 'position', new THREE.BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new THREE.BufferAttribute( normals, 3 ) );
if ( hasColors ) {
geometry.setAttribute( 'color', new THREE.BufferAttribute( colors, 3 ) );
geometry.hasColors = true;
geometry.alpha = alpha;
}
return geometry;
}
function parseASCII( data ) {
const geometry = new THREE.BufferGeometry();
const patternSolid = /solid([\s\S]*?)endsolid/g;
const patternFace = /facet([\s\S]*?)endfacet/g;
let faceCounter = 0;
const patternFloat = /[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source;
const patternVertex = new RegExp( 'vertex' + patternFloat + patternFloat + patternFloat, 'g' );
const patternNormal = new RegExp( 'normal' + patternFloat + patternFloat + patternFloat, 'g' );
const vertices = [];
const normals = [];
const normal = new THREE.Vector3();
let result;
let groupCount = 0;
let startVertex = 0;
let endVertex = 0;
while ( ( result = patternSolid.exec( data ) ) !== null ) {
startVertex = endVertex;
const solid = result[ 0 ];
while ( ( result = patternFace.exec( solid ) ) !== null ) {
let vertexCountPerFace = 0;
let normalCountPerFace = 0;
const text = result[ 0 ];
while ( ( result = patternNormal.exec( text ) ) !== null ) {
normal.x = parseFloat( result[ 1 ] );
normal.y = parseFloat( result[ 2 ] );
normal.z = parseFloat( result[ 3 ] );
normalCountPerFace ++;
}
while ( ( result = patternVertex.exec( text ) ) !== null ) {
vertices.push( parseFloat( result[ 1 ] ), parseFloat( result[ 2 ] ), parseFloat( result[ 3 ] ) );
normals.push( normal.x, normal.y, normal.z );
vertexCountPerFace ++;
endVertex ++;
} // every face have to own ONE valid normal
if ( normalCountPerFace !== 1 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the normal of face number ' + faceCounter );
} // each face have to own THREE valid vertices
if ( vertexCountPerFace !== 3 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the vertices of face number ' + faceCounter );
}
faceCounter ++;
}
const start = startVertex;
const count = endVertex - startVertex;
geometry.addGroup( start, count, groupCount );
groupCount ++;
}
geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new THREE.Float32BufferAttribute( normals, 3 ) );
return geometry;
}
function ensureString( buffer ) {
if ( typeof buffer !== 'string' ) {
return THREE.LoaderUtils.decodeText( new Uint8Array( buffer ) );
}
return buffer;
}
function ensureBinary( buffer ) {
if ( typeof buffer === 'string' ) {
const array_buffer = new Uint8Array( buffer.length );
for ( let i = 0; i < buffer.length; i ++ ) {
array_buffer[ i ] = buffer.charCodeAt( i ) & 0xff; // implicitly assumes little-endian
}
return array_buffer.buffer || array_buffer;
} else {
return buffer;
}
} // start
const binData = ensureBinary( data );
return isBinary( binData ) ? parseBinary( binData ) : parseASCII( ensureString( data ) );
}
}
THREE.STLLoader = STLLoader;
} )();

File diff suppressed because one or more lines are too long

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:
@@ -133,7 +250,12 @@ def download_gcode(file_id):
return redirect(url_for('main.files'))
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
gcode_dir = conf.value if (conf and conf.value and os.path.exists(conf.value)) else current_app.config['UPLOAD_FOLDER']
filepath = os.path.join(gcode_dir, gcode_filename)
if not os.path.exists(filepath):
fallback = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
if os.path.exists(fallback): filepath = fallback
if os.path.exists(filepath):
safe_name = print_file.original_filename.rsplit('.', 1)[0] + '.gcode'
@@ -149,7 +271,12 @@ def preview_gcode(file_id):
abort(403)
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
gcode_dir = conf.value if (conf and conf.value and os.path.exists(conf.value)) else current_app.config['UPLOAD_FOLDER']
filepath = os.path.join(gcode_dir, gcode_filename)
if not os.path.exists(filepath):
fallback = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
if os.path.exists(fallback): filepath = fallback
content = "File not found or not ready."
line_count = 0
@@ -176,7 +303,11 @@ def delete_file(file_id):
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)
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
gcode_dir = conf.value if (conf and conf.value and os.path.exists(conf.value)) else current_app.config['UPLOAD_FOLDER']
gcode_path = os.path.join(gcode_dir, gcode_filename)
fallback_gcode = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
proxy_path = stl_path + '.proxy.stl'
if os.path.exists(stl_path):
@@ -185,6 +316,8 @@ def delete_file(file_id):
os.remove(proxy_path)
if os.path.exists(gcode_path):
os.remove(gcode_path)
if os.path.exists(fallback_gcode):
os.remove(fallback_gcode)
db.session.delete(print_file)
db.session.commit()
@@ -223,6 +356,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 +373,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 +399,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):

View File

@@ -28,6 +28,12 @@
<div class="form-text">{{ _('Files smaller than this will not generate a simplified proxy.') }}</div>
</div>
<div class="mb-3">
<label for="gcode_upload_folder" class="form-label"><i class="bi bi-folder2-open me-2"></i>{{ _('Custom GCode Output Folder') }}</label>
<input type="text" class="form-control" name="gcode_upload_folder" id="gcode_upload_folder" value="{{ configs.get('gcode_upload_folder', '') }}">
<div class="form-text">{{ _('Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like "/home/pi/.octoprint/uploads"). Leave empty to use system default.') }}</div>
</div>
<h5 class="mt-4">{{ _('Default Plater Settings') }}</h5>
<hr>
@@ -70,6 +76,31 @@
</select>
</div>
<h5 class="card-title text-primary border-bottom pb-2 mt-4 mb-3"><i class="bi bi-hdd-network me-2"></i>{{ _('Default Storage Quotas (MB)') }}</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ _('Guest STL Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
<input type="number" class="form-control" name="default_guest_stl_quota_mb" value="{{ configs.get('default_guest_stl_quota_mb', '0') }}" min="0">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ _('Guest GCode Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
<input type="number" class="form-control" name="default_guest_gcode_quota_mb" value="{{ configs.get('default_guest_gcode_quota_mb', '0') }}" min="0">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ _('New User STL Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
<input type="number" class="form-control" name="default_user_stl_quota_mb" value="{{ configs.get('default_user_stl_quota_mb', '0') }}" min="0">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ _('New User GCode Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
<input type="number" class="form-control" name="default_user_gcode_quota_mb" value="{{ configs.get('default_user_gcode_quota_mb', '0') }}" min="0">
</div>
</div>
<button type="submit" class="btn btn-primary" id="btn-save-settings">{{ _('Save Settings') }}</button>
</form>
</div>

View File

@@ -5,6 +5,9 @@
<h1 class="h2">{{ _('User Management') }}</h1>
</div>
<div class="mb-3 text-end">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addUserModal"><i class="bi bi-person-plus me-1"></i>{{ _('Add User') }}</button>
</div>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
@@ -12,6 +15,7 @@
<th>{{ _('ID') }}</th>
<th>{{ _('Username') }}</th>
<th>{{ _('Role') }}</th>
<th>{{ _('Quotas') }}</th>
<th>{{ _('Created At') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
@@ -30,8 +34,16 @@
<span class="badge bg-primary">{{ _('User') }}</span>
{% endif %}
</td>
<td>
<small class="text-muted d-block">STL: {{ user_quotas[user.id]['stl'] if user_quotas[user.id]['stl'] != '0' else _('Unlimited') }} MB</small>
<small class="text-muted d-block">GCode: {{ user_quotas[user.id]['gcode'] if user_quotas[user.id]['gcode'] != '0' else _('Unlimited') }} MB</small>
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editQuotaModal{{ user.id }}">{{ _('Edit Quota') }}</button>
{% if not user.is_guest %}
<button class="btn btn-sm btn-outline-info" data-bs-toggle="modal" data-bs-target="#resetPwdModal{{ user.id }}">{{ _('Reset Password') }}</button>
{% endif %}
<form action="{{ url_for('admin.delete_user', user_id=user.id) }}" method="POST" class="d-inline" onsubmit="event.preventDefault(); window.customConfirm('{{ _('WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?') }}', () => { this.submit(); });">
<button type="submit" class="btn btn-sm btn-outline-danger" {% if user.id == current_user.id %}disabled{% endif %}>{{ _('Delete') }}</button>
</form>
@@ -41,4 +53,110 @@
</tbody>
</table>
</div>
{% for user in users %}
<!-- Edit Quota Modal -->
<div class="modal fade" id="editQuotaModal{{ user.id }}" tabindex="-1" aria-labelledby="editQuotaModalLabel{{ user.id }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ url_for('admin.update_quota', user_id=user.id) }}" method="POST">
<div class="modal-header">
<h5 class="modal-title" id="editQuotaModalLabel{{ user.id }}">{{ _('Edit Quota for') }} {{ user.username }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">{{ _('STL Quota') }} (MB) <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
<input type="number" class="form-control" name="stl_quota_mb" value="{{ user_quotas[user.id]['stl'] }}" min="0">
</div>
<div class="mb-3">
<label class="form-label">{{ _('GCode Quota') }} (MB) <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
<input type="number" class="form-control" name="gcode_quota_mb" value="{{ user_quotas[user.id]['gcode'] }}" min="0">
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
</div>
</form>
</div>
</div>
</div>
{% if not user.is_guest %}
<!-- Reset Password Modal -->
<div class="modal fade" id="resetPwdModal{{ user.id }}" tabindex="-1" aria-labelledby="resetPwdModalLabel{{ user.id }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ url_for('admin.reset_password', user_id=user.id) }}" method="POST">
<div class="modal-header">
<h5 class="modal-title" id="resetPwdModalLabel{{ user.id }}">{{ _('Reset Password for') }} {{ user.username }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">{{ _('New Password') }}</label>
<input type="password" class="form-control" name="password" required>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endfor %}
<!-- Add User Modal -->
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ url_for('admin.add_user') }}" method="POST">
<div class="modal-header">
<h5 class="modal-title" id="addUserModalLabel">{{ _('Add User') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">{{ _('Username') }}</label>
<input type="text" class="form-control" name="username" required>
</div>
<div class="mb-3">
<label class="form-label">{{ _('Password') }}</label>
<input type="password" class="form-control" name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="is_admin" id="isAdminCheck">
<label class="form-check-label" for="isAdminCheck">{{ _('Is Admin') }}</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">{{ _('Create User') }}</button>
</div>
</form>
</div>
</div>
</div>
<!-- Bootstrap Modal Z-Index Fix -->
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.modal').forEach(function(modal) {
document.body.appendChild(modal);
});
});
</script>
<!-- Bootstrap Modal Z-Index Fix -->
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.modal').forEach(function(modal) {
document.body.appendChild(modal);
});
});
</script>
{% endblock %}

View File

@@ -22,7 +22,17 @@
.card-header { border-bottom: 1px solid rgba(0,0,0,.05); background-color: transparent; }
.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; }
.toast {
border-radius: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0,0,0,.25);
pointer-events: auto;
border: none;
transform: translateY(-20px);
transition: opacity 0.35s ease, transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important;
}
.toast.showing, .toast.show {
transform: translateY(0);
}
/* 页面切换动画 Page Transition */
@keyframes pageFadeInSlide {
@@ -117,6 +127,9 @@
{% endif %}
{% else %}
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mb-2 text-muted fw-bold text-uppercase" style="font-size: 0.75rem;">
<span><i class="bi bi-list-task me-1"></i>{{ _('General Operations') }}</span>
</h6>
<ul class="nav flex-column nav-pills gap-1">
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'main.index' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.index') }}">
@@ -158,31 +171,30 @@
</nav>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 mt-4 bg-light min-vh-100 pb-5">
<!-- Toast Notification Container -->
<div class="toast-container" id="global-toast-container">
{% 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' %}
<div class="toast align-items-center border-0 {{ toast_class }} mb-2" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body fw-medium">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
{% block content %}{% endblock %}
</main>
</div>
</div>
<!-- Toast Notification Container -->
<div class="toast-container" id="global-toast-container">
{% 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' %}
<div class="toast align-items-center border-0 {{ toast_class }} mb-2" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body fw-medium">
{{ _(message) if _ else message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<!-- Global Custom Alert Modal -->
<div class="modal fade" id="globalAlertModal" tabindex="-1" aria-hidden="true" style="z-index: 1060;">
<div class="modal-dialog modal-dialog-centered modal-sm">

View File

@@ -1,20 +1,38 @@
{% extends 'base.html' %}
{% block content %}
<style>
/* Prevent the outer page from scrolling */
body {
overflow: hidden;
}
/* Make the main container take exactly the viewport height and act as a flex column */
main {
height: 100vh;
padding-bottom: 0 !important;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Let the iframe container fill all the remaining height automatically */
.octo-panel-container {
flex-grow: 1;
margin-bottom: 0 !important;
border-radius: 0px !important;
}
</style>
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-window-sidebar text-info me-2"></i>{{ _('OctoPrint Panel (Embedded)') }}</h1>
</div>
{% if embed_url %}
<div class="card shadow rounded overflow-hidden" style="height: calc(100vh - 180px); min-height: 500px;">
<!-- iFrame wrapper for responsivness -->
<div class="w-100 h-100 position-relative">
<iframe src="{{ embed_url }}"
class="position-absolute border-0 w-100 h-100"
style="top: 0; left: 0;"
allowfullscreen>
</iframe>
</div>
<div class="card shadow overflow-hidden octo-panel-container position-relative">
<iframe src="{{ embed_url }}"
class="position-absolute border-0 w-100 h-100"
style="top: 0; left: 0;"
allowfullscreen>
</iframe>
</div>
{% else %}
<div class="alert alert-warning shadow-sm border-0 d-flex align-items-center" role="alert">

View File

@@ -1,8 +1,29 @@
{% extends 'base.html' %}
{% block content %}
<style>
@keyframes blink-fade {
0% { background-color: rgba(255, 193, 7, 0.4); box-shadow: 0 0 15px rgba(255, 193, 7, 0.6); }
50% { background-color: rgba(255, 193, 7, 0); box-shadow: 0 0 0 rgba(255, 193, 7, 0); }
100% { background-color: rgba(255, 193, 7, 0.4); box-shadow: 0 0 15px rgba(255, 193, 7, 0.6); }
}
.animate-blink {
animation: blink-fade 0.8s ease-in-out 4;
border: 2px solid #ffc107 !important;
border-radius: 8px;
}
.transition-style {
transition: all 0.3s ease;
}
</style>
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-file-earmark-plus text-primary me-2"></i>{{ _('Prepare Print') }}</h1>
<div>
<button type="button" class="btn btn-outline-primary btn-sm rounded shadow-sm fw-bold" onclick="document.getElementById('gcodeUploadInput').click();">
<i class="bi bi-upload me-1"></i>{{ _('Upload External GCode') }}
</button>
<input type="file" id="gcodeUploadInput" style="display: none;" accept=".gcode,.gco,.g" onchange="uploadExternalGcode(this)">
</div>
</div>
{% if error %}
@@ -17,12 +38,13 @@
<div class="list-group list-group-flush">
{% for f in files %}
{% if f.type == 'machinecode' %}
<div class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3">
<div id="file-{{ f.id }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 transition-style">
<div class="me-auto text-truncate" style="max-width: 80%;">
<h6 class="mb-1"><i class="bi bi-file-earmark-code text-primary me-2"></i>{{ f.name }}</h6>
<small class="text-muted d-block">{{ _('Size:') }} {{ f.size }} bytes, {{ _('Time:') }} {{ f.gcodeAnalysis.estimatedPrintTime if f.gcodeAnalysis else 'Unknown' }}s</small>
</div>
<div>
<a href="{{ url_for('main.preview_gcode', file_id=f.id) }}" class="btn btn-sm btn-outline-info rounded-pill px-3 shadow-sm me-2"><i class="bi bi-eye me-1"></i>{{ _('Preview') }}</a>
<button class="btn btn-sm btn-outline-success rounded-pill px-3 shadow-sm" onclick="printFile('{{ f.origin }}', '{{ f.path }}')"><i class="bi bi-play-fill me-1"></i>{{ _('Print Now') }}</button>
<!-- <button class="btn btn-sm btn-outline-secondary rounded-pill ms-2" onclick="selectFile('{{ f.origin }}', '{{ f.path }}')">{{ _('Select') }}</button> -->
</div>
@@ -59,6 +81,54 @@ function printFile(origin, path) {
.catch(err => window.customAlert("Error: " + err));
});
}
function uploadExternalGcode(input) {
if (!input.files || input.files.length === 0) return;
let file = input.files[0];
let formData = new FormData();
formData.append('file', file);
window.showToast("{{ _('Uploading and linking GCode...') }}", "info");
let btn = document.querySelector('button[onclick="document.getElementById(\'gcodeUploadInput\').click();"]');
let oldBtnHtml = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Upload/Sync...';
btn.disabled = true;
fetch("{{ url_for('printer.upload_gcode') }}", {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
if(data.success) {
window.location.reload();
} else {
window.customAlert("Upload failed: " + data.error);
btn.innerHTML = oldBtnHtml;
btn.disabled = false;
}
}).catch(e => {
window.customAlert("Error: " + e);
btn.innerHTML = oldBtnHtml;
btn.disabled = false;
});
}
window.addEventListener('DOMContentLoaded', (event) => {
if(window.location.hash) {
let el = document.querySelector(window.location.hash);
if(el) {
setTimeout(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('animate-blink');
// fallback to remove class later
setTimeout(() => el.classList.remove('animate-blink'), 3500);
}, 300);
}
}
});
</script>
{% endif %}
{% endblock %}

View File

@@ -39,12 +39,15 @@
</thead>
<tbody>
{% for file in files %}
<tr id="file-row-{{ file.id }}" data-status="{{ file.status }}">
{% set is_gcode = file.original_filename.lower().endswith('.gcode') or file.original_filename.lower().endswith('.gco') or file.original_filename.lower().endswith('.g') %}
<tr id="file-row-{{ file.id }}" data-status="{{ file.status }}" data-is-gcode="{{ 'true' if is_gcode else 'false' }}">
<td class="ps-4 text-muted">
<i class="bi bi-clock me-1"></i>
<span class="local-time" data-utc="{{ file.created_at.isoformat() }}">{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
</td>
<td class="fw-medium">{{ file.original_filename }}</td>
<td class="fw-medium">
<i class="bi {{ 'bi-file-earmark-code text-success' if is_gcode else 'bi-box text-primary' }} me-2"></i>{{ file.original_filename }}
</td>
<td id="status-{{ file.id }}">
{% if file.status == 'waiting' %}
<span class="badge bg-info text-dark rounded-pill fw-normal px-2" title="{{ _('Waiting in queue for slicing') }}"><i class="bi bi-hourglass-split me-1"></i>{{ _('Waiting') }}...</span>
@@ -64,7 +67,9 @@
</td>
<td class="pe-4">
<div class="d-flex gap-2" id="actions-container-{{ file.id }}">
{% if not is_gcode %}
<a href="{{ url_for('main.plater') }}?add={{ file.id }}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>
{% endif %}
{% if file.status == 'sliced' %}
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-primary shadow-sm" title="{{ _('Download GCode') }}"><i class="bi bi-download"></i></a>
<a href="{{ url_for('main.preview_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-info shadow-sm" title="{{ _('GCode Preview') }}"><i class="bi bi-eye"></i></a>
@@ -138,7 +143,10 @@ document.addEventListener('DOMContentLoaded', function() {
let actionsHtml = '';
const platerUrl = `{{ url_for('main.plater') }}?add=${id}`;
actionsHtml += `<a href="${platerUrl}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>\n`;
const isGcode = tr.getAttribute('data-is-gcode') === 'true';
if (!isGcode) {
actionsHtml += `<a href="${platerUrl}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>\n`;
}
if (status === 'sliced') {
const downloadUrl = `{{ url_for('main.download_gcode', file_id=999999999) }}`.replace('999999999', id);

View File

@@ -4,7 +4,8 @@
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-eye text-primary me-2"></i>{{ _('GCode Preview') }}: {{ file.original_filename }}</h1>
<div>
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-primary btn-sm rounded shadow-sm"><i class="bi bi-download"></i> {{ _('Download GCode') }}</a>
<a href="{{ url_for('printer.prepare') }}#file-{{ file.id }}" class="btn btn-warning btn-sm rounded shadow-sm fw-bold"><i class="bi bi-printer"></i> {{ _('Go to Print') }}</a>
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-primary btn-sm rounded shadow-sm ms-2"><i class="bi bi-download"></i> {{ _('Download GCode') }}</a>
<a href="{{ url_for('main.files') }}" class="btn btn-outline-secondary btn-sm rounded ms-2 shadow-sm"><i class="bi bi-arrow-left"></i> {{ _('Back') }}</a>
</div>
</div>

View File

@@ -6,11 +6,50 @@
</div>
<div class="row">
<div class="col-md-4">
<!-- STL Files Stats -->
<div class="col-md-6">
<div class="card text-white bg-primary mb-3 shadow-sm border-0">
<div class="card-header border-0 fs-5 fw-medium"><i class="bi bi-bar-chart-fill me-2"></i>{{ _('Total Prints') }}</div>
<div class="card-header border-0 fs-5 fw-medium">
<i class="bi bi-box me-2"></i>{{ _('3D Model Files (STL)') }}
</div>
<div class="card-body mt-2">
<h5 class="card-title">{{ _('You have sliced') }} <b class="fs-1 mx-2">{{ current_user.print_files|length }}</b> {{ _('files') }}</h5>
<h5 class="card-title mb-3">
{{ _('You have uploaded') }} <b class="fs-1 mx-2">{{ stl_count }}</b> {{ _('files') }}
</h5>
<p class="card-text mb-2">
<i class="bi bi-hdd-fill me-1"></i>{{ _('Total Space Used') }}: <strong>{{ format_size(stl_used_bytes) }}</strong>
{% if stl_quota_mb > 0 %} / <strong>{{ stl_quota_mb }} MB</strong> <small class="opacity-75">({{ _('Quota') }})</small>{% else %} <small class="opacity-75">({{ _('Unlimited') }})</small>{% endif %}
</p>
{% if stl_quota_mb > 0 %}
{% set stl_percent = (stl_used_bytes / (stl_quota_mb * 1024 * 1024) * 100)|round(1) %}
<div class="progress bg-white bg-opacity-25" style="height: 8px;">
<div class="progress-bar {% if stl_percent > 90 %}bg-danger{% elif stl_percent > 75 %}bg-warning{% else %}bg-info{% endif %}" role="progressbar" style="width: {{ stl_percent if stl_percent <= 100 else 100 }}%"></div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- GCode Files Stats -->
<div class="col-md-6">
<div class="card text-white bg-success mb-3 shadow-sm border-0">
<div class="card-header border-0 fs-5 fw-medium">
<i class="bi bi-file-earmark-code me-2"></i>{{ _('Sliced Files (GCode)') }}
</div>
<div class="card-body mt-2">
<h5 class="card-title mb-3">
{{ _('You have sliced or uploaded') }} <b class="fs-1 mx-2">{{ gcode_count }}</b> {{ _('files') }}
</h5>
<p class="card-text mb-2">
<i class="bi bi-hdd-network-fill me-1"></i>{{ _('Total Space Used') }}: <strong>{{ format_size(gcode_used_bytes) }}</strong>
{% if gcode_quota_mb > 0 %} / <strong>{{ gcode_quota_mb }} MB</strong> <small class="opacity-75">({{ _('Quota') }})</small>{% else %} <small class="opacity-75">({{ _('Unlimited') }})</small>{% endif %}
</p>
{% if gcode_quota_mb > 0 %}
{% set gc_percent = (gcode_used_bytes / (gcode_quota_mb * 1024 * 1024) * 100)|round(1) %}
<div class="progress bg-white bg-opacity-25" style="height: 8px;">
<div class="progress-bar {% if gc_percent > 90 %}bg-danger{% elif gc_percent > 75 %}bg-warning{% else %}bg-info{% endif %}" role="progressbar" style="width: {{ gc_percent if gc_percent <= 100 else 100 }}%"></div>
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -148,6 +148,11 @@
<script>
// Toggle icons on collapse
document.addEventListener('DOMContentLoaded', function() {
{% if quota_exceeded %}
window.customConfirm("{{ _('GCode Storage Quota Exceeded. Please delete some files first.') }}",()=>{window.location.href = "{{ url_for('main.files') }}"});
return;
{% endif %}
const cards = document.querySelectorAll('.collapse');
cards.forEach(card => {
card.addEventListener('show.bs.collapse', function () {
@@ -861,7 +866,7 @@ function mergeAndSlice() {
if (m.userData.geomTrans) {
mat.multiply(m.userData.geomTrans);
}
const translation = new THREE.Matrix4().makeTranslation((bedWidth / 2) + offsetX, (bedDepth / 2) + offsetY, 0);
const translation = new THREE.Matrix4().makeTranslation(offsetX,offsetY, 0);
mat.premultiply(translation);
return {
file_id: m.userData.fileId,
@@ -896,7 +901,11 @@ function mergeAndSlice() {
if(data.success) {
window.location.href = "{{ url_for('main.files') }}";
} else {
window.customAlert("{{ _('Error:') }} " + data.error);
let errorMsg = data.error;
if (errorMsg === 'GCode Storage Quota Exceeded. Please delete some files first.') {
errorMsg = "{{ _('GCode Storage Quota Exceeded. Please delete some files first.') }}";
}
window.customAlert("{{ _('Error:') }} " + errorMsg);
btn.disabled = false;
icon.className = 'bi bi-gear-fill me-2';
text.innerText = '{{ _("Merge & Slice") }}';

View File

@@ -73,6 +73,24 @@ class OctoPrintClient:
payload = {"command": "select", "print": print_after_select}
return self._request("POST", f"/api/files/{location}/{path}", json=payload)
def upload_file(self, location, path_to_file, filename_override=None):
"""Upload a file to OctoPrint"""
with open(path_to_file, 'rb') as f:
files = {'file': (filename_override or path_to_file.split('/')[-1], f, 'application/octet-stream')}
url = urljoin(self.base_url, f"/api/files/{location}")
response = self.session.post(url, files=files)
response.raise_for_status()
if response.status_code == 204:
return True
try:
return response.json()
except ValueError:
return response.text
def delete_file(self, location, path):
"""Delete a file from OctoPrint"""
return self._request("DELETE", f"/api/files/{location}/{path}")
# -------------------------------------------------------------------------
# Printer Status
# -------------------------------------------------------------------------

View File

@@ -17,6 +17,13 @@ huey_db_path = os.path.join(instance_dir, 'huey_queue.db')
huey = SqliteHuey(filename=huey_db_path)
def get_gcode_dir(app):
with app.app_context():
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 app.config['UPLOAD_FOLDER']
@huey.task()
def slice_stl_task(file_id, stl_filepath, quality_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False):
@@ -31,7 +38,7 @@ def slice_stl_task(file_id, stl_filepath, quality_preset=None, infill_density=No
# Cache variables and commit slicing status
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
gcode_filepath = os.path.join(app.config['UPLOAD_FOLDER'], gcode_filename)
gcode_filepath = os.path.join(get_gcode_dir(app), gcode_filename)
print_file.status = 'slicing'
db.session.commit()

297
app/utils/tasks.py.orig Normal file
View File

@@ -0,0 +1,297 @@
from huey import SqliteHuey
import subprocess
import os
from app.models import db, PrintFile, SystemConfig
from app.utils.conf_parse import ConfParse
import json
import uuid
import configparser
import os
# Ensure instance directory exists
instance_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'instance')
os.makedirs(instance_dir, exist_ok=True)
huey_db_path = os.path.join(instance_dir, 'huey_queue.db')
huey = SqliteHuey(filename=huey_db_path)
@huey.task()
def slice_stl_task(file_id, stl_filepath, quality_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False):
# This is run by the Huey worker
# We need to create an app context to interact with the database
from app import create_app
app = create_app()
with app.app_context():
print_file = PrintFile.query.get(file_id)
if not print_file:
return
# Cache variables and commit slicing status
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
gcode_filepath = os.path.join(app.config['UPLOAD_FOLDER'], gcode_filename)
print_file.status = 'slicing'
db.session.commit()
# Remove DB session to avoid locking the sqlite db during long slicing operations
db.session.remove()
tmp_def_path = None
try:
# Create Cura engine options
# use our local minimal configurations detached from the entire Cura framework
print_config_path = os.path.abspath(os.path.join(app.root_path, '..', 'print_config'))
printers_path = os.path.join(print_config_path, 'printers')
extruders_path = os.path.join(print_config_path, 'extruders')
materials_path = os.path.join(print_config_path, 'materials')
presets_path = os.path.join(print_config_path, 'quality')
variants_path = os.path.join(print_config_path, 'variants')
env = os.environ.copy()
env["CURA_ENGINE_SEARCH_PATH"] = f"{printers_path}:{extruders_path}:{materials_path}:{presets_path}:{variants_path}"
def_files = [
os.path.join(printers_path, "fdmprinter.def.json"),
os.path.join(printers_path, "fdmextruder.def.json"),
os.path.join(printers_path, "creality_base.def.json"),
os.path.join(printers_path, "creality_ender3v3se.def.json")
]
inst_files_list = []
if quality_preset:
config = configparser.ConfigParser()
preset_path = os.path.join(presets_path, 'creality', 'presets', quality_preset)
if os.path.exists(preset_path):
config.read(preset_path)
material_type = config.get('metadata', 'material', fallback=None)
variant_type = config.get('metadata', 'variant', fallback=None)
quality_type = config.get('metadata', 'quality_type', fallback=None)
if material_type:
m_path = os.path.join(materials_path, f"{material_type}.inst.cfg")
if os.path.exists(m_path): inst_files_list.append(m_path)
if variant_type:
variant_d = variant_type.split("mm")[0]
v_path = os.path.join(variants_path, "creality", f"creality_ender3v3se_{variant_d}.inst.cfg")
if os.path.exists(v_path): inst_files_list.append(v_path)
if support_pattern == 'tree':
t_path = os.path.join(print_config_path, 'supports', 'tree.inst.cfg')
if os.path.exists(t_path): inst_files_list.append(t_path)
elif support_pattern and support_pattern != 'false':
n_path = os.path.join(print_config_path, 'supports', 'normal.inst.cfg')
if os.path.exists(n_path): inst_files_list.append(n_path)
if quality_preset and quality_type:
g_path = os.path.join(presets_path, 'creality', 'globals', f"{quality_type}.inst.cfg")
if os.path.exists(g_path): inst_files_list.append(g_path)
if quality_preset and os.path.exists(preset_path):
inst_files_list.append(preset_path)
p = ConfParse(def_files)
settings_with_inst = p.add_inst_cfg(inst_files_list)
if infill_density is not None:
if "infill_sparse_density" not in settings_with_inst: settings_with_inst["infill_sparse_density"] = {}
settings_with_inst["infill_sparse_density"]["value"] = str(infill_density)
if "infill_line_distance" not in settings_with_inst: settings_with_inst["infill_line_distance"] = {}
settings_with_inst["infill_line_distance"]["value"] = str(100 / int(infill_density)) if int(infill_density) > 0 else "9999"
if support_enable is not None:
if "support_enable" not in settings_with_inst: settings_with_inst["support_enable"] = {}
settings_with_inst["support_enable"]["value"] = True if support_enable in ['true', 'buildplate'] else False
if "support_type" not in settings_with_inst: settings_with_inst["support_type"] = {}
settings_with_inst["support_type"]["value"] = "'buildplate'" if support_enable == 'buildplate' else "'everywhere'"
if support_pattern == 'tree':
if "support_structure" not in settings_with_inst: settings_with_inst["support_structure"] = {}
settings_with_inst["support_structure"]["value"] = "'tree'"
elif support_pattern in settings_with_inst["support_pattern"]["options"].keys():
if "support_structure" not in settings_with_inst: settings_with_inst["support_structure"] = {}
settings_with_inst["support_structure"]["value"] = "'normal'"
if "support_pattern" not in settings_with_inst: settings_with_inst["support_pattern"] = {}
settings_with_inst["support_pattern"]["value"] = f"'{support_pattern}'"
# Parse to exact values
res = p.parse_configs(settings_with_inst)
override_dict = {}
for k, v in res.items():
if v.get("enabled", True):
val = v.get("value", None)
if val is not None:
# Filter out our protective ConfigStr wrappers
# if type(val).__name__ == "ConfigStr": pass
# else: override_dict[k] = {"default_value": val}
override_dict[k] = {"value": val,"default_value": val}
elif "default_value" in v:
override_dict[k] = {"default_value": v["default_value"], "value": v["default_value"]}
tmp_def_filename = f"tmp_{uuid.uuid4().hex}.def.json"
tmp_def_path = os.path.join(app.config['UPLOAD_FOLDER'], tmp_def_filename)
tmp_def_obj = {
"version": 2,
"name": "TempProfile",
"inherits": "fdmprinter",
"metadata": {
"visible": True,
"author": "System",
"manufacturer": "System",
"file_formats": "text/x-gcode",
"first_start_actions": ["MachineSettingsAction"],
"has_materials": True,
"has_variants": True,
"has_machine_quality": True,
"variants_name": "Nozzle Size",
"preferred_variant_name": "0.4mm Nozzle",
"preferred_quality_type": "standard",
"preferred_material": "generic_pla",
},
"overrides": override_dict
}
pretty_json = json.dumps(tmp_def_obj, indent=4)
with open(tmp_def_path, "w") as f:
f.write(pretty_json)
command = [
"CuraEngine", "slice",
"-j", tmp_def_path,
"-l", stl_filepath,
"-o", gcode_filepath
]
app.logger.info(f"Running command: {' '.join(command)}")
# print(f"Running command: {' '.join(command)}")
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
stdout, stderr = process.communicate()
# if stdout:
# print(f"[CuraEngine STDOUT]\n{stdout.decode('utf-8', errors='ignore')}")
# if stderr:
# print(f"[CuraEngine STDERR]\n{stderr.decode('utf-8', errors='ignore')}", flush=True)
# Re-fetch print_file and update status
print_file = PrintFile.query.get(file_id)
if not print_file:
return
if process.returncode == 0:
print_file.status = 'sliced'
else:
print_file.status = 'failed'
app.logger.error(f"CuraEngine Error: {stderr.decode()}")
except Exception as e:
# Re-fetch in case of exception
print_file = PrintFile.query.get(file_id)
if print_file:
print_file.status = 'failed'
app.logger.error(f"Subprocess Exception: {e}")
if delete_stl and os.path.exists(stl_filepath):
try:
os.remove(stl_filepath)
except Exception as e:
app.logger.error(f"Failed to delete temp STL {stl_filepath}: {e}")
if tmp_def_path and os.path.exists(tmp_def_path):
try:
os.remove(tmp_def_path)
# pass
except Exception as e:
app.logger.error(f"Failed to delete temp JSON config {tmp_def_path}: {e}")
db.session.commit()
db.session.remove()
@huey.task()
def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False):
from app import create_app
app = create_app()
with app.app_context():
from app.models import PrintFile, db
print_file = PrintFile.query.get(file_id)
if not print_file:
return
db.session.remove()
try:
from app.utils.stl_merger import merge_stls
merge_stls(inputs, merged_filepath)
# Now trigger the regular slicing task
# We can just call the slicing logic or enqueue it
slice_stl_task(file_id, merged_filepath, quality_preset, infill_density, support_enable, support_pattern, delete_stl=delete_stl)
except Exception as e:
print_file = PrintFile.query.get(file_id)
if print_file:
print_file.status = 'failed'
db.session.commit()
app.logger.error(f"Merge Exception: {e}")
finally:
db.session.remove()
@huey.task()
def simplify_stl_task(file_id, filepath):
from app import create_app
app = create_app()
with app.app_context():
from app.models import PrintFile, SystemConfig, db
import os
from app.utils.stl_simplifier import simplify_stl
print_file = PrintFile.query.get(file_id)
if not print_file:
return
try:
file_size_mb = os.path.getsize(filepath) / (1024 * 1024)
configs = {c.key: c.value for c in SystemConfig.query.all()}
skip_size = float(configs.get('proxy_skip_size_mb', '5.0'))
proxy_path = filepath + '.proxy.stl'
if file_size_mb <= skip_size:
# File is small enough, no proxy needed
print_file.status = 'uploaded'
db.session.commit()
return
# Aim for approx 7.5 MB for the proxy
target_mb = 7.5
keep_ratio = target_mb / file_size_mb
if keep_ratio > 1.0:
keep_ratio = 1.0
elif keep_ratio < 0.01:
keep_ratio = 0.01
app.logger.info(f"Simplifying {filepath}... Size: {file_size_mb:.2f}MB, Target Ratio: {keep_ratio:.3f}")
simplify_stl(filepath, proxy_path, keep_ratio=keep_ratio)
except Exception as e:
app.logger.error(f"Simplify task error: {e}")
# Update status to uploaded regardless of success or failure of proxy generation
# So the user can still slice or download it
print_file = PrintFile.query.get(file_id)
if print_file:
print_file.status = 'uploaded'
db.session.commit()
db.session.remove()

BIN
out.stl

Binary file not shown.

View File

@@ -1,178 +0,0 @@
import re
with open('app/routes/printer_routes.py', 'r', encoding='utf-8') as f:
content = f.read()
# Find everything between @sock.route('/proxy', bp=printer_bp) and def octo_proxy(path):
start_str = "@sock.route('/proxy', bp=printer_bp)"
end_str = "@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])"
pre_content = content[:content.find(start_str)]
post_content = content[content.find(end_str):]
new_proxy = """@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
@printer_bp.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
@login_required
def octo_proxy(path):
if not current_user.is_admin:
return "Unauthorized", 403
url_config = SystemConfig.query.filter_by(key='octoprint_url').first()
if not url_config or not url_config.value:
return "OctoPrint URL not configured", 404
base_url = url_config.value.rstrip('/')
# --- 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:
return "WebSocket Upgrade Failed", 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')}"
# Forward essential headers like NGINX Proxy
headers = {
'Host': request.host,
'X-Real-IP': request.remote_addr,
}
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-Proto'] = request.scheme
headers['X-Forwarded-Host'] = request.host
headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
if request.headers.get('Cookie'):
headers['Cookie'] = request.headers.get('Cookie')
try:
remote_ws = ws_connect(target_url, additional_headers=headers)
except Exception as e:
ws.close(1011, str(e))
return
def recv_loop():
try:
for message in remote_ws:
ws.send(message)
except Exception:
pass
finally:
try: remote_ws.close()
except: pass
try: ws.close()
except: pass
t = threading.Thread(target=recv_loop)
t.daemon = True
t.start()
try:
while True:
data = ws.receive()
if data is None:
break
remote_ws.send(data)
except Exception:
pass
finally:
try: remote_ws.close()
except: pass
try: ws.close()
except: pass
try:
handle_ws()
except ConnectionClosed:
pass
except Exception:
pass
finally:
try: ws.close()
except: pass
class WebSocketResponse(Response):
def __call__(self, *args, **kwargs):
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 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']}
# 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-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,
headers=headers,
data=request.get_data(),
cookies=request.cookies,
allow_redirects=False,
stream=True,
timeout=(60, 600)
)
except requests.exceptions.RequestException as e:
return f"Proxy connection error: {str(e)}", 502
# 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]
def generate():
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
yield chunk
return Response(generate(), resp.status_code, response_headers)
"""
post_end_str = " return Response(generate(), resp.status_code, response_headers)"
post_end_idx = post_content.find(post_end_str) + len(post_end_str)
final_content = pre_content + new_proxy + post_content[post_end_idx:]
with open('app/routes/printer_routes.py', 'w', encoding='utf-8') as f:
f.write(final_content)
print("Patched!")

View File

@@ -1,45 +0,0 @@
import re
with open('app/routes/printer_routes.py', 'r', encoding='utf-8') as f:
content = f.read()
# Replace the WS Proxy header logic
target_str = """ ws_headers['Host'] = request.host
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'))"""
replacement_str = """ ws_headers['Host'] = request.host
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'"""
new_content = content.replace(target_str, replacement_str)
origin_str = """ # Mask Origin/Referer to bypass Octoprint CSRF if needed
if 'Origin' in ws_headers:
ws_headers['Origin'] = base_url
if 'Referer' in ws_headers:
ws_headers['Referer'] = f"{base_url}/{path}\""""
replacement_origin = """ # 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 ws_headers:
ws_headers['Origin'] = base_url
if 'Referer' in ws_headers:
ws_headers['Referer'] = f"{base_url}/{path}\""""
new_content = new_content.replace(origin_str, replacement_origin)
with open('app/routes/printer_routes.py', 'w', encoding='utf-8') as f:
f.write(new_content)
print('Patched!')

View File

@@ -1,2 +0,0 @@
import flask_sock
print(dir(flask_sock))

View File

@@ -1,3 +0,0 @@
from flask_sock import Sock
import inspect
print(inspect.getsource(Sock.__init__))

View File

@@ -1,3 +0,0 @@
from flask_sock import Sock
import inspect
print(inspect.getsource(Sock.route))

View File

@@ -1,3 +0,0 @@
from flask_sock import Sock
import inspect
print(inspect.getsource(Sock.init_app))

View File

@@ -1,3 +0,0 @@
from flask_sock import Sock
import inspect
print(inspect.getsource(Sock.__init__))

View File

@@ -1,3 +0,0 @@
from flask_sock import Sock
import inspect
print(dir(Sock))

View File

@@ -1,4 +0,0 @@
import inspect
import flask_sock
from flask_sock import Server
print(Server)

View File

@@ -1,53 +1,178 @@
import re
with open('app/routes.py', 'r', encoding='utf-8') as f:
text = f.read()
with open('app/routes/printer_routes.py', 'r', encoding='utf-8') as f:
content = f.read()
# Add serve_proxy_file after serve_file
serve_file_code = """@main_bp.route('/file/<int:file_id>')
@login_required
def serve_file(file_id):
f = PrintFile.query.get_or_404(file_id)
if f.user_id != current_user.id and not current_user.is_admin:
abort(403)
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
return send_file(path)"""
# Find everything between @sock.route('/proxy', bp=printer_bp) and def octo_proxy(path):
start_str = "@sock.route('/proxy', bp=printer_bp)"
end_str = "@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])"
proxy_code = """@main_bp.route('/file/<int:file_id>')
@login_required
def serve_file(file_id):
f = PrintFile.query.get_or_404(file_id)
if f.user_id != current_user.id and not current_user.is_admin:
abort(403)
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
return send_file(path)
pre_content = content[:content.find(start_str)]
post_content = content[content.find(end_str):]
@main_bp.route('/proxy/<int:file_id>')
new_proxy = """@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
@printer_bp.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
@login_required
def serve_proxy_file(file_id):
f = PrintFile.query.get_or_404(file_id)
if f.user_id != current_user.id and not current_user.is_admin:
abort(403)
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
proxy_path = path + '.proxy.stl'
if not os.path.exists(proxy_path):
from stl_simplifier import simplify_stl
def octo_proxy(path):
if not current_user.is_admin:
return "Unauthorized", 403
url_config = SystemConfig.query.filter_by(key='octoprint_url').first()
if not url_config or not url_config.value:
return "OctoPrint URL not configured", 404
base_url = url_config.value.rstrip('/')
# --- WebSocket Proxy Logic ---
if request.headers.get('Upgrade', '').lower() == 'websocket':
from flask_sock import Server, ConnectionClosed
# Check if environment supports WebSockets
try:
simplify_stl(path, proxy_path, keep_ratio=0.1) # compress to 10%
except:
return send_file(path) # fallback to original if error
if os.path.exists(proxy_path):
return send_file(proxy_path)
return send_file(path)"""
ws = Server(request.environ)
except Exception as e:
return "WebSocket Upgrade Failed", 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')}"
text = text.replace(serve_file_code, proxy_code)
# Forward essential headers like NGINX Proxy
headers = {
'Host': request.host,
'X-Real-IP': request.remote_addr,
}
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-Proto'] = request.scheme
headers['X-Forwarded-Host'] = request.host
headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
if request.headers.get('Cookie'):
headers['Cookie'] = request.headers.get('Cookie')
# Change plater models to use serve_proxy_file
old_str = "url_for('main.serve_file', file_id=f.id)"
new_str = "url_for('main.serve_proxy_file', file_id=f.id)"
try:
remote_ws = ws_connect(target_url, additional_headers=headers)
except Exception as e:
ws.close(1011, str(e))
return
text = text.replace(old_str, new_str)
def recv_loop():
try:
for message in remote_ws:
ws.send(message)
except Exception:
pass
finally:
try: remote_ws.close()
except: pass
try: ws.close()
except: pass
with open('app/routes.py', 'w', encoding='utf-8') as f:
f.write(text)
print("Patched successfully")
t = threading.Thread(target=recv_loop)
t.daemon = True
t.start()
try:
while True:
data = ws.receive()
if data is None:
break
remote_ws.send(data)
except Exception:
pass
finally:
try: remote_ws.close()
except: pass
try: ws.close()
except: pass
try:
handle_ws()
except ConnectionClosed:
pass
except Exception:
pass
finally:
try: ws.close()
except: pass
class WebSocketResponse(Response):
def __call__(self, *args, **kwargs):
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 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']}
# 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-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,
headers=headers,
data=request.get_data(),
cookies=request.cookies,
allow_redirects=False,
stream=True,
timeout=(60, 600)
)
except requests.exceptions.RequestException as e:
return f"Proxy connection error: {str(e)}", 502
# 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]
def generate():
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
yield chunk
return Response(generate(), resp.status_code, response_headers)
"""
post_end_str = " return Response(generate(), resp.status_code, response_headers)"
post_end_idx = post_content.find(post_end_str) + len(post_end_str)
final_content = pre_content + new_proxy + post_content[post_end_idx:]
with open('app/routes/printer_routes.py', 'w', encoding='utf-8') as f:
f.write(final_content)
print("Patched!")