Merge branch 'maziggy:main' into feature/accurate-usage-tracking

This commit is contained in:
BambuMan
2026-02-05 19:53:52 +02:00
committed by GitHub
16 changed files with 204 additions and 35 deletions
+2
View File
@@ -79,6 +79,7 @@ jobs:
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH,MEDIUM'
version: 'v0.69.1'
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v4
@@ -95,6 +96,7 @@ jobs:
format: 'sarif'
output: 'trivy-config-results.sarif'
severity: 'CRITICAL,HIGH,MEDIUM'
version: 'v0.69.1'
- name: Upload Trivy config results
uses: github/codeql-action/upload-sarif@v4
+43 -1
View File
@@ -3,7 +3,24 @@
All notable changes to Bambuddy will be documented in this file.
## [0.1.8b] - Not released
## [0.1.8] - Not released
### Security
- **XML External Entity (XXE) Prevention**:
- Replaced `xml.etree.ElementTree` with `defusedxml` across all 3MF parsing code
- Prevents XXE attacks through malicious 3MF files
- Detected by Bandit B314 security scanner
- **Path Injection Vulnerabilities Fixed**:
- Added path traversal validation to project attachment endpoints
- Strengthened filename sanitization in timelapse processing
- Prevents directory traversal attacks via `../` sequences
- Detected by CodeQL security scanner
- **Security Scanning in CI/CD**:
- Added Bandit (Python security analyzer) with SARIF upload to GitHub Security
- Added Trivy (container/IaC scanner) for Docker image and Dockerfile analysis
- Added pip-audit and npm-audit for dependency vulnerability scanning
- Automatic GitHub issue creation for detected vulnerabilities
- Security scan results visible in GitHub Security tab
### Enhanced
- **3D Model Viewer Improvements** (PR #262):
@@ -53,6 +70,31 @@ All notable changes to Bambuddy will be documented in this file.
- Previously used `find()` which always returned the first match regardless of color
- Fixed in both backend (print_scheduler.py) and frontend (useFilamentMapping.ts)
- Resolves wrong tray selection (e.g., A4 instead of B1) when multiple AMS units have same filament type
- **A1/A1 Mini FTP Upload Failures** (Issue #271):
- Fixed FTP uploads hanging/timing out on A1 and A1 Mini printers
- Replaced `storbinary()` with manual chunked transfer using `transfercmd()`
- A1's FTP server has issues with Python's `storbinary()` waiting for completion response
- Uses 1MB chunks with explicit 120s socket timeout for reliable transfers
- Works for all printer models (X1C, P1S, P1P, A1, A1 Mini)
- **P1S/P1P FTP Upload Failures**:
- Fixed FTP uploads failing with EOFError on P1S and P1P printers
- These printers use vsFTPd which requires SSL session reuse on data channel
- Removed P1S/P1P from skip-session-reuse list (they were incorrectly added)
- **FTP Auto-Detection for A1 Printers**:
- Automatically detects working FTP mode (prot_p vs prot_c) for A1/A1 Mini
- Tries encrypted data channel first, falls back to clear if needed
- Caches working mode per printer IP to avoid repeated detection
- **Safari Camera Stream Failing**:
- Fixed camera streams not loading in Safari due to Service Worker error
- Safari has stricter Service Worker scope requirements
- **Queue Print Time for Multi-Plate Files** (PR #274):
- Fixed print time showing total for all plates instead of selected plate
- Now extracts per-plate print time from 3MF slice_info.config
- Contributed by MisterBeardy
- **Docker Permissions**:
- Added user directive to docker-compose.yml using PUID/PGID environment variables
- Allows container to run as host user, fixing permission issues with bind-mounted volumes
- Usage: `PUID=$(id -u) PGID=$(id -g) docker compose up -d`
### Added
- **Windows Portable Launcher** (contributed by nmori):
+14 -6
View File
@@ -1590,7 +1590,10 @@ async def process_timelapse(
raise HTTPException(400, "Audio must be .mp3, .wav, .m4a, .aac, or .ogg")
audio_content = await audio.read()
suffix = Path(audio.filename).suffix
# Extract and validate suffix to prevent path injection
suffix = Path(audio.filename).suffix.lower()
if suffix not in (".mp3", ".wav", ".m4a", ".aac", ".ogg"):
raise HTTPException(400, "Invalid audio file extension")
audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}{suffix}"
audio_temp_path.write_bytes(audio_content)
@@ -1605,8 +1608,11 @@ async def process_timelapse(
else:
# Save as new file alongside original
filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
# Sanitize filename
# Sanitize filename - remove path separators and traversal sequences
filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
# Prevent path traversal
if ".." in filename or not filename or filename.startswith("."):
filename = f"timelapse_{archive_id}_edited"
if not filename.endswith(".mp4"):
filename += ".mp4"
output_path = archive_dir / filename
@@ -1833,7 +1839,8 @@ async def get_archive_capabilities(
):
"""Check what viewing capabilities are available for this 3MF file."""
import json
import xml.etree.ElementTree as ET
import defusedxml.ElementTree as ET
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
@@ -2113,7 +2120,7 @@ async def get_plate_preview(
plate_num = 1
if "Metadata/slice_info.config" in names:
try:
import xml.etree.ElementTree as ET
import defusedxml.ElementTree as ET
slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
root = ET.fromstring(slice_content)
@@ -2254,7 +2261,8 @@ async def get_archive_plates(
"""
import json
import re
import xml.etree.ElementTree as ET
import defusedxml.ElementTree as ET
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
@@ -2560,7 +2568,7 @@ async def get_filament_requirements(
archive_id: The archive ID
plate_id: Optional plate index to filter filaments for (for multi-plate files)
"""
import xml.etree.ElementTree as ET
import defusedxml.ElementTree as ET
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
+4 -2
View File
@@ -1312,9 +1312,10 @@ async def get_library_file_plates(
"""
import json
import re
import xml.etree.ElementTree as ET
import zipfile
import defusedxml.ElementTree as ET
# Get the library file
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
lib_file = result.scalar_one_or_none()
@@ -1613,9 +1614,10 @@ async def get_library_file_filament_requirements(
file_id: The library file ID
plate_id: Optional plate index to get filaments for a specific plate
"""
import xml.etree.ElementTree as ET
import zipfile
import defusedxml.ElementTree as ET
# Get the library file
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
lib_file = result.scalar_one_or_none()
+66 -1
View File
@@ -2,11 +2,11 @@
import json
import logging
import xml.etree.ElementTree as ET
import zipfile
from datetime import datetime
from pathlib import Path
import defusedxml.ElementTree as ET
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -98,6 +98,58 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
return sorted(types)
def _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
"""Extract print time (prediction) from a 3MF file.
Args:
file_path: Path to the 3MF file
plate_id: Optional plate index to filter for (for multi-plate files)
Returns:
Print time in seconds, or None if not found
"""
try:
with zipfile.ZipFile(file_path, "r") as zf:
if "Metadata/slice_info.config" not in zf.namelist():
return None
content = zf.read("Metadata/slice_info.config").decode()
root = ET.fromstring(content)
if plate_id is not None:
for plate_elem in root.findall(".//plate"):
plate_index = None
for meta in plate_elem.findall("metadata"):
if meta.get("key") == "index":
try:
plate_index = int(meta.get("value", "0"))
except ValueError:
pass
break
if plate_index == plate_id:
for meta in plate_elem.findall("metadata"):
if meta.get("key") == "prediction":
try:
return int(meta.get("value", "0"))
except ValueError:
return None
break
else:
plate_elem = root.find(".//plate")
if plate_elem is not None:
for meta in plate_elem.findall("metadata"):
if meta.get("key") == "prediction":
try:
return int(meta.get("value", "0"))
except ValueError:
return None
except Exception as e:
logger.warning(f"Failed to extract print time from {file_path}: {e}")
return None
def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
"""Add nested archive/printer/library_file info to response."""
# Parse ams_mapping from JSON string BEFORE model_validate
@@ -153,6 +205,12 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
response.archive_name = item.archive.print_name or item.archive.filename
response.archive_thumbnail = item.archive.thumbnail_path
response.print_time_seconds = item.archive.print_time_seconds
if item.plate_id:
archive_path = settings.base_dir / item.archive.file_path
if archive_path.exists():
plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
if plate_time is not None:
response.print_time_seconds = plate_time
if item.library_file:
response.library_file_name = (
item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -163,6 +221,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
# Get print time from library file metadata if no archive
if not item.archive and item.library_file.file_metadata:
response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
if item.plate_id:
lib_path = Path(item.library_file.file_path)
library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
if library_file_path.exists():
plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
if plate_time is not None:
response.print_time_seconds = plate_time
if item.printer:
response.printer_name = item.printer.name
return response
+2 -1
View File
@@ -852,9 +852,10 @@ async def get_printer_file_plates(
"""Get available plates from a multi-plate 3MF file stored on a printer."""
import io
import json
import xml.etree.ElementTree as ET
import zipfile
import defusedxml.ElementTree as ET
# Validate printer
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
+8
View File
@@ -907,6 +907,10 @@ async def download_attachment(
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
):
"""Download an attachment from a project."""
# Validate filename to prevent path traversal
if "/" in filename or "\\" in filename or ".." in filename or not filename:
raise HTTPException(status_code=400, detail="Invalid filename")
# Verify project exists
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
@@ -939,6 +943,10 @@ async def delete_attachment(
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Delete an attachment from a project."""
# Validate filename to prevent path traversal
if "/" in filename or "\\" in filename or ".." in filename or not filename:
raise HTTPException(status_code=400, detail="Invalid filename")
# Verify project exists
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
+1 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from pydantic_settings import BaseSettings
# Application version - single source of truth
APP_VERSION = "0.1.8b"
APP_VERSION = "0.1.8"
GITHUB_REPO = "maziggy/bambuddy"
# App directory - where the application is installed (for static files)
+1 -1
View File
@@ -6,8 +6,8 @@ import shutil
import zipfile
from datetime import datetime
from pathlib import Path
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as ET
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
+50 -9
View File
@@ -80,6 +80,9 @@ class BambuFTPClient:
# Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
# These models have varying FTP SSL behavior depending on firmware version
A1_MODELS = ("A1", "A1 Mini")
# Chunk size for manual upload transfer (1MB)
# Larger chunks reduce overhead and work better with A1 printers
CHUNK_SIZE = 1024 * 1024
# Cache for working FTP modes per printer IP
# Maps IP -> "prot_p" or "prot_c"
@@ -363,15 +366,37 @@ class BambuFTPClient:
uploaded = 0
def on_block(block: bytes):
nonlocal uploaded
uploaded += len(block)
if progress_callback:
progress_callback(uploaded, file_size)
# Use manual transfer instead of storbinary() for A1 compatibility
# A1 printers have issues with storbinary's voidresp() hanging after transfer
with open(local_path, "rb") as f:
logger.debug(f"FTP STOR command starting for {remote_path}")
self._ftp.storbinary(f"STOR {remote_path}", f, callback=on_block)
conn = self._ftp.transfercmd(f"STOR {remote_path}")
# Set explicit socket options for reliable transfer
conn.setblocking(True)
conn.settimeout(120) # 2 minute timeout per chunk
try:
while True:
chunk = f.read(self.CHUNK_SIZE)
if not chunk:
logger.debug("FTP upload: final chunk reached")
break
conn.sendall(chunk)
uploaded += len(chunk)
logger.debug(f"FTP upload progress: {uploaded}/{file_size} bytes")
if progress_callback:
progress_callback(uploaded, file_size)
except OSError as e:
logger.error(f"FTP connection lost during upload: {e}")
conn.close()
raise
conn.close()
logger.info(f"FTP upload complete: {remote_path}")
return True
except ftplib.error_perm as e:
@@ -399,8 +424,24 @@ class BambuFTPClient:
return False
try:
buffer = BytesIO(data)
self._ftp.storbinary(f"STOR {remote_path}", buffer)
# Use manual transfer instead of storbinary() for A1 compatibility
conn = self._ftp.transfercmd(f"STOR {remote_path}")
conn.setblocking(True)
conn.settimeout(120)
try:
# Send data in chunks
offset = 0
while offset < len(data):
chunk = data[offset : offset + self.CHUNK_SIZE]
conn.sendall(chunk)
offset += len(chunk)
except OSError as e:
logger.error(f"FTP connection lost during upload_bytes: {e}")
conn.close()
raise
conn.close()
return True
except Exception:
return False
+1 -1
View File
@@ -3,11 +3,11 @@
import asyncio
import json
import logging
import xml.etree.ElementTree as ET
import zipfile
from datetime import datetime, timedelta
from pathlib import Path
import defusedxml.ElementTree as ET
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -221,7 +221,7 @@ class TestPrintableObjectsExtraction:
def test_extract_printable_objects_from_slice_info(self):
"""Test parsing printable objects from slice_info.config XML."""
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as ET
# Example slice_info.config content with 4 objects
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -250,7 +250,7 @@ class TestPrintableObjectsExtraction:
def test_extract_printable_objects_empty_plate(self):
"""Test handling plate with no objects."""
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as ET
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
<config>
@@ -272,7 +272,7 @@ class TestPrintableObjectsExtraction:
def test_extract_printable_objects_all_skipped(self):
"""Test handling plate where all objects are skipped."""
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as ET
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
<config>
@@ -299,7 +299,7 @@ class TestThreeMFPlateIndexExtraction:
def test_extract_plate_index_from_slice_info(self):
"""Test parsing plate index from slice_info.config metadata."""
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as ET
# Single-plate export from plate 5 of a multi-plate project
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -325,7 +325,7 @@ class TestThreeMFPlateIndexExtraction:
def test_extract_plate_index_plate_1(self):
"""Test parsing plate index when it's plate 1."""
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as ET
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
<config>
@@ -402,7 +402,7 @@ class TestThreeMFPlateIndexExtraction:
def test_high_plate_number_extraction(self):
"""Test extracting high plate numbers (e.g., plate 28)."""
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as ET
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
<config>
@@ -433,7 +433,7 @@ class TestMultiPlate3MFParsing:
def test_parse_multiple_plates_from_slice_info(self):
"""Test parsing multiple plates from slice_info.config."""
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as ET
# Multi-plate 3MF with 3 plates
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
@@ -510,7 +510,7 @@ class TestMultiPlate3MFParsing:
def test_filter_filaments_by_plate_id(self):
"""Test filtering filaments for a specific plate."""
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as ET
slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
<config>
@@ -1,8 +1,7 @@
"""Unit tests for plate object extraction from 3MF model_settings.config."""
from xml.etree import ElementTree as ET
import pytest
from defusedxml import ElementTree as ET
class TestPlateObjectExtraction:
+1
View File
@@ -20,6 +20,7 @@ pyftpdlib>=2.0.0
cryptography>=41.0.0
# 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
defusedxml>=0.7.0 # Safe XML parsing (prevents XXE attacks)
# Excel Export
openpyxl>=3.1.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-Dc108DF_.js"></script>
<script type="module" crossorigin src="/assets/index-CwIN-fI6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CosC5iN4.css">
</head>
<body>