Files
bambuddy/backend/app/services/library_trash.py
T
maziggy 396e9aa09e security: harden path-traversal class across routes + services; fifth CI backstop
Two attacker-controlled strings were being joined to library_dir with no
  resolve + containment check in the project ZIP import endpoint:

    - linked_folders[*].name from the request's project.json
    - per-entry zf.namelist() paths from the ZIP itself

  An absolute path in either field collapsed the join (Path("/lib") / "/etc"
  becomes Path("/etc") because pathlib discards the left side when the right
  is absolute) and the next write_bytes landed wherever the attacker chose.

  Adjacent finding from the routes audit: GET /archives/{id}/photos/{filename}
  had NO validation on filename and FileResponse-served arbitrary paths -
  the DELETE counterpart at least gated on the photos membership check.

  Adjacent finding from the services audit: ArchiveService.attach_timelapse
  wrote archive_dir / filename where filename ultimately came from a printer's
  FTP listing (compromised-printer threat model) or the /timelapse/select
  query param. A malicious printer that exposes a directory entry with ..
  segments could write the timelapse outside the archive directory.

  New backend/app/utils/safe_path.py::safe_join_under(parent, *parts) is the
  single source of truth: rejects empty / null-byte / absolute parts up-front,
  joins under parent, resolves both sides, asserts is_relative_to. Returns the
  resolved canonical path on success, raises HTTPException(400) on escape, or
  PathTraversalError when http=False (for service-layer callers that need to
  match a non-HTTP return contract).

  Wired into the import vectors, both archive photo handlers, and the
  attach_timelapse service. The full audit sweep inspected every Path/Name
  join in backend/app/api/routes/ AND backend/app/services/ - 25 route-layer
  sites + 8 service-layer sites confirmed safe and tagged with
  # SEC-PATH-OK: <reason> so future audits trust the inline guard at a glance.

  Fifth CI backstop test_route_path_arithmetic_is_safe_joined_or_marked
  AST-walks both layers and fails the build on any <dir-like>/<bare variable>
  join that doesn't either route through safe_join_under or carry the marker.
  The services layer is in scope because it receives values verbatim from the
  routes AND from external sources Bambuddy has no control over (the printer
  FTP-listing case above).

  SECURITY.md gets a fifth rule + a fifth row in the CI test mapping table;
  the rule now names the printer FTP-listing case explicitly so future
  services-layer audits set the right expectation.

--------------

  fix(library): suppress warning storm when bulk-uploading ZIPs of empty/stub STL files

  Uploading a ZIP of stub or empty STL files (e.g. the 24-byte
  "solid test\nendsolid test" shape) produced one WARNING per file in
  stl_thumbnail.py::generate_stl_thumbnail. The warnings were technically
  correct - trimesh returns a valid Mesh with zero vertices, the safeguard
  matches, and the function returns None so the library entry is still
  created without a thumbnail - but the volume turned a successful upload
  into thousands of WARNING lines in the journal.

  Two changes:

  1. The per-file "Failed to load STL or empty mesh" message in
     stl_thumbnail.py is now logger.debug instead of logger.warning. It's
     a per-file content observation, not an actionable error; the caller
     already handles None correctly. The branch now catches the rare
     "large enough but trimesh still can't parse it" case, visible in
     debug logs without spamming production.

  2. New module constant MIN_USABLE_STL_BYTES = 200 (smallest binary STL
     with one triangle is 134B, smallest ASCII ~150B; 200 is a safe floor
     below any real STL). The three thumbnail call sites in library.py
     (extract_zip_file, single-file upload, _backfill_external_stl_thumbnails)
     pre-skip files below this size before calling generate_stl_thumbnail.
     Stubs never enter the trimesh pipeline at all.

  Behavior is unchanged for real STLs: any file >=200 bytes runs through
  the existing pipeline, MAX_VERTICES still triggers simplification at
  100k vertices for the 256x256 thumbnail render, large files still get
  thumbnails.

------------

  fix(stl-thumbnail): silence matplotlib first-import noise (writable cache + font_manager log level)

  On first STL upload, three matplotlib-internal log lines surfaced:

    WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory
    INFO    [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf
    INFO    [matplotlib.font_manager] generated new fontManager

  The writable-dir warning fired because Bambuddy's $HOME isn't writable for
  matplotlib's default config path; matplotlib fell back to /tmp/matplotlib-XXX
  which lost the font cache on every host reboot, so font_manager rebuilt it
  each cold start - producing another batch of INFO lines.

  Fix is two small additions in stl_thumbnail.py before the matplotlib import:

  1. New _configure_matplotlib_cache() sets MPLCONFIGDIR to
     settings.base_dir/.cache/matplotlib (mkdir if missing) so the cache
     persists across container restarts and the writable-dir warning never
     fires. Respects an externally-set MPLCONFIGDIR so operators who chose
     their own path aren't overridden. Best-effort with a debug fallback if
     settings can't be imported or the mkdir fails.

  2. logging.getLogger("matplotlib.font_manager").setLevel(WARNING) at module
     import demotes the per-font INFO scan that fires when font_manager
     builds its cache cold. Real font warnings (>= WARNING) still surface.

  3 new tests: font_manager logger at WARNING after module import;
  _configure_matplotlib_cache creates the directory under base_dir and sets
  MPLCONFIGDIR; an externally-set MPLCONFIGDIR is preserved verbatim.
  5516 backend tests green, frontend gates clean.
2026-06-02 12:12:54 +02:00

391 lines
15 KiB
Python

"""Library trash sweeper + purge service (#1008).
Two-stage file deletion for the library:
1. Users / admins soft-delete files — the row stays in ``library_files`` with
``deleted_at`` stamped; the bytes stay on disk. This is handled inline in
``backend.app.api.routes.library`` and exposed to admins as a bulk "purge
old files" operation via :meth:`LibraryTrashService.purge_older_than`.
2. A background sweeper in this service hard-deletes rows (and their bytes)
whose ``deleted_at`` is older than the configured retention window.
External files (``is_external=True``) are never placed in the trash — their
bytes live outside Bambuddy's control, so there's nothing to restore.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import and_, delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import settings as app_settings
from backend.app.core.database import async_session
from backend.app.models.library import LibraryFile
from backend.app.models.settings import Settings
logger = logging.getLogger(__name__)
# Settings key used to persist the trash retention window (days). The sweeper
# reads this on every tick so the UI can change it without a restart.
TRASH_RETENTION_KEY = "library_trash_retention_days"
DEFAULT_RETENTION_DAYS = 30
# Clamp retention to a sensible range. 1 day is a reasonable floor (anything
# shorter just makes trash into hard-delete); 365 gives admins plenty of rope
# without letting accidental typos (99999) grow the table unboundedly.
MIN_RETENTION_DAYS = 1
MAX_RETENTION_DAYS = 365
# Auto-purge settings (#1008 follow-up). When enabled, the sweeper loop also
# runs the admin bulk purge once per 24h using the saved age threshold.
# Default-off so existing installs don't surprise users — opt-in via Settings.
AUTO_PURGE_ENABLED_KEY = "library_auto_purge_enabled"
AUTO_PURGE_DAYS_KEY = "library_auto_purge_days"
AUTO_PURGE_INCLUDE_NEVER_PRINTED_KEY = "library_auto_purge_include_never_printed"
AUTO_PURGE_LAST_RUN_KEY = "library_auto_purge_last_run"
DEFAULT_AUTO_PURGE_DAYS = 90
MIN_AUTO_PURGE_DAYS = 7 # anything shorter is begging for accidents
MAX_AUTO_PURGE_DAYS = 3650
def _to_absolute_path(relative_path: str | None) -> Path | None:
"""Mirror of the routes helper so this service has no route-module import.
Accepts the legacy absolute paths that predate the relative-path migration
verbatim; new rows always store paths relative to ``base_dir``.
"""
if not relative_path:
return None
path = Path(relative_path)
if path.is_absolute():
return path
return (
Path(app_settings.base_dir) / path
) # SEC-PATH-OK: relative_path is LibraryFile.file_path / LibraryFile.thumbnail_path — DB-stored, internally generated by the upload pipeline
def _age_cutoff(now: datetime, older_than_days: int) -> datetime:
return now - timedelta(days=older_than_days)
def _purge_filter(cutoff: datetime, include_never_printed: bool):
"""SQLAlchemy clause selecting files eligible for admin purge.
A file is "old" if either (a) ``last_printed_at`` is set and predates the
cutoff, or (b) ``last_printed_at`` is NULL *and* the file was uploaded
before the cutoff — but only when ``include_never_printed`` is True.
"""
last_printed_old = and_(
LibraryFile.last_printed_at.isnot(None),
LibraryFile.last_printed_at < cutoff,
)
if include_never_printed:
never_printed_old = and_(
LibraryFile.last_printed_at.is_(None),
LibraryFile.created_at < cutoff,
)
age_clause = or_(last_printed_old, never_printed_old)
else:
age_clause = last_printed_old
return and_(
LibraryFile.deleted_at.is_(None),
LibraryFile.is_external.is_(False),
age_clause,
)
class LibraryTrashService:
"""Manages the trash retention sweeper and admin-triggered bulk purges."""
def __init__(self):
self._scheduler_task: asyncio.Task | None = None
# Tick every 15 minutes — the window is a day, so this is plenty
# responsive without burning CPU.
self._check_interval = 900
async def start_scheduler(self):
"""Start the background sweeper task (idempotent)."""
if self._scheduler_task is not None:
return
logger.info("Starting library trash sweeper")
self._scheduler_task = asyncio.create_task(self._scheduler_loop())
def stop_scheduler(self):
if self._scheduler_task:
self._scheduler_task.cancel()
self._scheduler_task = None
logger.info("Stopped library trash sweeper")
async def _scheduler_loop(self):
while True:
try:
await asyncio.sleep(self._check_interval)
async with async_session() as db:
await self._sweep(db)
await self._maybe_run_auto_purge(db)
except asyncio.CancelledError:
break
except Exception as e: # pragma: no cover - defensive
logger.error("Error in library trash sweeper: %s", e)
await asyncio.sleep(60)
# ---- Settings -----------------------------------------------------
async def get_retention_days(self, db: AsyncSession | None = None) -> int:
if db is None:
async with async_session() as session:
return await self._read_retention(session)
return await self._read_retention(db)
@staticmethod
async def _read_retention(db: AsyncSession) -> int:
result = await db.execute(select(Settings.value).where(Settings.key == TRASH_RETENTION_KEY))
raw = result.scalar_one_or_none()
if raw is None:
return DEFAULT_RETENTION_DAYS
try:
days = int(raw)
except (TypeError, ValueError):
return DEFAULT_RETENTION_DAYS
return max(MIN_RETENTION_DAYS, min(MAX_RETENTION_DAYS, days))
async def set_retention_days(self, db: AsyncSession, days: int) -> int:
"""Persist the retention window. Clamped to [MIN, MAX]."""
clamped = max(MIN_RETENTION_DAYS, min(MAX_RETENTION_DAYS, int(days)))
result = await db.execute(select(Settings).where(Settings.key == TRASH_RETENTION_KEY))
row = result.scalar_one_or_none()
if row is None:
db.add(Settings(key=TRASH_RETENTION_KEY, value=str(clamped)))
else:
row.value = str(clamped)
await db.commit()
return clamped
@staticmethod
async def _read_setting(db: AsyncSession, key: str) -> str | None:
result = await db.execute(select(Settings.value).where(Settings.key == key))
return result.scalar_one_or_none()
@staticmethod
async def _write_setting(db: AsyncSession, key: str, value: str) -> None:
result = await db.execute(select(Settings).where(Settings.key == key))
row = result.scalar_one_or_none()
if row is None:
db.add(Settings(key=key, value=value))
else:
row.value = value
async def get_auto_purge_settings(self, db: AsyncSession) -> dict:
"""Return the current auto-purge config.
Returns a dict with ``enabled`` (bool), ``days`` (int, clamped) and
``include_never_printed`` (bool). Missing keys default to disabled /
90 days / include-never-printed-on, matching the manual purge UX.
"""
enabled_raw = await self._read_setting(db, AUTO_PURGE_ENABLED_KEY)
days_raw = await self._read_setting(db, AUTO_PURGE_DAYS_KEY)
incl_raw = await self._read_setting(db, AUTO_PURGE_INCLUDE_NEVER_PRINTED_KEY)
enabled = (enabled_raw or "false").lower() == "true"
try:
days = int(days_raw) if days_raw is not None else DEFAULT_AUTO_PURGE_DAYS
except (TypeError, ValueError):
days = DEFAULT_AUTO_PURGE_DAYS
days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, days))
include_never_printed = (incl_raw or "true").lower() == "true"
return {
"enabled": enabled,
"days": days,
"include_never_printed": include_never_printed,
}
async def set_auto_purge_settings(
self,
db: AsyncSession,
*,
enabled: bool,
days: int,
include_never_printed: bool,
) -> dict:
"""Persist auto-purge config; returns the saved (clamped) values."""
clamped_days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, int(days)))
await self._write_setting(db, AUTO_PURGE_ENABLED_KEY, "true" if enabled else "false")
await self._write_setting(db, AUTO_PURGE_DAYS_KEY, str(clamped_days))
await self._write_setting(
db,
AUTO_PURGE_INCLUDE_NEVER_PRINTED_KEY,
"true" if include_never_printed else "false",
)
await db.commit()
return {
"enabled": enabled,
"days": clamped_days,
"include_never_printed": include_never_printed,
}
async def _get_last_auto_purge_run(self, db: AsyncSession) -> datetime | None:
raw = await self._read_setting(db, AUTO_PURGE_LAST_RUN_KEY)
if not raw:
return None
try:
# Stored as ISO 8601 UTC; tolerate both with and without 'Z' suffix.
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
async def _stamp_last_auto_purge_run(self, db: AsyncSession, when: datetime) -> None:
await self._write_setting(db, AUTO_PURGE_LAST_RUN_KEY, when.isoformat())
await db.commit()
async def _maybe_run_auto_purge(self, db: AsyncSession) -> int:
"""If auto-purge is enabled and >=24h has elapsed since the last run, run it.
Returns the number of files moved to trash (0 if disabled or throttled).
The 24h throttle means a 15-minute sweeper cadence still only triggers
one actual purge per day, keeping the DB churn predictable.
"""
cfg = await self.get_auto_purge_settings(db)
if not cfg["enabled"]:
return 0
now = datetime.now(timezone.utc)
last = await self._get_last_auto_purge_run(db)
if last is not None and (now - last) < timedelta(hours=24):
return 0
moved = await self.purge_older_than(
db,
older_than_days=cfg["days"],
include_never_printed=cfg["include_never_printed"],
)
await self._stamp_last_auto_purge_run(db, now)
if moved:
logger.info("Library auto-purge: moved %d file(s) to trash (threshold=%d days)", moved, cfg["days"])
return moved
# ---- Preview / purge ---------------------------------------------
async def preview_purge(
self,
db: AsyncSession,
older_than_days: int,
include_never_printed: bool = True,
sample_limit: int = 5,
) -> dict:
"""Count + size of files eligible for purge. Reads only; never mutates."""
if older_than_days < 1:
return {"count": 0, "total_bytes": 0, "sample_filenames": []}
now = datetime.now(timezone.utc)
cutoff = _age_cutoff(now, older_than_days)
clause = _purge_filter(cutoff, include_never_printed)
count_result = await db.execute(select(func.count(LibraryFile.id)).where(clause))
count = int(count_result.scalar() or 0)
size_result = await db.execute(select(func.coalesce(func.sum(LibraryFile.file_size), 0)).where(clause))
total_bytes = int(size_result.scalar() or 0)
sample_result = await db.execute(
select(LibraryFile.filename).where(clause).order_by(LibraryFile.created_at).limit(sample_limit)
)
samples = [row[0] for row in sample_result.all()]
return {
"count": count,
"total_bytes": total_bytes,
"sample_filenames": samples,
"older_than_days": older_than_days,
"include_never_printed": include_never_printed,
}
async def purge_older_than(
self,
db: AsyncSession,
older_than_days: int,
include_never_printed: bool = True,
) -> int:
"""Move matching files to trash (stamps ``deleted_at``). Returns count."""
if older_than_days < 1:
return 0
now = datetime.now(timezone.utc)
cutoff = _age_cutoff(now, older_than_days)
clause = _purge_filter(cutoff, include_never_printed)
# We need the IDs so callers can audit or display them if they want.
# Doing a single UPDATE ... WHERE is safe even under concurrent
# uploads — the clause already excludes rows with deleted_at set.
id_result = await db.execute(select(LibraryFile.id).where(clause))
ids = [row[0] for row in id_result.all()]
if not ids:
return 0
await db.execute(LibraryFile.__table__.update().where(LibraryFile.id.in_(ids)).values(deleted_at=now))
await db.commit()
logger.info("Library purge: moved %d file(s) to trash (older_than_days=%d)", len(ids), older_than_days)
return len(ids)
# ---- Sweeper ------------------------------------------------------
async def _sweep(self, db: AsyncSession) -> int:
"""Hard-delete trashed rows whose retention window has elapsed."""
retention = await self._read_retention(db)
now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=retention)
result = await db.execute(
select(LibraryFile).where(
LibraryFile.deleted_at.isnot(None),
LibraryFile.deleted_at < cutoff,
)
)
rows = result.scalars().all()
if not rows:
return 0
deleted = 0
for row in rows:
self._unlink_on_disk(row)
deleted += 1
# Single DELETE is faster than N await db.delete() round-trips; we
# still need the Python loop above to unlink bytes on disk.
await db.execute(delete(LibraryFile).where(LibraryFile.id.in_([r.id for r in rows])))
await db.commit()
logger.info("Library trash sweeper: hard-deleted %d row(s) past %d-day retention", deleted, retention)
return deleted
@staticmethod
def _unlink_on_disk(row: LibraryFile) -> None:
"""Best-effort cleanup of the file + thumbnail on disk."""
for rel in (row.file_path, row.thumbnail_path):
abs_path = _to_absolute_path(rel)
if abs_path is None:
continue
try:
if abs_path.exists():
abs_path.unlink()
except OSError as e:
logger.warning("Trash sweep: failed to unlink %s: %s", abs_path, e)
# ---- User-facing trash ops ----------------------------------------
async def restore(self, db: AsyncSession, file: LibraryFile) -> LibraryFile:
"""Clear ``deleted_at`` so the file reappears in listings."""
file.deleted_at = None
await db.commit()
await db.refresh(file)
return file
async def hard_delete_now(self, db: AsyncSession, file: LibraryFile) -> None:
"""Bypass retention and delete this trashed file + its bytes immediately."""
self._unlink_on_disk(file)
await db.delete(file)
await db.commit()
library_trash_service = LibraryTrashService()