From 164d22f2bb0aefea39426ffc5a2870fc773061f7 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 26 Jan 2026 13:17:43 +0100 Subject: [PATCH 01/11] Add security scanning to CI pipeline - Add pip-audit check to PR workflow (non-blocking warning) - Add npm audit check to PR workflow (non-blocking warning) - Create scheduled weekly security audit workflow that: - Runs strict pip-audit and npm audit - Creates/updates GitHub issues when vulnerabilities found - Uploads audit results as artifacts - Supports manual trigger via workflow_dispatch --- .github/workflows/ci.yml | 45 +++++++ .github/workflows/security.yml | 213 +++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e769a08b..e9f862b8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,28 @@ jobs: - name: Run ruff format check run: ruff format --check backend/ + backend-security: + name: Backend Security + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.actor != github.repository_owner + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pip-audit + + - name: Run pip-audit + run: pip-audit --desc on + backend-tests: name: Backend Tests runs-on: ubuntu-latest @@ -103,6 +125,29 @@ jobs: working-directory: frontend run: npm run lint + frontend-security: + name: Frontend Security + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.actor != github.repository_owner + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Run npm audit + working-directory: frontend + run: npm audit --audit-level=moderate + frontend-typecheck: name: Frontend Type Check runs-on: ubuntu-latest diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 000000000..ae9f89b4a --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,213 @@ +name: Security Audit + +on: + schedule: + # Run weekly on Monday at 6:00 UTC + - cron: '0 6 * * 1' + workflow_dispatch: + # Allow manual trigger + +env: + PYTHON_VERSION: '3.11' + NODE_VERSION: '20' + +jobs: + backend-audit: + name: Backend Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pip-audit + + - name: Run pip-audit + id: pip-audit + run: | + pip-audit --desc on --format json --output pip-audit-results.json || echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT + pip-audit --desc on || true + + - name: Upload audit results + if: always() + uses: actions/upload-artifact@v4 + with: + name: pip-audit-results + path: pip-audit-results.json + retention-days: 30 + + - name: Create issue on vulnerability + if: steps.pip-audit.outputs.vulnerabilities_found == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const results = JSON.parse(fs.readFileSync('pip-audit-results.json', 'utf8')); + + // Build vulnerability table + let table = '| Package | Version | Vulnerability | Fix Version |\n'; + table += '|---------|---------|---------------|-------------|\n'; + + for (const vuln of results.dependencies || []) { + for (const v of vuln.vulns || []) { + table += `| ${vuln.name} | ${vuln.version} | ${v.id} | ${v.fix_versions?.join(', ') || 'N/A'} |\n`; + } + } + + const title = `Security Alert: ${results.dependencies?.reduce((acc, d) => acc + (d.vulns?.length || 0), 0) || 0} Python vulnerabilities found`; + + // Check for existing open issue + const existingIssues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'security,automated' + }); + + const existingIssue = existingIssues.data.find(i => i.title.startsWith('Security Alert:') && i.title.includes('Python')); + + const body = `## Automated Security Audit Results + + The weekly security audit found vulnerabilities in Python dependencies. + + ${table} + + ### Recommended Actions + + 1. Review each vulnerability + 2. Update affected packages: \`pip install --upgrade \` + 3. Run \`pip-audit\` locally to verify fixes + 4. Close this issue when resolved + + --- + *This issue was automatically created by the security audit workflow.*`; + + if (existingIssue) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existingIssue.number, + body: body + }); + console.log(`Updated existing issue #${existingIssue.number}`); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: ['security', 'automated', 'dependencies'] + }); + console.log('Created new security issue'); + } + + frontend-audit: + name: Frontend Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Run npm audit + id: npm-audit + working-directory: frontend + run: | + npm audit --json > npm-audit-results.json || echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT + npm audit --audit-level=moderate || true + + - name: Upload audit results + if: always() + uses: actions/upload-artifact@v4 + with: + name: npm-audit-results + path: frontend/npm-audit-results.json + retention-days: 30 + + - name: Create issue on vulnerability + if: steps.npm-audit.outputs.vulnerabilities_found == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const results = JSON.parse(fs.readFileSync('frontend/npm-audit-results.json', 'utf8')); + + const vulns = results.vulnerabilities || {}; + const vulnCount = Object.keys(vulns).length; + + if (vulnCount === 0) { + console.log('No vulnerabilities to report'); + return; + } + + // Build vulnerability table + let table = '| Package | Severity | Via | Fix |\n'; + table += '|---------|----------|-----|-----|\n'; + + for (const [name, info] of Object.entries(vulns)) { + const via = Array.isArray(info.via) ? info.via.map(v => typeof v === 'string' ? v : v.name).join(', ') : info.via; + table += `| ${name} | ${info.severity} | ${via} | ${info.fixAvailable ? 'Yes' : 'No'} |\n`; + } + + const title = `Security Alert: ${vulnCount} npm vulnerabilities found`; + + // Check for existing open issue + const existingIssues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'security,automated' + }); + + const existingIssue = existingIssues.data.find(i => i.title.startsWith('Security Alert:') && i.title.includes('npm')); + + const body = `## Automated Security Audit Results + + The weekly security audit found vulnerabilities in npm dependencies. + + ${table} + + ### Recommended Actions + + 1. Review each vulnerability: \`npm audit\` + 2. Auto-fix if possible: \`npm audit fix\` + 3. Manual fix for breaking changes: \`npm audit fix --force\` (review changes!) + 4. Close this issue when resolved + + --- + *This issue was automatically created by the security audit workflow.*`; + + if (existingIssue) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existingIssue.number, + body: body + }); + console.log(`Updated existing issue #${existingIssue.number}`); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: ['security', 'automated', 'dependencies'] + }); + console.log('Created new security issue'); + } From 580225a38d15a474853ff387aef93e0db3f9aa95 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 26 Jan 2026 13:20:33 +0100 Subject: [PATCH 02/11] Add security scanning to CI pipeline - Add pip-audit check to PR workflow (non-blocking warning) - Add npm audit check to PR workflow (non-blocking, high severity only) - Create scheduled weekly security audit workflow that: - Runs strict pip-audit and npm audit - Creates/updates GitHub issues when vulnerabilities found - Uploads audit results as artifacts - Supports manual trigger via workflow_dispatch --- .github/workflows/ci.yml | 2 +- .github/workflows/security.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9f862b8c..9d07aecd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,7 +146,7 @@ jobs: - name: Run npm audit working-directory: frontend - run: npm audit --audit-level=moderate + run: npm audit --audit-level=high frontend-typecheck: name: Frontend Type Check diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ae9f89b4a..020103e15 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -130,7 +130,7 @@ jobs: working-directory: frontend run: | npm audit --json > npm-audit-results.json || echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT - npm audit --audit-level=moderate || true + npm audit --audit-level=high || true - name: Upload audit results if: always() From 2da519a86e8a4b25c93a69eb1d68e080179dd480 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 26 Jan 2026 13:24:15 +0100 Subject: [PATCH 03/11] Replace python-jose with PyJWT to eliminate ecdsa vulnerability --- =2.8.0 | 5 +++++ backend/app/core/auth.py | 3 ++- requirements.txt | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 =2.8.0 diff --git a/=2.8.0 b/=2.8.0 new file mode 100644 index 000000000..9f7171c00 --- /dev/null +++ b/=2.8.0 @@ -0,0 +1,5 @@ +Collecting PyJWT + Downloading PyJWT-2.10.1-py3-none-any.whl.metadata (4.0 kB) +Downloading PyJWT-2.10.1-py3-none-any.whl (22 kB) +Installing collected packages: PyJWT +Successfully installed PyJWT-2.10.1 diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index f79a66b49..fb3c1c367 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -4,9 +4,10 @@ import secrets from datetime import datetime, timedelta from typing import Annotated +import jwt from fastapi import Depends, Header, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from jose import JWTError, jwt +from jwt.exceptions import PyJWTError as JWTError from passlib.context import CryptContext from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession diff --git a/requirements.txt b/requirements.txt index 27238e913..1a7fc23e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ qrcode[pil]>=7.4.0 psutil>=6.0.0 # Authentication -python-jose[cryptography]>=3.3.0 +PyJWT>=2.8.0 passlib[bcrypt]>=1.7.4 # Development From 51df60cb91732f8dea4ae44b48c2f835836817f3 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 26 Jan 2026 15:52:23 +0100 Subject: [PATCH 04/11] Fixed CI --- .github/workflows/ci.yml | 4 ++++ .github/workflows/security.yml | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d07aecd3..1354c1ef3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Minimum permissions for all jobs +permissions: + contents: read + jobs: # ============================================================================ # Backend Checks diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 020103e15..957e191ae 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -11,10 +11,17 @@ env: PYTHON_VERSION: '3.11' NODE_VERSION: '20' +# Default permissions for all jobs +permissions: + contents: read + jobs: backend-audit: name: Backend Security Audit runs-on: ubuntu-latest + permissions: + contents: read + issues: write steps: - uses: actions/checkout@v4 @@ -111,6 +118,9 @@ jobs: frontend-audit: name: Frontend Security Audit runs-on: ubuntu-latest + permissions: + contents: read + issues: write steps: - uses: actions/checkout@v4 From aa5ab135c7989b00670ffbb9da763322d22765c8 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 27 Jan 2026 11:14:48 +0100 Subject: [PATCH 05/11] Added stale issues workflow --- .github/workflows/stale.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..f2d4b707d --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,17 @@ +name: Close stale issues + +on: + schedule: + - cron: '0 0 * * *' # Run daily at midnight UTC + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + stale-issue-message: 'This issue has been marked as stale due to inactivity. It will be closed in 7 days if there is no further activity.' + close-issue-message: 'Closed due to inactivity. Feel free to reopen if this is still relevant.' + days-before-stale: 21 + days-before-close: 7 + stale-issue-label: 'feedback' From 56efb44c4ff11c3e2ad013f6fbc68dbb5b42c682 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 27 Jan 2026 11:24:49 +0100 Subject: [PATCH 06/11] Added explicit permissions: issues: write --- .github/workflows/stale.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index f2d4b707d..a4f53e2a3 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -4,6 +4,9 @@ on: schedule: - cron: '0 0 * * *' # Run daily at midnight UTC +permissions: + issues: write + jobs: stale: runs-on: ubuntu-latest From a1c59fd6cbf76acd87a84ef665728c926d07d8e8 Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 28 Jan 2026 07:13:01 +0100 Subject: [PATCH 07/11] Updated the workflow: - Changed stale-issue-label from feedback to stale (so stale issues get the "stale" label) - Added remove-issue-labels: 'feedback' to remove the feedback label when issues are auto-closed --- .github/workflows/stale.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a4f53e2a3..4ada6255e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,4 +17,5 @@ jobs: close-issue-message: 'Closed due to inactivity. Feel free to reopen if this is still relevant.' days-before-stale: 21 days-before-close: 7 - stale-issue-label: 'feedback' + stale-issue-label: 'stale' + remove-issue-labels: 'feedback' From 54abee53f8582d75a6fdbc73d190e26122c2adb4 Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 28 Jan 2026 09:15:26 +0100 Subject: [PATCH 08/11] Updated Github workflow --- .github/workflows/issue-closed.yml | 29 +++++++++++++++++++++++++++++ .github/workflows/stale.yml | 1 - 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/issue-closed.yml diff --git a/.github/workflows/issue-closed.yml b/.github/workflows/issue-closed.yml new file mode 100644 index 000000000..17bf9a5fb --- /dev/null +++ b/.github/workflows/issue-closed.yml @@ -0,0 +1,29 @@ +name: Clean up closed issues + +on: + issues: + types: [closed] + +permissions: + issues: write + +jobs: + remove-labels: + runs-on: ubuntu-latest + steps: + - name: Remove feedback label + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const hasLabel = issue.labels.some(l => l.name === 'feedback'); + + if (hasLabel) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: 'feedback' + }); + console.log(`Removed 'feedback' label from issue #${issue.number}`); + } diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 4ada6255e..03e2b7824 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,4 +18,3 @@ jobs: days-before-stale: 21 days-before-close: 7 stale-issue-label: 'stale' - remove-issue-labels: 'feedback' From 86ad13398aebcebe2b05432f81026733e5870317 Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 29 Jan 2026 13:30:45 +0100 Subject: [PATCH 09/11] Updated CI --- .github/workflows/issue-closed.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/issue-closed.yml b/.github/workflows/issue-closed.yml index 17bf9a5fb..31f2e607b 100644 --- a/.github/workflows/issue-closed.yml +++ b/.github/workflows/issue-closed.yml @@ -19,11 +19,19 @@ jobs: const hasLabel = issue.labels.some(l => l.name === 'feedback'); if (hasLabel) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: 'feedback' - }); - console.log(`Removed 'feedback' label from issue #${issue.number}`); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: 'feedback' + }); + console.log(`Removed 'feedback' label from issue #${issue.number}`); + } catch (error) { + if (error.status === 404) { + console.log(`Label 'feedback' already removed from issue #${issue.number}`); + } else { + throw error; + } + } } From 375d5238d05309ced8b931e26c2f3699d52071ca Mon Sep 17 00:00:00 2001 From: MisterBeardy Date: Thu, 29 Jan 2026 08:53:49 -0500 Subject: [PATCH 10/11] Add STL thumbnail generation support - Add trimesh and matplotlib dependencies for software-based 3D rendering - Create stl_thumbnail service with generate_stl_thumbnail() function - Handle mesh simplification for large files (>100k vertices) - Auto-generate thumbnails during STL file upload and ZIP extraction - Add POST /library/files/{id}/regenerate-thumbnail endpoint - Add POST /library/generate-stl-thumbnails batch endpoint - Add "Generate Thumbnails" button to file manager toolbar - Add "Regenerate Thumbnail" option to file context menu - Add unit and integration tests for new functionality Co-Authored-By: Claude Opus 4.5 --- .gitignore | 2 +- backend/app/api/routes/library.py | 220 ++++++++++++ backend/app/schemas/library.py | 29 ++ backend/app/services/stl_thumbnail.py | 227 ++++++++++++ backend/tests/integration/test_library_api.py | 123 +++++++ .../tests/unit/services/test_stl_thumbnail.py | 326 ++++++++++++++++++ frontend/src/api/client.ts | 30 ++ frontend/src/pages/FileManagerPage.tsx | 61 +++- requirements.txt | 4 + 9 files changed, 1020 insertions(+), 2 deletions(-) create mode 100644 backend/app/services/stl_thumbnail.py create mode 100644 backend/tests/unit/services/test_stl_thumbnail.py diff --git a/.gitignore b/.gitignore index f5eb2dfd3..470b31f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,4 @@ bambutrack.log.* firmware/ # Node modules -node_modules/ \ No newline at end of file +node_modules/ diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index a07e835c8..bbbaa450f 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -25,6 +25,9 @@ from backend.app.schemas.library import ( AddToQueueRequest, AddToQueueResponse, AddToQueueResult, + BatchThumbnailRequest, + BatchThumbnailResponse, + BatchThumbnailResult, BulkDeleteRequest, BulkDeleteResponse, FileDuplicate, @@ -708,6 +711,18 @@ async def upload_file( except Exception as e: logger.warning(f"Failed to extract gcode thumbnail: {e}") + elif ext == ".stl": + # Generate thumbnail from STL file + try: + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + if generate_stl_thumbnail(file_path, thumb_path): + thumbnail_path = str(thumb_path) + except Exception as e: + logger.warning(f"Failed to generate STL thumbnail: {e}") + elif ext.lower() in IMAGE_EXTENSIONS: # For image files, create a thumbnail from the image itself thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) @@ -907,6 +922,17 @@ async def extract_zip_file( except Exception as e: logger.warning(f"Failed to extract gcode thumbnail from ZIP: {e}") + elif ext == ".stl": + try: + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + if generate_stl_thumbnail(file_path, thumb_path): + thumbnail_path = str(thumb_path) + except Exception as e: + logger.warning(f"Failed to generate STL thumbnail from ZIP: {e}") + elif ext.lower() in IMAGE_EXTENSIONS: thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) @@ -1888,3 +1914,197 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)): "disk_total_bytes": disk_total_bytes, "disk_used_bytes": disk_used_bytes, } + + +# ============ Thumbnail Generation Endpoints ============ + + +@router.post("/files/{file_id}/regenerate-thumbnail", response_model=FileResponseSchema) +async def regenerate_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)): + """Regenerate thumbnail for a specific file. + + Works for STL, 3MF, gcode, and image files. + """ + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) + file = result.scalar_one_or_none() + + if not file: + raise HTTPException(status_code=404, detail="File not found") + + if not file.file_path or not os.path.exists(file.file_path): + raise HTTPException(status_code=404, detail="File not found on disk") + + ext = os.path.splitext(file.filename)[1].lower() + thumbnails_dir = get_library_thumbnails_dir() + file_path = Path(file.file_path) + + # Delete old thumbnail if exists + if file.thumbnail_path and os.path.exists(file.thumbnail_path): + try: + os.remove(file.thumbnail_path) + except Exception as e: + logger.warning(f"Failed to delete old thumbnail: {e}") + + thumbnail_path = None + + if ext == ".3mf": + try: + parser = ThreeMFParser(str(file_path)) + raw_metadata = parser.parse() + thumbnail_data = raw_metadata.get("_thumbnail_data") + thumbnail_ext = raw_metadata.get("_thumbnail_ext", ".png") + + if thumbnail_data: + thumb_filename = f"{uuid.uuid4().hex}{thumbnail_ext}" + thumb_path = thumbnails_dir / thumb_filename + with open(thumb_path, "wb") as f: + f.write(thumbnail_data) + thumbnail_path = str(thumb_path) + except Exception as e: + logger.warning(f"Failed to extract 3MF thumbnail: {e}") + + elif ext == ".gcode": + try: + thumbnail_data = extract_gcode_thumbnail(file_path) + if thumbnail_data: + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + with open(thumb_path, "wb") as f: + f.write(thumbnail_data) + thumbnail_path = str(thumb_path) + except Exception as e: + logger.warning(f"Failed to extract gcode thumbnail: {e}") + + elif ext == ".stl": + try: + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + if generate_stl_thumbnail(file_path, thumb_path): + thumbnail_path = str(thumb_path) + except Exception as e: + logger.warning(f"Failed to generate STL thumbnail: {e}") + + elif ext.lower() in IMAGE_EXTENSIONS: + thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) + + # Update database + file.thumbnail_path = thumbnail_path + await db.flush() + await db.refresh(file) + + # Return full response + return await get_file(file_id, db) + + +@router.post("/generate-stl-thumbnails", response_model=BatchThumbnailResponse) +async def batch_generate_stl_thumbnails( + request: BatchThumbnailRequest, + db: AsyncSession = Depends(get_db), +): + """Generate thumbnails for existing STL files. + + Can target specific files, a folder, or all STL files missing thumbnails. + + Args: + request: Batch request specifying which files to process + - file_ids: List of specific file IDs to process + - folder_id: Process all STL files in this folder + - all_missing: Process all STL files without thumbnails + """ + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + results: list[BatchThumbnailResult] = [] + thumbnails_dir = get_library_thumbnails_dir() + + # Build query based on request parameters + query = select(LibraryFile).where(LibraryFile.file_type == "stl") + + if request.file_ids: + # Specific files requested + query = query.where(LibraryFile.id.in_(request.file_ids)) + elif request.folder_id is not None: + # All STL files in a folder + query = query.where(LibraryFile.folder_id == request.folder_id) + elif request.all_missing: + # All STL files without thumbnails + query = query.where(LibraryFile.thumbnail_path.is_(None)) + else: + # No valid filter specified + raise HTTPException( + status_code=400, + detail="Must specify file_ids, folder_id, or all_missing=true", + ) + + result = await db.execute(query) + files = result.scalars().all() + + succeeded = 0 + failed = 0 + + for file in files: + if not file.file_path or not os.path.exists(file.file_path): + results.append( + BatchThumbnailResult( + file_id=file.id, + filename=file.filename, + success=False, + error="File not found on disk", + ) + ) + failed += 1 + continue + + try: + # Delete old thumbnail if exists + if file.thumbnail_path and os.path.exists(file.thumbnail_path): + try: + os.remove(file.thumbnail_path) + except Exception: + pass + + # Generate new thumbnail + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + + if generate_stl_thumbnail(file.file_path, thumb_path): + file.thumbnail_path = str(thumb_path) + results.append( + BatchThumbnailResult( + file_id=file.id, + filename=file.filename, + success=True, + ) + ) + succeeded += 1 + else: + results.append( + BatchThumbnailResult( + file_id=file.id, + filename=file.filename, + success=False, + error="Thumbnail generation failed", + ) + ) + failed += 1 + + except Exception as e: + results.append( + BatchThumbnailResult( + file_id=file.id, + filename=file.filename, + success=False, + error=str(e), + ) + ) + failed += 1 + + await db.commit() + + return BatchThumbnailResponse( + processed=len(results), + succeeded=succeeded, + failed=failed, + results=results, + ) diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py index dacbcc735..0c52b6b1a 100644 --- a/backend/app/schemas/library.py +++ b/backend/app/schemas/library.py @@ -262,3 +262,32 @@ class ZipExtractResponse(BaseModel): folders_created: int files: list[ZipExtractResult] errors: list[ZipExtractError] + + +# ============ Batch Thumbnail Generation ============ + + +class BatchThumbnailRequest(BaseModel): + """Schema for batch STL thumbnail generation request.""" + + file_ids: list[int] | None = None # Specific file IDs to process + folder_id: int | None = None # Process all STL files in this folder + all_missing: bool = False # Process all STL files without thumbnails + + +class BatchThumbnailResult(BaseModel): + """Result for a single file thumbnail generation.""" + + file_id: int + filename: str + success: bool + error: str | None = None + + +class BatchThumbnailResponse(BaseModel): + """Schema for batch thumbnail generation response.""" + + processed: int + succeeded: int + failed: int + results: list[BatchThumbnailResult] diff --git a/backend/app/services/stl_thumbnail.py b/backend/app/services/stl_thumbnail.py new file mode 100644 index 000000000..2861e45d8 --- /dev/null +++ b/backend/app/services/stl_thumbnail.py @@ -0,0 +1,227 @@ +"""STL thumbnail generation service. + +Generates PNG thumbnails from STL files using trimesh and matplotlib. +Supports both ASCII and binary STL formats, handles large meshes via simplification. +""" + +import io +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Maximum vertices before simplification is applied +MAX_VERTICES = 100000 + +# Default thumbnail size +DEFAULT_SIZE = (256, 256) + + +def generate_stl_thumbnail( + stl_path: str | Path, + output_path: str | Path, + size: tuple[int, int] = DEFAULT_SIZE, +) -> bool: + """Generate a PNG thumbnail from an STL file. + + Args: + stl_path: Path to the input STL file + output_path: Path where the PNG thumbnail will be saved + size: Tuple of (width, height) for the output image + + Returns: + True if thumbnail was generated successfully, False otherwise + """ + try: + import matplotlib + + matplotlib.use("Agg") # Use non-interactive backend + import matplotlib.pyplot as plt + import numpy as np + import trimesh + + # Load the STL file + mesh = trimesh.load(str(stl_path), file_type="stl") + + if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0: + logger.warning(f"Failed to load STL or empty mesh: {stl_path}") + return False + + # Simplify if mesh is too large + if len(mesh.vertices) > MAX_VERTICES: + logger.info(f"Simplifying mesh with {len(mesh.vertices)} vertices to ~{MAX_VERTICES}") + # Calculate target face count based on vertex ratio + target_faces = int(len(mesh.faces) * (MAX_VERTICES / len(mesh.vertices))) + try: + mesh = mesh.simplify_quadric_decimation(target_faces) + except Exception as e: + logger.warning(f"Mesh simplification failed, using original: {e}") + + # Create figure with transparent background + fig = plt.figure(figsize=(size[0] / 100, size[1] / 100), dpi=100) + ax = fig.add_subplot(111, projection="3d") + + # Get mesh vertices and faces + vertices = mesh.vertices + faces = mesh.faces + + # Center the mesh + center = vertices.mean(axis=0) + vertices = vertices - center + + # Scale to fit in view + max_extent = np.abs(vertices).max() + if max_extent > 0: + vertices = vertices / max_extent + + # Create triangles for plotting + triangles = vertices[faces] + + # Plot the mesh with a nice color scheme + from mpl_toolkits.mplot3d.art3d import Poly3DCollection + + collection = Poly3DCollection( + triangles, + alpha=1.0, + facecolor="#00AE42", # Bambu green + edgecolor="#008833", # Darker green for edges + linewidths=0.1, + ) + ax.add_collection3d(collection) + + # Set axis limits + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + ax.set_zlim(-1, 1) + + # Set viewing angle (isometric-ish) + ax.view_init(elev=25, azim=45) + + # Remove axes for cleaner look + ax.set_axis_off() + + # Set background color + ax.set_facecolor("#1a1a1a") + fig.patch.set_facecolor("#1a1a1a") + + # Tight layout to minimize whitespace + plt.tight_layout(pad=0) + + # Save the figure + plt.savefig( + str(output_path), + format="png", + dpi=100, + facecolor="#1a1a1a", + bbox_inches="tight", + pad_inches=0.05, + ) + plt.close(fig) + + logger.info(f"Generated STL thumbnail: {output_path}") + return True + + except ImportError as e: + logger.error(f"Missing dependency for STL thumbnails: {e}") + return False + except Exception as e: + logger.error(f"Failed to generate STL thumbnail for {stl_path}: {e}") + return False + + +def generate_stl_thumbnail_bytes( + stl_data: bytes, + size: tuple[int, int] = DEFAULT_SIZE, +) -> bytes | None: + """Generate a PNG thumbnail from STL data in memory. + + Args: + stl_data: Raw STL file data (binary or ASCII) + size: Tuple of (width, height) for the output image + + Returns: + PNG image data as bytes, or None on failure + """ + try: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import numpy as np + import trimesh + + # Load from bytes + mesh = trimesh.load( + file_obj=io.BytesIO(stl_data), + file_type="stl", + ) + + if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0: + logger.warning("Failed to load STL from bytes or empty mesh") + return None + + # Simplify if mesh is too large + if len(mesh.vertices) > MAX_VERTICES: + target_faces = int(len(mesh.faces) * (MAX_VERTICES / len(mesh.vertices))) + try: + mesh = mesh.simplify_quadric_decimation(target_faces) + except Exception: + pass # Use original if simplification fails + + # Create figure + fig = plt.figure(figsize=(size[0] / 100, size[1] / 100), dpi=100) + ax = fig.add_subplot(111, projection="3d") + + vertices = mesh.vertices + faces = mesh.faces + + # Center and scale + center = vertices.mean(axis=0) + vertices = vertices - center + max_extent = np.abs(vertices).max() + if max_extent > 0: + vertices = vertices / max_extent + + triangles = vertices[faces] + + from mpl_toolkits.mplot3d.art3d import Poly3DCollection + + collection = Poly3DCollection( + triangles, + alpha=1.0, + facecolor="#00AE42", + edgecolor="#008833", + linewidths=0.1, + ) + ax.add_collection3d(collection) + + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + ax.set_zlim(-1, 1) + ax.view_init(elev=25, azim=45) + ax.set_axis_off() + ax.set_facecolor("#1a1a1a") + fig.patch.set_facecolor("#1a1a1a") + plt.tight_layout(pad=0) + + # Save to bytes buffer + buf = io.BytesIO() + plt.savefig( + buf, + format="png", + dpi=100, + facecolor="#1a1a1a", + bbox_inches="tight", + pad_inches=0.05, + ) + plt.close(fig) + + buf.seek(0) + return buf.read() + + except ImportError as e: + logger.error(f"Missing dependency for STL thumbnails: {e}") + return None + except Exception as e: + logger.error(f"Failed to generate STL thumbnail from bytes: {e}") + return None diff --git a/backend/tests/integration/test_library_api.py b/backend/tests/integration/test_library_api.py index a9ae2e5f3..1113dc189 100644 --- a/backend/tests/integration/test_library_api.py +++ b/backend/tests/integration/test_library_api.py @@ -445,3 +445,126 @@ class TestLibraryZipExtractAPI: result = response.json() assert result["extracted"] == 1 # Only real_file.txt assert result["files"][0]["filename"] == "real_file.txt" + + +class TestSTLThumbnailAPI: + """Integration tests for STL thumbnail generation endpoints.""" + + @pytest.fixture + async def stl_file_factory(self, db_session): + """Factory to create test STL files.""" + _counter = [0] + + async def _create_stl_file(**kwargs): + from backend.app.models.library import LibraryFile + + _counter[0] += 1 + counter = _counter[0] + + defaults = { + "filename": f"test_model_{counter}.stl", + "file_path": f"/test/path/test_model_{counter}.stl", + "file_size": 1024, + "file_type": "stl", + } + defaults.update(kwargs) + + lib_file = LibraryFile(**defaults) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + return lib_file + + return _create_stl_file + + @pytest.fixture + async def folder_factory(self, db_session): + """Factory to create test folders.""" + _counter = [0] + + async def _create_folder(**kwargs): + from backend.app.models.library import LibraryFolder + + _counter[0] += 1 + counter = _counter[0] + + defaults = {"name": f"Test Folder {counter}"} + defaults.update(kwargs) + + folder = LibraryFolder(**defaults) + db_session.add(folder) + await db_session.commit() + await db_session.refresh(folder) + return folder + + return _create_folder + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_regenerate_thumbnail_file_not_found(self, async_client: AsyncClient, db_session): + """Verify 404 for non-existent file.""" + response = await async_client.post("/api/v1/library/files/9999/regenerate-thumbnail") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_regenerate_thumbnail_file_missing_on_disk( + self, async_client: AsyncClient, stl_file_factory, db_session + ): + """Verify error when file exists in DB but not on disk.""" + stl_file = await stl_file_factory() + response = await async_client.post(f"/api/v1/library/files/{stl_file.id}/regenerate-thumbnail") + assert response.status_code == 404 + assert "not found on disk" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_no_filter(self, async_client: AsyncClient, db_session): + """Verify error when no filter is specified.""" + data = {} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 400 + assert "Must specify" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_all_missing_empty(self, async_client: AsyncClient, db_session): + """Verify batch generation with no matching files.""" + data = {"all_missing": True} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 0 + assert result["succeeded"] == 0 + assert result["failed"] == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_specific_files(self, async_client: AsyncClient, stl_file_factory, db_session): + """Verify batch generation with specific file IDs.""" + stl_file = await stl_file_factory() + data = {"file_ids": [stl_file.id]} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 1 + # Will fail because file doesn't exist on disk + assert result["failed"] == 1 + assert result["results"][0]["error"] == "File not found on disk" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_by_folder( + self, async_client: AsyncClient, stl_file_factory, folder_factory, db_session + ): + """Verify batch generation by folder ID.""" + folder = await folder_factory() + await stl_file_factory(folder_id=folder.id) + await stl_file_factory(folder_id=folder.id) + await stl_file_factory() # File in root, should not be processed + + data = {"folder_id": folder.id} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 2 # Only files in the folder diff --git a/backend/tests/unit/services/test_stl_thumbnail.py b/backend/tests/unit/services/test_stl_thumbnail.py new file mode 100644 index 000000000..fe293a74e --- /dev/null +++ b/backend/tests/unit/services/test_stl_thumbnail.py @@ -0,0 +1,326 @@ +"""Unit tests for the STL thumbnail service.""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +class TestSTLThumbnailService: + """Tests for STL thumbnail generation.""" + + def test_generate_thumbnail_ascii_stl(self): + """Test generating thumbnail from ASCII STL file.""" + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + # Create a simple ASCII STL cube + ascii_stl = """solid cube + facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 1 1 0 + endloop + endfacet + facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 1 1 0 + vertex 0 1 0 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex 0 0 1 + vertex 1 1 1 + vertex 1 0 1 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex 0 0 1 + vertex 0 1 1 + vertex 1 1 1 + endloop + endfacet + facet normal 0 -1 0 + outer loop + vertex 0 0 0 + vertex 1 0 1 + vertex 1 0 0 + endloop + endfacet + facet normal 0 -1 0 + outer loop + vertex 0 0 0 + vertex 0 0 1 + vertex 1 0 1 + endloop + endfacet + facet normal 1 0 0 + outer loop + vertex 1 0 0 + vertex 1 1 1 + vertex 1 1 0 + endloop + endfacet + facet normal 1 0 0 + outer loop + vertex 1 0 0 + vertex 1 0 1 + vertex 1 1 1 + endloop + endfacet + facet normal 0 1 0 + outer loop + vertex 0 1 0 + vertex 1 1 0 + vertex 1 1 1 + endloop + endfacet + facet normal 0 1 0 + outer loop + vertex 0 1 0 + vertex 1 1 1 + vertex 0 1 1 + endloop + endfacet + facet normal -1 0 0 + outer loop + vertex 0 0 0 + vertex 0 1 0 + vertex 0 1 1 + endloop + endfacet + facet normal -1 0 0 + outer loop + vertex 0 0 0 + vertex 0 1 1 + vertex 0 0 1 + endloop + endfacet +endsolid cube +""" + + with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as stl_file: + stl_file.write(ascii_stl) + stl_path = stl_file.name + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file: + png_path = png_file.name + + try: + result = generate_stl_thumbnail(stl_path, png_path) + assert result is True + assert os.path.exists(png_path) + # Check it's a valid PNG (starts with PNG magic bytes) + with open(png_path, "rb") as f: + header = f.read(8) + assert header[:4] == b"\x89PNG" + finally: + os.unlink(stl_path) + if os.path.exists(png_path): + os.unlink(png_path) + + def test_generate_thumbnail_binary_stl(self): + """Test generating thumbnail from binary STL file.""" + import struct + + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + # Create a simple binary STL cube (minimal version) + # Binary STL format: + # - 80 bytes header + # - 4 bytes number of triangles (uint32) + # - For each triangle: + # - 12 bytes normal (3 floats) + # - 36 bytes vertices (9 floats, 3 vertices x 3 coords) + # - 2 bytes attribute byte count (usually 0) + + header = b"\x00" * 80 # Empty header + num_triangles = 12 # A cube has 12 triangles (2 per face) + + # Define cube vertices + vertices = [ + # Bottom face (z=0) + ((0, 0, -1), [(0, 0, 0), (1, 0, 0), (1, 1, 0)]), + ((0, 0, -1), [(0, 0, 0), (1, 1, 0), (0, 1, 0)]), + # Top face (z=1) + ((0, 0, 1), [(0, 0, 1), (1, 1, 1), (1, 0, 1)]), + ((0, 0, 1), [(0, 0, 1), (0, 1, 1), (1, 1, 1)]), + # Front face (y=0) + ((0, -1, 0), [(0, 0, 0), (1, 0, 1), (1, 0, 0)]), + ((0, -1, 0), [(0, 0, 0), (0, 0, 1), (1, 0, 1)]), + # Back face (y=1) + ((0, 1, 0), [(0, 1, 0), (1, 1, 0), (1, 1, 1)]), + ((0, 1, 0), [(0, 1, 0), (1, 1, 1), (0, 1, 1)]), + # Left face (x=0) + ((-1, 0, 0), [(0, 0, 0), (0, 1, 0), (0, 1, 1)]), + ((-1, 0, 0), [(0, 0, 0), (0, 1, 1), (0, 0, 1)]), + # Right face (x=1) + ((1, 0, 0), [(1, 0, 0), (1, 1, 1), (1, 1, 0)]), + ((1, 0, 0), [(1, 0, 0), (1, 0, 1), (1, 1, 1)]), + ] + + binary_data = header + struct.pack("; }>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`), + + // STL Thumbnail Generation + regenerateFileThumbnail: (fileId: number) => + request(`/library/files/${fileId}/regenerate-thumbnail`, { + method: 'POST', + }), + batchGenerateStlThumbnails: (options: { + file_ids?: number[]; + folder_id?: number; + all_missing?: boolean; + }) => + request('/library/generate-stl-thumbnails', { + method: 'POST', + body: JSON.stringify(options), + }), }; // AMS History types @@ -3045,6 +3060,21 @@ export interface ZipExtractResponse { errors: ZipExtractError[]; } +// Batch Thumbnail Generation types +export interface BatchThumbnailResult { + file_id: number; + filename: string; + success: boolean; + error: string | null; +} + +export interface BatchThumbnailResponse { + processed: number; + succeeded: number; + failed: number; + results: BatchThumbnailResult[]; +} + // Library Queue types export interface AddToQueueResult { file_id: number; diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index 7ebe87dc6..74512189e 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -35,6 +35,7 @@ import { Printer, Pencil, Play, + ImageIcon, } from 'lucide-react'; import { api } from '../api/client'; import type { @@ -821,9 +822,10 @@ interface FileCardProps { onAddToQueue?: (id: number) => void; onPrint?: (file: LibraryFileListItem) => void; onRename?: (file: LibraryFileListItem) => void; + onRegenerateThumbnail?: (id: number) => void; } -function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) { +function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onRegenerateThumbnail }: FileCardProps) { const [showActions, setShowActions] = useState(false); return ( @@ -924,6 +926,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue Rename )} + {onRegenerateThumbnail && ['stl', '3mf', 'gcode'].includes(file.file_type.toLowerCase()) && ( + + )} + @@ -1604,6 +1652,7 @@ export function FileManagerPage() { onAddToQueue={(id) => addToQueueMutation.mutate([id])} onPrint={setPrintFile} onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })} + onRegenerateThumbnail={(id) => regenerateThumbnailMutation.mutate(id)} /> ))} @@ -1720,6 +1769,16 @@ export function FileManagerPage() { > + {['stl', '3mf', 'gcode'].includes(file.file_type.toLowerCase()) && ( + + )}