Add authentication to 200+ API endpoints (CVE-2026-25505)

Security fix for critical vulnerability (CVSS 9.8) where API endpoints
were accessible without authentication when auth was enabled.

Changes:
- Add RequirePermissionIfAuthEnabled() to all unprotected route files:
  archives, projects, settings, api_keys, groups, cloud, github_backup,
  support, notifications, notification_templates, maintenance, filaments,
  external_links, smart_plugs, discovery, firmware, kprofiles, camera,
  ams_history, pending_uploads, updates, spoolman, system, print_queue,
  printers
- Keep image-serving endpoints (thumbnails, timelapse, photos, camera
  streams, icons) unauthenticated since <img> tags cannot send headers
- Add backend integration tests for endpoint auth enforcement
- Add frontend tests for ownership-based permissions (canModify)

Fixes: CVE-2026-25505
This commit is contained in:
maziggy
2026-02-03 08:44:07 +01:00
parent 5b76cf3623
commit 3fa9ed2b91
28 changed files with 948 additions and 119 deletions
+5
View File
@@ -7,8 +7,11 @@ from pydantic import BaseModel
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.ams_history import AMSSensorHistory
from backend.app.models.user import User
router = APIRouter(prefix="/ams-history", tags=["ams-history"])
@@ -38,6 +41,7 @@ async def get_ams_history(
ams_id: int,
hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
):
"""Get AMS sensor history for a specific printer and AMS unit."""
since = datetime.now() - timedelta(hours=hours)
@@ -101,6 +105,7 @@ async def delete_old_history(
printer_id: int,
days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
):
"""Delete old AMS history data for a printer."""
cutoff = datetime.now() - timedelta(days=days)
+11 -2
View File
@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import generate_api_key
from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.api_key import APIKey
from backend.app.models.user import User
from backend.app.schemas.api_key import (
APIKeyCreate,
APIKeyCreateResponse,
@@ -20,7 +22,10 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"])
@router.get("/", response_model=list[APIKeyResponse])
async def list_api_keys(db: AsyncSession = Depends(get_db)):
async def list_api_keys(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
):
"""List all API keys (without full key values)."""
result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))
return list(result.scalars().all())
@@ -30,6 +35,7 @@ async def list_api_keys(db: AsyncSession = Depends(get_db)):
async def create_api_key(
data: APIKeyCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
):
"""Create a new API key.
@@ -74,6 +80,7 @@ async def create_api_key(
async def get_api_key(
key_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
):
"""Get an API key by ID."""
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
@@ -90,6 +97,7 @@ async def update_api_key(
key_id: int,
data: APIKeyUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
):
"""Update an API key."""
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
@@ -124,6 +132,7 @@ async def update_api_key(
async def delete_api_key(
key_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
):
"""Delete (revoke) an API key."""
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
+137 -26
View File
@@ -8,7 +8,10 @@ from fastapi.responses import FileResponse, Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
from backend.app.core.auth import (
RequirePermissionIfAuthEnabled,
require_ownership_permission,
)
from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
@@ -118,6 +121,7 @@ async def list_archives(
limit: int = 50,
offset: int = 0,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""List archived prints."""
service = ArchiveService(db)
@@ -148,6 +152,7 @@ async def search_archives(
limit: int = 50,
offset: int = 0,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Full-text search across archives.
@@ -229,7 +234,10 @@ async def search_archives(
@router.post("/search/rebuild-index")
async def rebuild_search_index(db: AsyncSession = Depends(get_db)):
async def rebuild_search_index(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Rebuild the full-text search index from existing archives.
Use this if search results seem incomplete or incorrect.
@@ -267,6 +275,7 @@ async def analyze_failures(
printer_id: int | None = None,
project_id: int | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Analyze failure patterns across prints.
@@ -291,6 +300,7 @@ async def analyze_failures(
async def compare_archives(
archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Compare multiple archives side by side.
@@ -331,6 +341,7 @@ async def export_archives(
date_to: str | None = Query(None, description="End date (ISO format)"),
search: str | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Export archives to CSV or Excel format.
@@ -393,6 +404,7 @@ async def export_stats(
printer_id: int | None = None,
project_id: int | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
):
"""Export statistics summary to CSV or Excel format."""
from fastapi.responses import StreamingResponse
@@ -421,7 +433,10 @@ async def export_stats(
@router.get("/stats", response_model=ArchiveStats)
async def get_archive_stats(db: AsyncSession = Depends(get_db)):
async def get_archive_stats(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
):
"""Get statistics across all archives."""
# Total counts
total_result = await db.execute(select(func.count(PrintArchive.id)))
@@ -574,7 +589,10 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
@router.get("/tags")
async def get_all_tags(db: AsyncSession = Depends(get_db)):
async def get_all_tags(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""List all unique tags with usage counts.
Returns a list of tags sorted by count (descending), then by name.
@@ -604,6 +622,7 @@ async def rename_tag(
tag_name: str,
request: Request,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Rename a tag across all archives.
@@ -646,7 +665,11 @@ async def rename_tag(
@router.delete("/tags/{tag_name}")
async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
async def delete_tag(
tag_name: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Delete a tag from all archives.
Returns the count of affected archives.
@@ -671,7 +694,11 @@ async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
@router.get("/{archive_id}", response_model=ArchiveResponse)
async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
async def get_archive(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Get a specific archive."""
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
@@ -694,6 +721,7 @@ async def find_similar_archives(
archive_id: int,
limit: int = 10,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Find archives with similar settings for comparison.
@@ -762,6 +790,7 @@ async def update_archive(
async def toggle_favorite(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
):
"""Toggle favorite status for an archive."""
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -776,7 +805,11 @@ async def toggle_favorite(
@router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
async def rescan_archive(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Rescan the 3MF file and update metadata."""
from backend.app.api.routes.settings import get_setting
from backend.app.services.archive import ThreeMFParser
@@ -835,7 +868,10 @@ async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
@router.post("/recalculate-costs")
async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
async def recalculate_all_costs(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Recalculate costs for all archives based on filament usage and prices."""
from backend.app.api.routes.settings import get_setting
@@ -865,7 +901,10 @@ async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
@router.post("/rescan-all")
async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
async def rescan_all_archives(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Rescan all archives and update their metadata."""
from backend.app.services.archive import ThreeMFParser
@@ -912,7 +951,11 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
@router.get("/{archive_id}/duplicates")
async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)):
async def get_archive_duplicates(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Get duplicates for a specific archive."""
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
@@ -930,7 +973,10 @@ async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get
@router.post("/backfill-hashes")
async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
async def backfill_content_hashes(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Compute and store content hashes for all archives missing them."""
result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None)))
archives = list(result.scalars().all())
@@ -991,6 +1037,7 @@ async def download_archive(
archive_id: int,
inline: bool = False,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Download the 3MF file."""
service = ArchiveService(db)
@@ -1018,6 +1065,7 @@ async def download_archive_with_filename(
archive_id: int,
filename: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
service = ArchiveService(db)
@@ -1037,8 +1085,14 @@ async def download_archive_with_filename(
@router.get("/{archive_id}/thumbnail")
async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
"""Get the thumbnail image."""
async def get_thumbnail(
archive_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get the thumbnail image.
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
"""
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
if not archive or not archive.thumbnail_path:
@@ -1062,8 +1116,14 @@ async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
@router.get("/{archive_id}/timelapse")
async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
"""Get the timelapse video."""
async def get_timelapse(
archive_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get the timelapse video.
Note: Unauthenticated - loaded via <video> tags which can't send auth headers.
"""
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
if not archive or not archive.timelapse_path:
@@ -1091,6 +1151,7 @@ async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
async def scan_timelapse(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Scan printer for timelapse matching this archive and attach it."""
from backend.app.models.printer import Printer
@@ -1317,6 +1378,7 @@ async def select_timelapse(
archive_id: int,
filename: str = Query(..., description="Timelapse filename to attach"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Manually select a timelapse from the printer to attach."""
from backend.app.models.printer import Printer
@@ -1401,6 +1463,7 @@ async def upload_timelapse(
archive_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Manually upload a timelapse video to an archive."""
service = ArchiveService(db)
@@ -1421,7 +1484,11 @@ async def upload_timelapse(
@router.get("/{archive_id}/timelapse/info")
async def get_timelapse_info(archive_id: int, db: AsyncSession = Depends(get_db)):
async def get_timelapse_info(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Get timelapse video metadata for editor."""
from backend.app.schemas.timelapse import TimelapseInfoResponse
from backend.app.services.timelapse_processor import TimelapseProcessor
@@ -1450,6 +1517,7 @@ async def get_timelapse_thumbnails(
count: int = Query(10, ge=1, le=30),
width: int = Query(160, ge=80, le=320),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Generate timeline thumbnail frames for visual scrubbing."""
import base64
@@ -1489,6 +1557,7 @@ async def process_timelapse(
output_filename: str = Form(None),
audio: UploadFile = File(None),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Process timelapse with trim, speed, and optional audio overlay."""
import shutil
@@ -1592,6 +1661,7 @@ async def upload_photo(
archive_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
):
"""Upload a photo of the printed result."""
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -1636,7 +1706,10 @@ async def get_photo(
filename: str,
db: AsyncSession = Depends(get_db),
):
"""Get a specific photo."""
"""Get a specific photo.
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
"""
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
archive = result.scalar_one_or_none()
if not archive:
@@ -1666,6 +1739,7 @@ async def delete_photo(
archive_id: int,
filename: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
):
"""Delete a photo."""
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -1703,7 +1777,10 @@ async def get_qrcode(
size: int = 200,
db: AsyncSession = Depends(get_db),
):
"""Generate a QR code that links to this archive."""
"""Generate a QR code that links to this archive.
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
"""
try:
import qrcode
from PIL import Image as PILImage
@@ -1751,7 +1828,11 @@ async def get_qrcode(
@router.get("/{archive_id}/capabilities")
async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)):
async def get_archive_capabilities(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Check what viewing capabilities are available for this 3MF file."""
import json
import xml.etree.ElementTree as ET
@@ -1968,7 +2049,11 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
@router.get("/{archive_id}/gcode")
async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
async def get_gcode(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Extract and return G-code from the 3MF file."""
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
@@ -2001,11 +2086,16 @@ async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
@router.get("/{archive_id}/plate-preview")
async def get_plate_preview(archive_id: int, db: AsyncSession = Depends(get_db)):
async def get_plate_preview(
archive_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get the plate preview image from the 3MF file.
Returns the slicer-generated plate thumbnail which shows the model
with correct colors and positioning.
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
"""
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
@@ -2068,7 +2158,7 @@ async def upload_archive(
file: UploadFile = File(...),
printer_id: int | None = None,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_auth_if_enabled),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
):
"""Manually upload a 3MF file to archive."""
if not file.filename or not file.filename.endswith(".3mf"):
@@ -2103,7 +2193,7 @@ async def upload_archives_bulk(
files: list[UploadFile] = File(...),
printer_id: int | None = None,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_auth_if_enabled),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
):
"""Bulk upload multiple 3MF files to archive."""
results = []
@@ -2157,6 +2247,7 @@ async def upload_archives_bulk(
async def get_archive_plates(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Get available plates from a multi-plate 3MF archive.
@@ -2337,7 +2428,10 @@ async def get_plate_thumbnail(
plate_index: int,
db: AsyncSession = Depends(get_db),
):
"""Get the thumbnail image for a specific plate."""
"""Get the thumbnail image for a specific plate.
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
"""
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
if not archive:
@@ -2364,6 +2458,7 @@ async def get_filament_requirements(
archive_id: int,
plate_id: int | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Get filament requirements from the archived 3MF file.
@@ -2659,7 +2754,11 @@ async def reprint_archive(
@router.get("/{archive_id}/project-page")
async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)):
async def get_project_page(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Get the project page data from the 3MF file."""
from backend.app.schemas.archive import ProjectPageResponse
from backend.app.services.archive import ProjectPageParser
@@ -2684,6 +2783,7 @@ async def update_project_page(
archive_id: int,
update_data: dict,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
):
"""Update project page metadata in the 3MF file."""
from backend.app.services.archive import ProjectPageParser
@@ -2714,7 +2814,10 @@ async def get_project_image(
image_path: str,
db: AsyncSession = Depends(get_db),
):
"""Get an image from the 3MF project page."""
"""Get an image from the 3MF project page.
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
"""
from backend.app.services.archive import ProjectPageParser
service = ArchiveService(db)
@@ -2750,6 +2853,7 @@ async def upload_source_3mf(
archive_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
):
"""Upload the original source 3MF project file for an archive."""
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2796,6 +2900,7 @@ async def upload_source_3mf(
async def download_source_3mf(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Download the source 3MF project file."""
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2825,6 +2930,7 @@ async def download_source_3mf_for_slicer(
archive_id: int,
filename: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2851,6 +2957,7 @@ async def upload_source_3mf_by_name(
file: UploadFile = File(...),
print_name: str = Query(None, description="Match archive by print name"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
):
"""Upload source 3MF and match to archive by print name.
@@ -2937,6 +3044,7 @@ async def upload_source_3mf_by_name(
async def delete_source_3mf(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
):
"""Delete the source 3MF project file from an archive."""
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2969,6 +3077,7 @@ async def upload_f3d(
archive_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
):
"""Upload a Fusion 360 design file for an archive."""
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -3015,6 +3124,7 @@ async def upload_f3d(
async def download_f3d(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
):
"""Download the Fusion 360 design file."""
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -3043,6 +3153,7 @@ async def download_f3d(
async def delete_f3d(
archive_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
):
"""Delete the Fusion 360 design file from an archive."""
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+28 -3
View File
@@ -9,8 +9,11 @@ from fastapi.responses import Response, StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.printer import Printer
from backend.app.models.user import User
from backend.app.services.camera import (
capture_camera_frame,
generate_chamber_image_stream,
@@ -353,6 +356,8 @@ async def camera_stream(
This endpoint returns a multipart MJPEG stream that can be used directly
in an <img> tag or video player.
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
Uses external camera if configured, otherwise uses built-in camera:
- External: MJPEG, RTSP, or HTTP snapshot
- A1/P1: Chamber image protocol (port 6000)
@@ -476,7 +481,10 @@ async def camera_stream(
@router.api_route("/{printer_id}/camera/stop", methods=["GET", "POST"])
async def stop_camera_stream(printer_id: int):
async def stop_camera_stream(
printer_id: int,
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
"""Stop all active camera streams for a printer.
This can be called by the frontend when the camera window is closed.
@@ -527,6 +535,8 @@ async def camera_snapshot(
"""Capture a single frame from the printer camera.
Returns a JPEG image.
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
"""
import tempfile
from pathlib import Path
@@ -574,6 +584,7 @@ async def camera_snapshot(
async def test_camera(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
"""Test camera connection for a printer.
@@ -591,7 +602,10 @@ async def test_camera(
@router.get("/{printer_id}/camera/status")
async def camera_status(printer_id: int):
async def camera_status(
printer_id: int,
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
"""Get the status of an active camera stream.
Returns whether a stream is active and when the last frame was received.
@@ -658,6 +672,7 @@ async def test_external_camera(
url: str,
camera_type: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
"""Test external camera connection.
@@ -684,6 +699,7 @@ async def check_plate_empty(
use_external: bool = False,
include_debug_image: bool = False,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
"""Check if the build plate is empty using camera vision.
@@ -791,6 +807,7 @@ async def calibrate_plate_detection(
label: str | None = None,
use_external: bool = False,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
"""Calibrate plate detection by capturing a reference image of the empty plate.
@@ -854,6 +871,7 @@ async def delete_plate_calibration(
printer_id: int,
plate_type: str | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
"""Delete the plate detection calibration for a printer and plate type.
@@ -894,6 +912,7 @@ async def get_plate_detection_status(
printer_id: int,
plate_type: str | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
"""Check plate detection status for a printer and plate type.
@@ -937,6 +956,7 @@ async def get_plate_detection_status(
async def get_plate_references(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
"""Get all calibration references for a printer with metadata.
@@ -971,7 +991,10 @@ async def get_reference_thumbnail(
index: int,
db: AsyncSession = Depends(get_db),
):
"""Get thumbnail image for a calibration reference."""
"""Get thumbnail image for a calibration reference.
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
"""
from fastapi.responses import Response
from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
@@ -997,6 +1020,7 @@ async def update_reference_label(
index: int,
label: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
"""Update the label for a calibration reference."""
from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
@@ -1021,6 +1045,7 @@ async def delete_reference(
printer_id: int,
index: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
):
"""Delete a specific calibration reference."""
from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
+63 -13
View File
@@ -13,8 +13,11 @@ from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.schemas.cloud import (
CloudAuthStatus,
CloudDevice,
@@ -74,7 +77,10 @@ async def clear_token(db: AsyncSession) -> None:
@router.get("/status", response_model=CloudAuthStatus)
async def get_auth_status(db: AsyncSession = Depends(get_db)):
async def get_auth_status(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
):
"""Get current cloud authentication status."""
token, email = await get_stored_token(db)
cloud = get_cloud_service()
@@ -89,7 +95,11 @@ async def get_auth_status(db: AsyncSession = Depends(get_db)):
@router.post("/login", response_model=CloudLoginResponse)
async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
async def login(
request: CloudLoginRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
):
"""
Initiate login to Bambu Cloud.
@@ -126,7 +136,11 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
@router.post("/verify", response_model=CloudLoginResponse)
async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(get_db)):
async def verify_code(
request: CloudVerifyRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
):
"""
Complete login with verification code (email or TOTP).
@@ -162,7 +176,11 @@ async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(ge
@router.post("/token", response_model=CloudAuthStatus)
async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_db)):
async def set_token(
request: CloudTokenRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
):
"""
Set access token directly.
@@ -182,7 +200,10 @@ async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_d
@router.post("/logout")
async def logout(db: AsyncSession = Depends(get_db)):
async def logout(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
):
"""Log out of Bambu Cloud."""
cloud = get_cloud_service()
cloud.logout()
@@ -194,6 +215,7 @@ async def logout(db: AsyncSession = Depends(get_db)):
async def get_slicer_settings(
version: str = "02.04.00.70",
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""
Get all slicer settings (filament, printer, process presets).
@@ -250,7 +272,11 @@ async def get_slicer_settings(
@router.get("/settings/{setting_id}")
async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)):
async def get_setting_detail(
setting_id: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""
Get detailed information for a specific setting/preset.
@@ -311,7 +337,11 @@ def _filament_id_to_setting_id(filament_id: str) -> str:
@router.post("/filament-info")
async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
async def get_filament_info(
setting_ids: list[str] = Body(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""
Get filament preset info (name and K value) for multiple setting IDs.
@@ -385,7 +415,10 @@ async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession
@router.get("/devices", response_model=list[CloudDevice])
async def get_devices(db: AsyncSession = Depends(get_db)):
async def get_devices(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
):
"""
Get list of bound printer devices.
@@ -423,7 +456,10 @@ async def get_devices(db: AsyncSession = Depends(get_db)):
@router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
async def get_firmware_updates(db: AsyncSession = Depends(get_db)):
async def get_firmware_updates(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
):
"""
Check for firmware updates for all bound devices.
@@ -499,7 +535,11 @@ async def get_firmware_updates(db: AsyncSession = Depends(get_db)):
@router.post("/settings")
async def create_setting(request: SlicerSettingCreate, db: AsyncSession = Depends(get_db)):
async def create_setting(
request: SlicerSettingCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""
Create a new slicer preset/setting.
@@ -539,6 +579,7 @@ async def update_setting(
setting_id: str,
request: SlicerSettingUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""
Update an existing slicer preset/setting.
@@ -570,7 +611,11 @@ async def update_setting(
@router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
async def delete_setting(setting_id: str, db: AsyncSession = Depends(get_db)):
async def delete_setting(
setting_id: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""
Delete a slicer preset/setting.
@@ -635,7 +680,10 @@ def _load_fields(preset_type: str) -> dict:
@router.get("/fields/{preset_type}")
async def get_preset_fields(preset_type: Literal["filament", "print", "process", "printer"]):
async def get_preset_fields(
preset_type: Literal["filament", "print", "process", "printer"],
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""
Get field definitions for a preset type.
@@ -654,7 +702,9 @@ async def get_preset_fields(preset_type: Literal["filament", "print", "process",
@router.get("/fields")
async def get_all_preset_fields():
async def get_all_preset_fields(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""
Get all field definitions for all preset types.
+29 -8
View File
@@ -10,6 +10,9 @@ import logging
from fastapi import APIRouter
from pydantic import BaseModel
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.permissions import Permission
from backend.app.models.user import User
from backend.app.services.discovery import (
discovery_service,
is_running_in_docker,
@@ -60,7 +63,9 @@ class DiscoveredPrinterResponse(BaseModel):
@router.get("/info", response_model=DiscoveryInfo)
async def get_discovery_info():
async def get_discovery_info(
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
):
"""Get discovery environment info (Docker detection, etc.)."""
return DiscoveryInfo(
is_docker=is_running_in_docker(),
@@ -70,13 +75,18 @@ async def get_discovery_info():
@router.get("/status", response_model=DiscoveryStatus)
async def get_discovery_status():
async def get_discovery_status(
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
):
"""Get the current SSDP discovery status."""
return DiscoveryStatus(running=discovery_service.is_running)
@router.post("/start", response_model=DiscoveryStatus)
async def start_discovery(duration: float = 10.0):
async def start_discovery(
duration: float = 10.0,
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
):
"""Start SSDP printer discovery.
Args:
@@ -87,14 +97,18 @@ async def start_discovery(duration: float = 10.0):
@router.post("/stop", response_model=DiscoveryStatus)
async def stop_discovery():
async def stop_discovery(
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
):
"""Stop SSDP printer discovery."""
await discovery_service.stop()
return DiscoveryStatus(running=discovery_service.is_running)
@router.get("/printers", response_model=list[DiscoveredPrinterResponse])
async def get_discovered_printers():
async def get_discovered_printers(
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
):
"""Get list of discovered printers (from both SSDP and subnet scan)."""
# Combine results from both discovery methods
printers = {}
@@ -124,7 +138,10 @@ async def get_discovered_printers():
@router.post("/scan", response_model=SubnetScanStatus)
async def start_subnet_scan(request: SubnetScanRequest):
async def start_subnet_scan(
request: SubnetScanRequest,
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
):
"""Start a subnet scan for Bambu printers.
Use this when running in Docker where SSDP multicast doesn't work.
@@ -147,7 +164,9 @@ async def start_subnet_scan(request: SubnetScanRequest):
@router.get("/scan/status", response_model=SubnetScanStatus)
async def get_scan_status():
async def get_scan_status(
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
):
"""Get the current subnet scan status."""
scanned, total = subnet_scanner.progress
return SubnetScanStatus(
@@ -158,7 +177,9 @@ async def get_scan_status():
@router.post("/scan/stop", response_model=SubnetScanStatus)
async def stop_subnet_scan():
async def stop_subnet_scan(
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
):
"""Stop the current subnet scan."""
subnet_scanner.stop()
scanned, total = subnet_scanner.progress
+18 -2
View File
@@ -9,9 +9,12 @@ from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.config import settings as app_settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.external_link import ExternalLink
from backend.app.models.user import User
from backend.app.schemas.external_link import (
ExternalLinkCreate,
ExternalLinkReorder,
@@ -29,7 +32,10 @@ router = APIRouter(prefix="/external-links", tags=["external-links"])
@router.get("/", response_model=list[ExternalLinkResponse])
async def list_external_links(db: AsyncSession = Depends(get_db)):
async def list_external_links(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
):
"""List all external links ordered by sort_order."""
result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
links = result.scalars().all()
@@ -40,6 +46,7 @@ async def list_external_links(db: AsyncSession = Depends(get_db)):
async def create_external_link(
link_data: ExternalLinkCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
):
"""Create a new external link."""
# Get the highest sort_order to place new link at end
@@ -67,6 +74,7 @@ async def create_external_link(
async def get_external_link(
link_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
):
"""Get a specific external link."""
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -83,6 +91,7 @@ async def update_external_link(
link_id: int,
update_data: ExternalLinkUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
):
"""Update an external link."""
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -108,6 +117,7 @@ async def update_external_link(
async def delete_external_link(
link_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
):
"""Delete an external link."""
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -129,6 +139,7 @@ async def delete_external_link(
async def reorder_external_links(
reorder_data: ExternalLinkReorder,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
):
"""Update the sort order of external links."""
# Update sort_order for each link based on position in the list
@@ -154,6 +165,7 @@ async def upload_icon(
link_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
):
"""Upload a custom icon for an external link."""
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -202,6 +214,7 @@ async def upload_icon(
async def delete_icon(
link_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
):
"""Delete the custom icon for an external link."""
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -227,7 +240,10 @@ async def get_icon(
link_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get the custom icon for an external link."""
"""Get the custom icon for an external link.
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
"""
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
link = result.scalar_one_or_none()
+25 -4
View File
@@ -2,8 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.filament import Filament
from backend.app.models.user import User
from backend.app.schemas.filament import (
FilamentCostCalculation,
FilamentCreate,
@@ -15,7 +18,10 @@ router = APIRouter(prefix="/filaments", tags=["filaments"])
@router.get("/", response_model=list[FilamentResponse])
async def list_filaments(db: AsyncSession = Depends(get_db)):
async def list_filaments(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""List all filaments."""
result = await db.execute(select(Filament).order_by(Filament.type, Filament.name))
return list(result.scalars().all())
@@ -25,6 +31,7 @@ async def list_filaments(db: AsyncSession = Depends(get_db)):
async def create_filament(
filament_data: FilamentCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
):
"""Create a new filament entry."""
filament = Filament(**filament_data.model_dump())
@@ -35,7 +42,11 @@ async def create_filament(
@router.get("/{filament_id}", response_model=FilamentResponse)
async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
async def get_filament(
filament_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get a specific filament."""
result = await db.execute(select(Filament).where(Filament.id == filament_id))
filament = result.scalar_one_or_none()
@@ -49,6 +60,7 @@ async def update_filament(
filament_id: int,
filament_data: FilamentUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
):
"""Update a filament."""
result = await db.execute(select(Filament).where(Filament.id == filament_id))
@@ -65,7 +77,11 @@ async def update_filament(
@router.delete("/{filament_id}")
async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
async def delete_filament(
filament_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_DELETE),
):
"""Delete a filament."""
result = await db.execute(select(Filament).where(Filament.id == filament_id))
filament = result.scalar_one_or_none()
@@ -82,6 +98,7 @@ async def calculate_cost(
filament_id: int,
weight_grams: float,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Calculate the cost for a given weight of filament."""
result = await db.execute(select(Filament).where(Filament.id == filament_id))
@@ -104,6 +121,7 @@ async def calculate_cost(
async def get_filaments_by_type(
filament_type: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get all filaments of a specific type."""
result = await db.execute(select(Filament).where(Filament.type.ilike(f"%{filament_type}%")).order_by(Filament.name))
@@ -111,7 +129,10 @@ async def get_filaments_by_type(
@router.post("/seed-defaults")
async def seed_default_filaments(db: AsyncSession = Depends(get_db)):
async def seed_default_filaments(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
):
"""Seed the database with common filament types."""
defaults = [
{
+14 -2
View File
@@ -12,8 +12,11 @@ from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.printer import Printer
from backend.app.models.user import User
from backend.app.services.firmware_check import get_firmware_service
from backend.app.services.firmware_update import (
FirmwareUploadStatus,
@@ -59,6 +62,7 @@ class LatestFirmwareInfo(BaseModel):
@router.get("/updates", response_model=FirmwareUpdatesResponse)
async def check_firmware_updates(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
):
"""
Check for firmware updates for all connected printers.
@@ -112,6 +116,7 @@ async def check_firmware_updates(
async def check_printer_firmware(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
):
"""
Check for firmware update for a specific printer.
@@ -148,7 +153,9 @@ async def check_printer_firmware(
@router.get("/latest", response_model=list[LatestFirmwareInfo])
async def get_all_latest_firmware():
async def get_all_latest_firmware(
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
):
"""
Get the latest firmware versions for all Bambu Lab printer models.
@@ -211,6 +218,7 @@ class FirmwareUploadStartResponse(BaseModel):
async def prepare_firmware_upload(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
):
"""
Check prerequisites for uploading firmware to a printer.
@@ -232,6 +240,7 @@ async def prepare_firmware_upload(
async def start_firmware_upload(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
):
"""
Start uploading firmware to a printer's SD card.
@@ -279,7 +288,10 @@ async def start_firmware_upload(
@router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
async def get_firmware_upload_status(printer_id: int):
async def get_firmware_upload_status(
printer_id: int,
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
):
"""
Get the current status of a firmware upload operation.
+28 -5
View File
@@ -6,8 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
from backend.app.models.user import User
from backend.app.schemas.github_backup import (
GitHubBackupConfigCreate,
GitHubBackupConfigResponse,
@@ -48,7 +51,10 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
@router.get("/config", response_model=GitHubBackupConfigResponse | None)
async def get_config(db: AsyncSession = Depends(get_db)):
async def get_config(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""Get the current GitHub backup configuration."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
@@ -63,6 +69,7 @@ async def get_config(db: AsyncSession = Depends(get_db)):
async def save_config(
config_data: GitHubBackupConfigCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""Create or update GitHub backup configuration.
@@ -121,6 +128,7 @@ async def save_config(
async def update_config(
update_data: GitHubBackupConfigUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""Partially update GitHub backup configuration."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -153,7 +161,10 @@ async def update_config(
@router.delete("/config")
async def delete_config(db: AsyncSession = Depends(get_db)):
async def delete_config(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""Delete the GitHub backup configuration and all logs."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
@@ -173,6 +184,7 @@ async def delete_config(db: AsyncSession = Depends(get_db)):
async def test_connection(
repo_url: str = Query(..., description="GitHub repository URL"),
token: str = Query(..., description="Personal Access Token"),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""Test GitHub connection with provided credentials."""
result = await github_backup_service.test_connection(repo_url, token)
@@ -180,7 +192,10 @@ async def test_connection(
@router.post("/test-stored", response_model=GitHubTestConnectionResponse)
async def test_stored_connection(db: AsyncSession = Depends(get_db)):
async def test_stored_connection(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""Test GitHub connection using stored configuration."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
@@ -196,7 +211,10 @@ async def test_stored_connection(db: AsyncSession = Depends(get_db)):
@router.post("/run", response_model=GitHubBackupTriggerResponse)
async def trigger_backup(db: AsyncSession = Depends(get_db)):
async def trigger_backup(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""Manually trigger a backup."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
@@ -213,7 +231,10 @@ async def trigger_backup(db: AsyncSession = Depends(get_db)):
@router.get("/status", response_model=GitHubBackupStatus)
async def get_status(db: AsyncSession = Depends(get_db)):
async def get_status(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""Get current backup status."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
config = result.scalar_one_or_none()
@@ -245,6 +266,7 @@ async def get_logs(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""Get backup logs."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -282,6 +304,7 @@ async def get_logs(
async def clear_logs(
keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
):
"""Clear backup logs, optionally keeping the most recent entries."""
result = await db.execute(select(GitHubBackupConfig).limit(1))
+10
View File
@@ -7,9 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
from backend.app.models.printer import Printer
from backend.app.models.user import User
from backend.app.schemas.kprofile import (
KProfile,
KProfileCreate,
@@ -30,6 +33,7 @@ async def get_kprofiles(
printer_id: int,
nozzle_diameter: str = "0.4",
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
):
"""Get K-profiles from a printer.
@@ -78,6 +82,7 @@ async def set_kprofile(
printer_id: int,
profile: KProfileCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
):
"""Create or update a K-profile on the printer.
@@ -178,6 +183,7 @@ async def set_kprofiles_batch(
printer_id: int,
profiles: list[KProfileCreate],
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
):
"""Create multiple K-profiles in a single command (for dual-nozzle).
@@ -236,6 +242,7 @@ async def delete_kprofile(
printer_id: int,
profile: KProfileDelete,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
):
"""Delete a K-profile from the printer.
@@ -278,6 +285,7 @@ async def delete_kprofile(
async def get_kprofile_notes(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
):
"""Get all K-profile notes for a printer.
@@ -305,6 +313,7 @@ async def set_kprofile_note(
printer_id: int,
note_data: KProfileNote,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
):
"""Set or update a note for a K-profile.
@@ -353,6 +362,7 @@ async def delete_kprofile_note(
printer_id: int,
setting_id: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
):
"""Delete a note for a K-profile.
+25 -3
View File
@@ -8,9 +8,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
from backend.app.models.printer import Printer
from backend.app.models.user import User
from backend.app.schemas.maintenance import (
MaintenanceHistoryResponse,
MaintenanceStatus,
@@ -114,7 +117,10 @@ async def ensure_default_types(db: AsyncSession) -> None:
@router.get("/types", response_model=list[MaintenanceTypeResponse])
async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
async def get_maintenance_types(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
):
"""Get all maintenance types."""
await ensure_default_types(db)
result = await db.execute(select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name))
@@ -125,6 +131,7 @@ async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
async def create_maintenance_type(
data: MaintenanceTypeCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
):
"""Create a custom maintenance type."""
new_type = MaintenanceType(
@@ -146,6 +153,7 @@ async def update_maintenance_type(
type_id: int,
data: MaintenanceTypeUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
):
"""Update a maintenance type."""
result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
@@ -166,6 +174,7 @@ async def update_maintenance_type(
async def delete_maintenance_type(
type_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
):
"""Delete a custom maintenance type."""
result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
@@ -331,13 +340,17 @@ async def _get_printer_maintenance_internal(
async def get_printer_maintenance(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
):
"""Get maintenance overview for a specific printer."""
return await _get_printer_maintenance_internal(printer_id, db, commit=True)
@router.get("/overview", response_model=list[PrinterMaintenanceOverview])
async def get_all_maintenance_overview(db: AsyncSession = Depends(get_db)):
async def get_all_maintenance_overview(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
):
"""Get maintenance overview for all active printers."""
await ensure_default_types(db)
@@ -361,6 +374,7 @@ async def update_printer_maintenance(
item_id: int,
data: PrinterMaintenanceUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
):
"""Update a printer maintenance item (e.g., custom interval, enabled)."""
result = await db.execute(
@@ -386,6 +400,7 @@ async def assign_maintenance_type(
printer_id: int,
type_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
):
"""Assign a maintenance type to a specific printer (for custom types)."""
# Verify printer exists
@@ -438,6 +453,7 @@ async def assign_maintenance_type(
async def remove_maintenance_item(
item_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
):
"""Remove a maintenance item (unassign a custom type from a printer)."""
result = await db.execute(
@@ -464,6 +480,7 @@ async def perform_maintenance(
item_id: int,
data: PerformMaintenanceRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
):
"""Mark maintenance as performed (reset the counter)."""
result = await db.execute(
@@ -541,6 +558,7 @@ async def perform_maintenance(
async def get_maintenance_history(
item_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
):
"""Get maintenance history for a specific item."""
result = await db.execute(
@@ -552,7 +570,10 @@ async def get_maintenance_history(
@router.get("/summary")
async def get_maintenance_summary(db: AsyncSession = Depends(get_db)):
async def get_maintenance_summary(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
):
"""Get a summary of maintenance status across all printers."""
await ensure_default_types(db)
@@ -589,6 +610,7 @@ async def set_printer_hours(
printer_id: int,
total_hours: float,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
):
"""Set the total print hours for a printer (adjusts offset to match).
@@ -4,8 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
from backend.app.models.user import User
from backend.app.schemas.notification_template import (
EVENT_VARIABLES,
SAMPLE_DATA,
@@ -45,14 +48,19 @@ EVENT_NAMES = {
@router.get("", response_model=list[NotificationTemplateResponse])
@router.get("/", response_model=list[NotificationTemplateResponse])
async def get_templates(db: AsyncSession = Depends(get_db)):
async def get_templates(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
):
"""Get all notification templates."""
result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))
return result.scalars().all()
@router.get("/variables", response_model=list[EventVariablesResponse])
async def get_variables():
async def get_variables(
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
):
"""Get available variables for each event type."""
return [
EventVariablesResponse(
@@ -65,7 +73,11 @@ async def get_variables():
@router.get("/{template_id}", response_model=NotificationTemplateResponse)
async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
async def get_template(
template_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
):
"""Get a single notification template."""
result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
template = result.scalar_one_or_none()
@@ -79,6 +91,7 @@ async def update_template(
template_id: int,
update: NotificationTemplateUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
):
"""Update a notification template."""
result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
@@ -101,7 +114,11 @@ async def update_template(
@router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
async def reset_template(
template_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
):
"""Reset a notification template to its default values."""
result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
template = result.scalar_one_or_none()
@@ -129,7 +146,10 @@ async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
@router.post("/preview", response_model=TemplatePreviewResponse)
async def preview_template(request: TemplatePreviewRequest):
async def preview_template(
request: TemplatePreviewRequest,
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
):
"""Preview a template with sample data."""
sample = SAMPLE_DATA.get(request.event_type, {})
+20 -2
View File
@@ -8,8 +8,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.notification import NotificationLog, NotificationProvider
from backend.app.models.user import User
from backend.app.schemas.notification import (
NotificationLogResponse,
NotificationLogStats,
@@ -86,7 +89,10 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
@router.get("/", response_model=list[NotificationProviderResponse])
async def list_notification_providers(db: AsyncSession = Depends(get_db)):
async def list_notification_providers(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
):
"""List all notification providers."""
result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
providers = result.scalars().all()
@@ -98,6 +104,7 @@ async def list_notification_providers(db: AsyncSession = Depends(get_db)):
async def create_notification_provider(
provider_data: NotificationProviderCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
):
"""Create a new notification provider."""
provider = NotificationProvider(
@@ -153,6 +160,7 @@ async def create_notification_provider(
async def test_notification_config(
test_request: NotificationTestRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
):
"""Test notification configuration before saving."""
success, message = await notification_service.send_test_notification(
@@ -163,7 +171,10 @@ async def test_notification_config(
@router.post("/test-all")
async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
async def test_all_notification_providers(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
):
"""Send a test notification to all enabled providers."""
result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
providers = result.scalars().all()
@@ -222,6 +233,7 @@ async def get_notification_logs(
success: bool | None = Query(default=None),
days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
):
"""Get notification logs with optional filters."""
query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
@@ -278,6 +290,7 @@ async def get_notification_logs(
async def get_notification_log_stats(
days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
):
"""Get notification log statistics."""
cutoff = datetime.utcnow() - timedelta(days=days)
@@ -323,6 +336,7 @@ async def get_notification_log_stats(
async def clear_notification_logs(
older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
):
"""Clear old notification logs."""
cutoff = datetime.utcnow() - timedelta(days=older_than_days)
@@ -345,6 +359,7 @@ async def clear_notification_logs(
async def get_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
):
"""Get a specific notification provider."""
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
@@ -361,6 +376,7 @@ async def update_notification_provider(
provider_id: int,
update_data: NotificationProviderUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
):
"""Update a notification provider."""
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
@@ -392,6 +408,7 @@ async def update_notification_provider(
async def delete_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
):
"""Delete a notification provider."""
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
@@ -413,6 +430,7 @@ async def delete_notification_provider(
async def test_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
):
"""Send a test notification using an existing provider."""
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
+22 -4
View File
@@ -8,8 +8,11 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.pending_upload import PendingUpload
from backend.app.models.user import User
from backend.app.services.archive import ArchiveService
router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
@@ -41,7 +44,10 @@ class PendingUploadResponse(BaseModel):
@router.get("/", response_model=list[PendingUploadResponse])
async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
async def list_pending_uploads(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
):
"""List all pending uploads."""
result = await db.execute(
select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
@@ -51,7 +57,10 @@ async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
@router.get("/count")
async def get_pending_count(db: AsyncSession = Depends(get_db)):
async def get_pending_count(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
):
"""Get count of pending uploads."""
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
count = len(result.scalars().all())
@@ -64,7 +73,10 @@ async def get_pending_count(db: AsyncSession = Depends(get_db)):
@router.post("/archive-all")
async def archive_all_pending(db: AsyncSession = Depends(get_db)):
async def archive_all_pending(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
):
"""Archive all pending uploads."""
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
pending_uploads = result.scalars().all()
@@ -117,7 +129,10 @@ async def archive_all_pending(db: AsyncSession = Depends(get_db)):
@router.delete("/discard-all")
async def discard_all_pending(db: AsyncSession = Depends(get_db)):
async def discard_all_pending(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
):
"""Discard all pending uploads."""
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
pending_uploads = result.scalars().all()
@@ -144,6 +159,7 @@ async def discard_all_pending(db: AsyncSession = Depends(get_db)):
async def get_pending_upload(
upload_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
):
"""Get a specific pending upload."""
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
@@ -160,6 +176,7 @@ async def archive_pending_upload(
upload_id: int,
request: ArchiveRequest = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
):
"""Archive a pending upload."""
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
@@ -227,6 +244,7 @@ async def archive_pending_upload(
async def discard_pending_upload(
upload_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
):
"""Discard a pending upload without archiving."""
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
+11 -3
View File
@@ -12,7 +12,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
@@ -173,6 +173,7 @@ async def list_queue(
printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
status: str | None = Query(None, description="Filter by status"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
):
"""List all queue items, optionally filtered by printer or status."""
query = (
@@ -204,7 +205,7 @@ async def list_queue(
async def add_to_queue(
data: PrintQueueItemCreate,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_auth_if_enabled),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
):
"""Add an item to the print queue."""
# Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
@@ -425,7 +426,11 @@ async def bulk_update_queue_items(
@router.get("/{item_id}", response_model=PrintQueueItemResponse)
async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
async def get_queue_item(
item_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
):
"""Get a specific queue item."""
result = await db.execute(
select(PrintQueueItem)
@@ -553,6 +558,7 @@ async def delete_queue_item(
async def reorder_queue(
data: PrintQueueReorder,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
):
"""Bulk update positions for queue items."""
for reorder_item in data.items:
@@ -605,6 +611,7 @@ async def cancel_queue_item(
async def stop_queue_item(
item_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
):
"""Stop an actively printing queue item."""
import asyncio
@@ -675,6 +682,7 @@ async def stop_queue_item(
async def start_queue_item(
item_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
):
"""Manually start a staged (manual_start) queue item.
+2
View File
@@ -1361,6 +1361,7 @@ async def reset_ams_slot(
ams_id: int,
tray_id: int,
db: AsyncSession = Depends(get_db),
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
):
"""Reset an AMS slot to empty/unconfigured state.
@@ -1403,6 +1404,7 @@ async def reset_ams_slot(
async def debug_simulate_print_complete(
printer_id: int,
db: AsyncSession = Depends(get_db),
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
):
"""DEBUG: Simulate print completion to test freeze behavior.
+27
View File
@@ -14,13 +14,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.api.routes.library import get_library_dir
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.archive import PrintArchive
from backend.app.models.library import LibraryFile, LibraryFolder
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.project import Project
from backend.app.models.project_bom import ProjectBOMItem
from backend.app.models.user import User
from backend.app.schemas.project import (
ArchivePreview,
BatchAddArchives,
@@ -154,6 +157,7 @@ async def compute_project_stats(
async def list_projects(
status: str | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
):
"""List all projects with basic stats."""
query = select(Project)
@@ -258,6 +262,7 @@ async def list_projects(
async def create_project(
data: ProjectCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
):
"""Create a new project."""
# Verify parent exists if specified
@@ -319,6 +324,7 @@ async def create_project(
@router.get("/templates", response_model=list[ProjectListResponse])
async def list_templates(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
):
"""List all project templates."""
result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
@@ -356,6 +362,7 @@ async def create_project_from_template(
template_id: int,
name: str = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
):
"""Create a new project from a template."""
result = await db.execute(select(Project).where(Project.id == template_id))
@@ -470,6 +477,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
async def get_project(
project_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
):
"""Get a project by ID with detailed stats."""
result = await db.execute(select(Project).where(Project.id == project_id))
@@ -519,6 +527,7 @@ async def update_project(
project_id: int,
data: ProjectUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Update a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
@@ -609,6 +618,7 @@ async def update_project(
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_DELETE),
):
"""Delete a project. Archives and queue items will have project_id set to NULL."""
result = await db.execute(select(Project).where(Project.id == project_id))
@@ -628,6 +638,7 @@ async def list_project_archives(
limit: int = 100,
offset: int = 0,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
):
"""List archives in a project."""
# Verify project exists
@@ -657,6 +668,7 @@ async def list_project_archives(
async def list_project_queue(
project_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
):
"""List queue items in a project."""
# Verify project exists
@@ -677,6 +689,7 @@ async def add_archives_to_project(
project_id: int,
data: BatchAddArchives,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Batch add archives to a project."""
# Verify project exists
@@ -701,6 +714,7 @@ async def add_queue_items_to_project(
project_id: int,
data: BatchAddQueueItems,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Batch add queue items to a project."""
# Verify project exists
@@ -725,6 +739,7 @@ async def remove_archives_from_project(
project_id: int,
data: BatchAddArchives,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Remove archives from a project (sets project_id to NULL)."""
updated = 0
@@ -811,6 +826,7 @@ async def upload_attachment(
project_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Upload an attachment to a project."""
logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
@@ -888,6 +904,7 @@ async def download_attachment(
project_id: int,
filename: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
):
"""Download an attachment from a project."""
# Verify project exists
@@ -919,6 +936,7 @@ async def delete_attachment(
project_id: int,
filename: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Delete an attachment from a project."""
# Verify project exists
@@ -962,6 +980,7 @@ async def delete_attachment(
async def list_bom_items(
project_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
):
"""List all BOM items for a project."""
# Verify project exists
@@ -1013,6 +1032,7 @@ async def create_bom_item(
project_id: int,
data: BOMItemCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Add a BOM item to a project."""
# Verify project exists
@@ -1072,6 +1092,7 @@ async def update_bom_item(
item_id: int,
data: BOMItemUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Update a BOM item."""
result = await db.execute(
@@ -1135,6 +1156,7 @@ async def delete_bom_item(
project_id: int,
item_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Delete a BOM item."""
result = await db.execute(
@@ -1157,6 +1179,7 @@ async def delete_bom_item(
async def create_template_from_project(
project_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
):
"""Create a template from an existing project."""
result = await db.execute(select(Project).where(Project.id == project_id))
@@ -1238,6 +1261,7 @@ async def get_project_timeline(
project_id: int,
limit: int = 50,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
):
"""Get timeline of events for a project."""
# Verify project exists
@@ -1338,6 +1362,7 @@ async def export_project(
project_id: int,
format: str = "zip", # "zip" (with files) or "json" (metadata only)
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
):
"""Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
result = await db.execute(select(Project).where(Project.id == project_id))
@@ -1458,6 +1483,7 @@ async def export_project(
async def import_project(
data: ProjectImport,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
):
"""Import a project with optional BOM items and linked folders."""
# Create the project
@@ -1551,6 +1577,7 @@ async def import_project(
async def import_project_file(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
):
"""Import a project from a ZIP or JSON file."""
if not file.filename:
+32 -7
View File
@@ -8,9 +8,12 @@ from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.config import settings as app_settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -39,7 +42,10 @@ async def set_setting(db: AsyncSession, key: str, value: str) -> None:
@router.get("", response_model=AppSettings)
@router.get("/", response_model=AppSettings)
async def get_settings(db: AsyncSession = Depends(get_db)):
async def get_settings(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Get all application settings."""
settings_dict = DEFAULT_SETTINGS.model_dump()
@@ -96,6 +102,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
async def update_settings(
settings_update: AppSettingsUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Update application settings."""
update_data = settings_update.model_dump(exclude_unset=True)
@@ -153,13 +160,17 @@ async def update_settings(
async def patch_settings(
settings_update: AppSettingsUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Partially update application settings (same as PUT, for REST compatibility)."""
return await update_settings(settings_update, db)
return await update_settings(settings_update, db, _)
@router.post("/reset", response_model=AppSettings)
async def reset_settings(db: AsyncSession = Depends(get_db)):
async def reset_settings(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Reset all settings to defaults."""
# Delete all settings
result = await db.execute(select(Settings))
@@ -185,7 +196,10 @@ async def check_ffmpeg():
@router.get("/spoolman")
async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
async def get_spoolman_settings(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Get Spoolman integration settings."""
spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
spoolman_url = await get_setting(db, "spoolman_url") or ""
@@ -202,6 +216,7 @@ async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
async def update_spoolman_settings(
settings: dict,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Update Spoolman integration settings."""
if "spoolman_enabled" in settings:
@@ -219,7 +234,10 @@ async def update_spoolman_settings(
@router.get("/backup")
async def create_backup(db: AsyncSession = Depends(get_db)):
async def create_backup(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
):
"""Create a complete backup (database + all files) as a ZIP.
This is a simplified backup that includes the entire SQLite database
@@ -280,6 +298,7 @@ async def create_backup(db: AsyncSession = Depends(get_db)):
async def restore_backup(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
):
"""Restore from a complete backup ZIP.
@@ -363,7 +382,10 @@ async def get_virtual_printer_models():
@router.get("/virtual-printer")
async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
async def get_virtual_printer_settings(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Get virtual printer settings and status."""
from backend.app.services.virtual_printer import (
DEFAULT_VIRTUAL_PRINTER_MODEL,
@@ -391,6 +413,7 @@ async def update_virtual_printer_settings(
mode: str = None,
model: str = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Update virtual printer settings and restart services if needed."""
from backend.app.services.virtual_printer import (
@@ -482,7 +505,9 @@ async def update_virtual_printer_settings(
@router.get("/mqtt/status")
async def get_mqtt_status():
async def get_mqtt_status(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Get MQTT relay connection status."""
from backend.app.services.mqtt_relay import mqtt_relay
+61 -13
View File
@@ -9,9 +9,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.api.routes.settings import get_setting
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.printer import Printer
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.user import User
from backend.app.schemas.smart_plug import (
HAEntity,
HASensorEntity,
@@ -38,7 +41,10 @@ router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
@router.get("/", response_model=list[SmartPlugResponse])
async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
async def list_smart_plugs(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
):
"""List all smart plugs."""
result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
return list(result.scalars().all())
@@ -48,6 +54,7 @@ async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
async def create_smart_plug(
data: SmartPlugCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
):
"""Create a new smart plug."""
# Validate printer_id if provided
@@ -142,7 +149,11 @@ async def create_smart_plug(
@router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
async def get_smart_plug_by_printer(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
):
"""Get the main smart plug assigned to a printer.
When multiple plugs are assigned (e.g., a regular plug + script),
@@ -165,7 +176,11 @@ async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(
@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
async def get_script_plugs_by_printer(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
):
"""Get all HA script plugs assigned to a printer.
Returns only script entities (script.*) for the printer that have
@@ -244,7 +259,10 @@ class DiscoveredTasmotaDevice(BaseModel):
@router.post("/discover/scan", response_model=TasmotaScanStatus)
async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=None)):
async def start_tasmota_scan(
request: TasmotaScanRequest | None = Body(default=None),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
):
"""Start an IP range scan for Tasmota devices.
Auto-detects local network if no IP range provided.
@@ -268,7 +286,9 @@ async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=N
@router.get("/discover/status", response_model=TasmotaScanStatus)
async def get_tasmota_scan_status():
async def get_tasmota_scan_status(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
):
"""Get the current Tasmota scan status."""
scanned, total = tasmota_scanner.progress
return TasmotaScanStatus(
@@ -279,7 +299,9 @@ async def get_tasmota_scan_status():
@router.post("/discover/stop", response_model=TasmotaScanStatus)
async def stop_tasmota_scan():
async def stop_tasmota_scan(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
):
"""Stop the current Tasmota scan."""
tasmota_scanner.stop()
scanned, total = tasmota_scanner.progress
@@ -291,7 +313,9 @@ async def stop_tasmota_scan():
@router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
async def get_discovered_tasmota_devices():
async def get_discovered_tasmota_devices(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
):
"""Get list of discovered Tasmota devices."""
return [
DiscoveredTasmotaDevice(
@@ -309,7 +333,10 @@ async def get_discovered_tasmota_devices():
@router.post("/ha/test-connection", response_model=HATestConnectionResponse)
async def test_ha_connection(request: HATestConnectionRequest):
async def test_ha_connection(
request: HATestConnectionRequest,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
):
"""Test connection to Home Assistant."""
result = await homeassistant_service.test_connection(request.url, request.token)
return HATestConnectionResponse(**result)
@@ -319,6 +346,7 @@ async def test_ha_connection(request: HATestConnectionRequest):
async def list_ha_entities(
db: AsyncSession = Depends(get_db),
search: str | None = None,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
):
"""List available Home Assistant entities.
@@ -340,7 +368,10 @@ async def list_ha_entities(
@router.get("/ha/sensors", response_model=list[HASensorEntity])
async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
async def list_ha_sensor_entities(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
):
"""List available Home Assistant sensor entities for energy monitoring.
Returns sensors with power/energy units (W, kW, kWh, Wh).
@@ -359,7 +390,11 @@ async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
@router.get("/{plug_id}", response_model=SmartPlugResponse)
async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
async def get_smart_plug(
plug_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
):
"""Get a specific smart plug."""
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
plug = result.scalar_one_or_none()
@@ -373,6 +408,7 @@ async def update_smart_plug(
plug_id: int,
data: SmartPlugUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
):
"""Update a smart plug."""
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
@@ -489,7 +525,11 @@ async def update_smart_plug(
@router.delete("/{plug_id}")
async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
async def delete_smart_plug(
plug_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
):
"""Delete a smart plug."""
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
plug = result.scalar_one_or_none()
@@ -529,6 +569,7 @@ async def control_smart_plug(
plug_id: int,
control: SmartPlugControl,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
):
"""Manual control: on/off/toggle."""
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
@@ -635,7 +676,11 @@ async def trigger_associated_scripts(printer_id: int, plug_state: str, db: Async
@router.get("/{plug_id}/status", response_model=SmartPlugStatus)
async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
async def get_plug_status(
plug_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
):
"""Get current plug status from device including energy data."""
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
plug = result.scalar_one_or_none()
@@ -763,7 +808,10 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
@router.post("/test-connection")
async def test_connection(data: SmartPlugTestConnection):
async def test_connection(
data: SmartPlugTestConnection,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
):
"""Test connection to a Tasmota device."""
result = await tasmota_service.test_connection(
data.ip_address,
+36 -8
View File
@@ -7,9 +7,12 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.printer import Printer
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.services.printer_manager import printer_manager
from backend.app.services.spoolman import (
close_spoolman_client,
@@ -72,7 +75,10 @@ async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
@router.get("/status", response_model=SpoolmanStatus)
async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
async def get_spoolman_status(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get Spoolman integration status."""
enabled, url, _ = await get_spoolman_settings(db)
@@ -89,7 +95,10 @@ async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
@router.post("/connect")
async def connect_spoolman(db: AsyncSession = Depends(get_db)):
async def connect_spoolman(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Connect to Spoolman server using configured URL."""
enabled, url, _ = await get_spoolman_settings(db)
@@ -119,7 +128,9 @@ async def connect_spoolman(db: AsyncSession = Depends(get_db)):
@router.post("/disconnect")
async def disconnect_spoolman():
async def disconnect_spoolman(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Disconnect from Spoolman server."""
await close_spoolman_client()
return {"success": True, "message": "Disconnected from Spoolman"}
@@ -129,6 +140,7 @@ async def disconnect_spoolman():
async def sync_printer_ams(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
):
"""Sync AMS data from a specific printer to Spoolman."""
# Check if Spoolman is enabled and connected
@@ -267,7 +279,10 @@ async def sync_printer_ams(
@router.post("/sync-all", response_model=SyncResult)
async def sync_all_printers(db: AsyncSession = Depends(get_db)):
async def sync_all_printers(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
):
"""Sync AMS data from all connected printers to Spoolman."""
# Check if Spoolman is enabled
enabled, url, _ = await get_spoolman_settings(db)
@@ -392,7 +407,10 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
@router.get("/spools")
async def get_spools(db: AsyncSession = Depends(get_db)):
async def get_spools(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get all spools from Spoolman."""
enabled, url, _ = await get_spoolman_settings(db)
if not enabled:
@@ -413,7 +431,10 @@ async def get_spools(db: AsyncSession = Depends(get_db)):
@router.get("/filaments")
async def get_filaments(db: AsyncSession = Depends(get_db)):
async def get_filaments(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get all filaments from Spoolman."""
enabled, url, _ = await get_spoolman_settings(db)
if not enabled:
@@ -445,7 +466,10 @@ class UnlinkedSpool(BaseModel):
@router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
async def get_unlinked_spools(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get all Spoolman spools that don't have a tag (not linked to AMS)."""
enabled, url, _ = await get_spoolman_settings(db)
if not enabled:
@@ -487,7 +511,10 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
@router.get("/spools/linked")
async def get_linked_spools(db: AsyncSession = Depends(get_db)):
async def get_linked_spools(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
enabled, url, _ = await get_spoolman_settings(db)
if not enabled:
@@ -530,6 +557,7 @@ async def link_spool(
spool_id: int,
request: LinkSpoolRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
):
"""Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
enabled, url, _ = await get_spoolman_settings(db)
+17 -4
View File
@@ -15,14 +15,17 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.config import APP_VERSION, settings
from backend.app.core.database import async_session
from backend.app.core.permissions import Permission
from backend.app.models.archive import PrintArchive
from backend.app.models.filament import Filament
from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.user import User
router = APIRouter(prefix="/support", tags=["support"])
logger = logging.getLogger(__name__)
@@ -107,7 +110,9 @@ def _apply_log_level(debug: bool):
@router.get("/debug-logging", response_model=DebugLoggingState)
async def get_debug_logging_state():
async def get_debug_logging_state(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Get current debug logging state."""
global _debug_logging_enabled, _debug_logging_enabled_at
@@ -128,7 +133,10 @@ async def get_debug_logging_state():
@router.post("/debug-logging", response_model=DebugLoggingState)
async def toggle_debug_logging(toggle: DebugLoggingToggle):
async def toggle_debug_logging(
toggle: DebugLoggingToggle,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Enable or disable debug logging."""
global _debug_logging_enabled, _debug_logging_enabled_at
@@ -273,6 +281,7 @@ async def get_logs(
limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
search: str | None = Query(None, description="Search in message or logger name"),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Get recent application log entries with optional filtering."""
entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
@@ -285,7 +294,9 @@ async def get_logs(
@router.delete("/logs")
async def clear_logs():
async def clear_logs(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Clear the application log file."""
log_file = settings.log_dir / "bambuddy.log"
@@ -445,7 +456,9 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
@router.get("/bundle")
async def generate_support_bundle():
async def generate_support_bundle(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Generate a support bundle ZIP file for issue reporting."""
global _debug_logging_enabled, _debug_logging_enabled_at
+7 -1
View File
@@ -9,13 +9,16 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.config import APP_VERSION, settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.archive import PrintArchive
from backend.app.models.filament import Filament
from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.user import User
from backend.app.services.printer_manager import printer_manager
router = APIRouter(prefix="/system", tags=["system"])
@@ -60,7 +63,10 @@ def format_uptime(seconds: float) -> str:
@router.get("/info")
async def get_system_info(db: AsyncSession = Depends(get_db)):
async def get_system_info(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
):
"""Get comprehensive system information."""
# Database stats
+18 -4
View File
@@ -11,8 +11,11 @@ import httpx
from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.user import User
logger = logging.getLogger(__name__)
@@ -153,7 +156,10 @@ def is_newer_version(latest: str, current: str) -> bool:
@router.get("/version")
async def get_version():
"""Get current application version."""
"""Get current application version.
Note: Unauthenticated - needed to display version in UI without login.
"""
return {
"version": APP_VERSION,
"repo": GITHUB_REPO,
@@ -161,7 +167,10 @@ async def get_version():
@router.get("/check")
async def check_for_updates(db: AsyncSession = Depends(get_db)):
async def check_for_updates(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
):
"""Check GitHub for available updates."""
global _update_status
@@ -432,7 +441,10 @@ async def _perform_update():
@router.post("/apply")
async def apply_update(background_tasks: BackgroundTasks):
async def apply_update(
background_tasks: BackgroundTasks,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Apply available update (git pull + rebuild)."""
global _update_status
@@ -473,6 +485,8 @@ async def apply_update(background_tasks: BackgroundTasks):
@router.get("/status")
async def get_update_status():
async def get_update_status(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
):
"""Get current update status."""
return _update_status
@@ -0,0 +1,173 @@
"""Integration tests for API endpoint authentication.
Tests that verify endpoints properly enforce authentication when auth is enabled,
and allow access when auth is disabled (CVE-2026-25505 fix verification).
"""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
class TestEndpointAuthenticationEnforcement:
"""Tests that endpoints enforce authentication when auth is enabled."""
@pytest.fixture
async def user_factory(self, db_session):
"""Factory to create test users."""
async def _create_user(**kwargs):
from passlib.hash import bcrypt
from backend.app.models.user import User
defaults = {
"username": "testuser",
"password_hash": bcrypt.hash("testpass123"),
"is_admin": False,
}
defaults.update(kwargs)
user = User(**defaults)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
return _create_user
@pytest.fixture
async def admin_user(self, user_factory, db_session):
"""Create an admin user for testing."""
from sqlalchemy import select
from backend.app.models.group import Group
# Get or create admin group
result = await db_session.execute(select(Group).where(Group.name == "Administrators"))
admin_group = result.scalar_one_or_none()
user = await user_factory(username="admin", is_admin=True)
if admin_group:
user.groups.append(admin_group)
await db_session.commit()
return user
@pytest.fixture
async def auth_token(self, admin_user, async_client: AsyncClient):
"""Get a valid auth token for the admin user."""
response = await async_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "testpass123"},
)
if response.status_code == 200:
return response.json().get("access_token")
return None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_filaments_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
"""Verify filaments list is accessible when auth is disabled."""
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
response = await async_client.get("/api/v1/filaments/")
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_external_links_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
"""Verify external links list is accessible when auth is disabled."""
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
response = await async_client.get("/api/v1/external-links/")
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_notifications_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
"""Verify notifications list is accessible when auth is disabled."""
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
response = await async_client.get("/api/v1/notifications/")
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_maintenance_types_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
"""Verify maintenance types is accessible when auth is disabled."""
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
response = await async_client.get("/api/v1/maintenance/types")
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_system_info_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
"""Verify system info is accessible when auth is disabled."""
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
response = await async_client.get("/api/v1/system/info")
assert response.status_code == 200
class TestImageEndpointsPublicAccess:
"""Tests that image endpoints remain accessible without auth.
These endpoints serve images via <img> tags which cannot send Authorization headers.
"""
@pytest.fixture
async def link_with_icon(self, db_session):
"""Create an external link with a custom icon for testing."""
from backend.app.models.external_link import ExternalLink
link = ExternalLink(
name="Test Link",
url="https://example.com",
icon="Link",
sort_order=0,
custom_icon=None, # No custom icon set
)
db_session.add(link)
await db_session.commit()
await db_session.refresh(link)
return link
@pytest.mark.asyncio
@pytest.mark.integration
async def test_external_link_icon_returns_404_when_no_icon(self, async_client: AsyncClient, link_with_icon):
"""Verify icon endpoint returns 404 (not 401) when no icon is set.
This confirms the endpoint doesn't require auth - a 401 would indicate
auth is being enforced, but 404 means the endpoint is accessible but
no icon exists.
"""
response = await async_client.get(f"/api/v1/external-links/{link_with_icon.id}/icon")
# Should be 404 (no icon set), not 401 (unauthorized)
assert response.status_code == 404
assert "No custom icon set" in response.json().get("detail", "")
class TestAuthenticationPatterns:
"""Tests for authentication helper functions and patterns."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_require_permission_if_auth_enabled_allows_access_when_disabled(self, async_client: AsyncClient):
"""Verify require_permission_if_auth_enabled allows access when auth disabled."""
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
# Test a protected endpoint
response = await async_client.get("/api/v1/filaments/")
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_multiple_endpoints_accessible_when_auth_disabled(self, async_client: AsyncClient):
"""Verify multiple protected endpoints are accessible when auth is disabled."""
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
endpoints = [
"/api/v1/filaments/",
"/api/v1/external-links/",
"/api/v1/notifications/",
"/api/v1/maintenance/types",
]
for endpoint in endpoints:
response = await async_client.get(endpoint)
assert response.status_code == 200, f"Endpoint {endpoint} should be accessible"