mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
chore: remove dead code and fail CI on unused frontend files
vulture and knip found code nothing reaches; each hit was checked for use by name (getattr dispatch, string references, lazy imports) and only the dead ones were removed, about 2,700 lines: - backend: the unused auth factories (RequireAdmin, RequirePermission, require_permission, require_role), the unused backend i18n package, printer fan/axis/motor/timelapse/liveview helpers no route uses, the amqtt-based virtual printer MQTT server and the FTP TLS proxy that nothing starts, the emailed-password reset functions (reset sends a link), the unreachable "password_reset_link" template branch, force_immediate (every notification has been sent immediately and also queued for the digest for a long time) and an unused parameter - frontend: UsersPage, WifiSignal, App.css, and the unused @floating-ui/dom and autoprefixer packages knip now runs in CI (frontend lint job), in test_frontend.sh and as npm run check:knip, checking unused files and dependencies; knip.jsonc says why the few exceptions are there. The basedpyright baseline shrinks by 10.
This commit is contained in:
@@ -3363,14 +3363,6 @@
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportAssignmentType",
|
||||
"range": {
|
||||
"startColumn": 32,
|
||||
"endColumn": 50,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportAssignmentType",
|
||||
"range": {
|
||||
@@ -4261,38 +4253,6 @@
|
||||
}
|
||||
],
|
||||
"./backend/app/services/email_service.py": [
|
||||
{
|
||||
"code": "reportAttributeAccessIssue",
|
||||
"range": {
|
||||
"startColumn": 43,
|
||||
"endColumn": 50,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportAttributeAccessIssue",
|
||||
"range": {
|
||||
"startColumn": 45,
|
||||
"endColumn": 49,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportAttributeAccessIssue",
|
||||
"range": {
|
||||
"startColumn": 45,
|
||||
"endColumn": 54,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportAttributeAccessIssue",
|
||||
"range": {
|
||||
"startColumn": 85,
|
||||
"endColumn": 94,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
@@ -6809,14 +6769,6 @@
|
||||
}
|
||||
],
|
||||
"./backend/app/services/virtual_printer/mqtt_server.py": [
|
||||
{
|
||||
"code": "reportMissingImports",
|
||||
"range": {
|
||||
"startColumn": 17,
|
||||
"endColumn": 29,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportGeneralTypeIssues",
|
||||
"range": {
|
||||
@@ -6827,14 +6779,6 @@
|
||||
}
|
||||
],
|
||||
"./backend/app/services/virtual_printer/tcp_proxy.py": [
|
||||
{
|
||||
"code": "reportAttributeAccessIssue",
|
||||
"range": {
|
||||
"startColumn": 25,
|
||||
"endColumn": 33,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
@@ -6899,30 +6843,6 @@
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 97,
|
||||
"endColumn": 108,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 93,
|
||||
"endColumn": 104,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 49,
|
||||
"endColumn": 73,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportRedeclaration",
|
||||
"range": {
|
||||
|
||||
@@ -270,6 +270,10 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Unused files and dependencies (knip)
|
||||
working-directory: frontend
|
||||
run: npm run check:knip
|
||||
|
||||
frontend-security:
|
||||
name: Frontend Security
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+13
-3
@@ -225,7 +225,8 @@ Then regenerate the type-check lock (command in `requirements-typecheck.txt`), i
|
||||
|
||||
### Frontend (TypeScript/React)
|
||||
|
||||
We use ESLint for linting and TypeScript for type checking:
|
||||
We use ESLint for linting, TypeScript for type checking and knip for unused
|
||||
files and dependencies:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
@@ -235,8 +236,17 @@ npm run lint
|
||||
|
||||
# Type check
|
||||
npm run typecheck
|
||||
|
||||
# Unused files and dependencies
|
||||
npm run check:knip
|
||||
```
|
||||
|
||||
knip follows imports from the entry points (`index.html`, the Vite and Vitest
|
||||
configs, and `public/sw.js`, which the browser loads by URL). A file nothing
|
||||
imports, or a package nothing uses, fails the check: delete it, or add it to
|
||||
`frontend/knip.jsonc` with a comment saying why if something loads it in a way
|
||||
knip can't see.
|
||||
|
||||
### Pre-commit Hooks
|
||||
|
||||
Pre-commit hooks run automatically on `git commit` and include Ruff linting/formatting, trailing whitespace fixes, YAML/JSON validation, and import shadowing checks. To run manually:
|
||||
@@ -347,7 +357,7 @@ reprint eligibility. Archive reprint still needs `queue:create` before it can en
|
||||
The easiest way to run tests is with the provided scripts in the project root:
|
||||
|
||||
```bash
|
||||
./test_frontend.sh # TypeScript check + ESLint + Vitest
|
||||
./test_frontend.sh # TypeScript check + ESLint + knip + Vitest
|
||||
./test_backend.sh # Ruff lint/format + type check (if .typecheck-venv exists) + PostgreSQL upgrade check (if Docker is available) + pytest (parallel)
|
||||
./test_docker.sh # Full Docker build, unit tests, and integration tests
|
||||
./test_all.sh # All of the above (frontend → backend → docker)
|
||||
@@ -415,7 +425,7 @@ npm run test:coverage # With coverage
|
||||
Pull requests trigger automated CI checks via GitHub Actions (`.github/workflows/ci.yml`):
|
||||
|
||||
- **Backend**: Ruff lint + format check, basedpyright type check (against the baseline), unit/integration tests, PostgreSQL upgrade check, pip-audit
|
||||
- **Frontend**: ESLint, TypeScript type check, Vitest tests, production build
|
||||
- **Frontend**: ESLint, knip (unused files and dependencies), TypeScript type check, Vitest tests, production build
|
||||
- **Docker**: Full image build, backend/frontend tests in Docker, integration health checks
|
||||
- **Security**: CodeQL analysis, dependency audits
|
||||
|
||||
|
||||
@@ -1057,7 +1057,7 @@ def _read_backup_manifest(temp_path: Path) -> dict:
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
|
||||
async def _import_sqlite_to_postgres(sqlite_path: Path):
|
||||
"""Import data from a SQLite database file into the current PostgreSQL database.
|
||||
|
||||
Used for cross-database restore (SQLite backup → PostgreSQL).
|
||||
@@ -1534,7 +1534,7 @@ async def restore_backup(
|
||||
else:
|
||||
# Import SQLite backup into PostgreSQL
|
||||
logger.info("Importing SQLite backup into PostgreSQL...")
|
||||
await _import_sqlite_to_postgres(backup_db, app_settings.database_url)
|
||||
await _import_sqlite_to_postgres(backup_db)
|
||||
|
||||
# 6. Replace data directories
|
||||
# For Docker compatibility: clear contents then copy (don't delete mount points)
|
||||
|
||||
+1
-101
@@ -441,7 +441,7 @@ def _check_apikey_permissions(
|
||||
``resolve_apikey_owner``.
|
||||
|
||||
By default ALL requested permissions must pass (mirrors
|
||||
``require_permission`` / ``require_permission_if_auth_enabled``).
|
||||
``require_permission_if_auth_enabled``).
|
||||
When ``require_any=True``, only one needs to pass (mirrors
|
||||
``require_any_permission_if_auth_enabled``).
|
||||
"""
|
||||
@@ -1658,20 +1658,6 @@ async def require_auth_if_enabled(
|
||||
)
|
||||
|
||||
|
||||
def require_role(required_role: str):
|
||||
"""Dependency factory for role-based access control."""
|
||||
|
||||
async def role_checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
if current_user.role != required_role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires {required_role} role",
|
||||
)
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
|
||||
|
||||
def require_admin_if_auth_enabled():
|
||||
"""Dependency factory that requires admin role if auth is enabled.
|
||||
|
||||
@@ -2160,9 +2146,6 @@ def require_printer_permission_if_auth_enabled(*permissions: str | Permission):
|
||||
|
||||
|
||||
# Convenience dependencies - these are functions that return Depends objects
|
||||
def RequireAdmin():
|
||||
"""Dependency that requires admin role."""
|
||||
return Depends(require_role("admin"))
|
||||
|
||||
|
||||
def RequireAdminIfAuthEnabled():
|
||||
@@ -2170,84 +2153,6 @@ def RequireAdminIfAuthEnabled():
|
||||
return Depends(require_admin_if_auth_enabled())
|
||||
|
||||
|
||||
def require_permission(*permissions: str | Permission):
|
||||
"""Dependency factory that requires user to have ALL specified permissions.
|
||||
|
||||
Accepts both JWT tokens (via Authorization: Bearer header) and API keys
|
||||
(via X-API-Key header or Authorization: Bearer bb_xxx).
|
||||
|
||||
Args:
|
||||
*permissions: Permission strings or Permission enum values to require
|
||||
|
||||
Returns:
|
||||
A dependency function that validates permissions
|
||||
"""
|
||||
# Convert Permission enums to strings
|
||||
perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
|
||||
|
||||
async def permission_checker(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
||||
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
||||
) -> User | None:
|
||||
async with async_session() as db:
|
||||
# Check for API key first (X-API-Key header)
|
||||
if x_api_key:
|
||||
api_key = await _validate_api_key(db, x_api_key)
|
||||
if api_key:
|
||||
await authorize_api_key(db, api_key, perm_strings)
|
||||
return None # API key valid, allow access
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if credentials is None:
|
||||
raise credentials_exception
|
||||
|
||||
token = credentials.credentials
|
||||
# Check if it's an API key (starts with bb_)
|
||||
if token.startswith("bb_"):
|
||||
api_key = await _validate_api_key(db, token)
|
||||
if api_key:
|
||||
await authorize_api_key(db, api_key, perm_strings)
|
||||
return None # API key valid, allow access
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Otherwise treat as JWT
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti, db):
|
||||
raise credentials_exception
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = await get_user_by_username(db, username)
|
||||
if user is None or not user.is_active:
|
||||
raise credentials_exception
|
||||
if not _is_token_fresh(iat, user):
|
||||
raise credentials_exception
|
||||
|
||||
if not user.has_all_permissions(*perm_strings):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing required permissions: {', '.join(perm_strings)}",
|
||||
)
|
||||
return user
|
||||
|
||||
return permission_checker
|
||||
|
||||
|
||||
def require_permission_if_auth_enabled(*permissions: str | Permission):
|
||||
"""Dependency factory that checks permissions only if auth is enabled.
|
||||
|
||||
@@ -2359,11 +2264,6 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
|
||||
return permission_checker
|
||||
|
||||
|
||||
def RequirePermission(*permissions: str | Permission):
|
||||
"""Convenience dependency that requires ALL specified permissions."""
|
||||
return Depends(require_permission(*permissions))
|
||||
|
||||
|
||||
def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
|
||||
"""Convenience dependency that requires permissions if auth is enabled."""
|
||||
return Depends(require_permission_if_auth_enabled(*permissions))
|
||||
|
||||
@@ -189,11 +189,3 @@ def api_key_own_scope(api_key) -> PrinterScope:
|
||||
if api_key.printer_ids is None:
|
||||
return ALL_PRINTERS
|
||||
return PrinterScope(frozenset(int(pid) for pid in api_key.printer_ids))
|
||||
|
||||
|
||||
async def group_restricts_printers(db: AsyncSession, group_ids: Iterable[int]) -> bool:
|
||||
ids = list(group_ids)
|
||||
if not ids:
|
||||
return False
|
||||
result = await db.execute(select(Group.id).where(Group.id.in_(ids), Group.restrict_printers.is_(True)).limit(1))
|
||||
return result.first() is not None
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"""Internationalization module for backend notifications."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
# English translations
|
||||
EN = {
|
||||
"notification": {
|
||||
# Print events
|
||||
"print_started": "Print Started",
|
||||
"print_completed": "Print Completed",
|
||||
"print_failed": "Print Failed",
|
||||
"print_stopped": "Print Stopped",
|
||||
"print_ended": "Print Ended",
|
||||
"print_progress": "Print {progress}% Complete",
|
||||
"estimated": "Estimated",
|
||||
"time": "Time",
|
||||
"filament": "Filament",
|
||||
"reason": "Reason",
|
||||
"unknown": "Unknown",
|
||||
# Printer events
|
||||
"printer_offline": "Printer Offline",
|
||||
"printer_disconnected": "{printer} has disconnected",
|
||||
"printer_error": "Printer Error: {error_type}",
|
||||
# Filament
|
||||
"filament_low": "Filament Low",
|
||||
"slot_at_percent": "{printer}: Slot {slot} at {percent}%",
|
||||
# Maintenance
|
||||
"maintenance_due": "Maintenance Due",
|
||||
"overdue": "OVERDUE",
|
||||
"soon": "Soon",
|
||||
# Test notification
|
||||
"test_title": "Bambuddy Test",
|
||||
"test_message": "This is a test notification from Bambuddy. If you see this, notifications are working correctly!",
|
||||
}
|
||||
}
|
||||
|
||||
# German translations
|
||||
DE = {
|
||||
"notification": {
|
||||
# Print events
|
||||
"print_started": "Druck gestartet",
|
||||
"print_completed": "Druck abgeschlossen",
|
||||
"print_failed": "Druck fehlgeschlagen",
|
||||
"print_stopped": "Druck gestoppt",
|
||||
"print_ended": "Druck beendet",
|
||||
"print_progress": "Druck {progress}% fertig",
|
||||
"estimated": "Geschätzt",
|
||||
"time": "Zeit",
|
||||
"filament": "Filament",
|
||||
"reason": "Grund",
|
||||
"unknown": "Unbekannt",
|
||||
# Printer events
|
||||
"printer_offline": "Drucker offline",
|
||||
"printer_disconnected": "{printer} wurde getrennt",
|
||||
"printer_error": "Druckerfehler: {error_type}",
|
||||
# Filament
|
||||
"filament_low": "Wenig Filament",
|
||||
"slot_at_percent": "{printer}: Slot {slot} bei {percent}%",
|
||||
# Maintenance
|
||||
"maintenance_due": "Wartung fällig",
|
||||
"overdue": "ÜBERFÄLLIG",
|
||||
"soon": "Bald",
|
||||
# Test notification
|
||||
"test_title": "Bambuddy Test",
|
||||
"test_message": "Dies ist eine Testbenachrichtigung von Bambuddy. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!",
|
||||
}
|
||||
}
|
||||
|
||||
# All available translations
|
||||
TRANSLATIONS = {
|
||||
"en": EN,
|
||||
"de": DE,
|
||||
}
|
||||
|
||||
|
||||
def get_translation(lang: str, key: str, **kwargs: Any) -> str:
|
||||
"""
|
||||
Get a translation string by key with optional interpolation.
|
||||
|
||||
Args:
|
||||
lang: Language code (e.g., 'en', 'de')
|
||||
key: Dot-separated key path (e.g., 'notification.print_started')
|
||||
**kwargs: Values to interpolate into the string
|
||||
|
||||
Returns:
|
||||
Translated string, or the key if not found
|
||||
"""
|
||||
# Fall back to English if language not found
|
||||
translations = TRANSLATIONS.get(lang, TRANSLATIONS["en"])
|
||||
|
||||
# Navigate to the nested key
|
||||
keys = key.split(".")
|
||||
value = translations
|
||||
for k in keys:
|
||||
if isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
# Key not found, fall back to English
|
||||
value = TRANSLATIONS["en"]
|
||||
for k2 in keys:
|
||||
if isinstance(value, dict) and k2 in value:
|
||||
value = value[k2]
|
||||
else:
|
||||
return key # Return key if not found in fallback either
|
||||
break
|
||||
|
||||
if isinstance(value, str):
|
||||
# Interpolate values
|
||||
try:
|
||||
return value.format(**kwargs)
|
||||
except KeyError:
|
||||
return value
|
||||
|
||||
return key
|
||||
|
||||
|
||||
class Translator:
|
||||
"""Helper class for translations with a specific language."""
|
||||
|
||||
def __init__(self, lang: str = "en"):
|
||||
self.lang = lang if lang in TRANSLATIONS else "en"
|
||||
|
||||
def t(self, key: str, **kwargs: Any) -> str:
|
||||
"""Translate a key."""
|
||||
return get_translation(self.lang, key, **kwargs)
|
||||
@@ -5937,20 +5937,6 @@ class BambuMQTTClient:
|
||||
# The correct nozzle data comes from push_status response.
|
||||
return True
|
||||
|
||||
def _request_accessories(self):
|
||||
"""Request accessories info (nozzle type, etc.) from printer."""
|
||||
if self._client:
|
||||
self._sequence_id += 1
|
||||
message = {
|
||||
"system": {
|
||||
"sequence_id": str(self._sequence_id),
|
||||
"command": "get_accessories",
|
||||
"accessory_type": "none",
|
||||
}
|
||||
}
|
||||
logger.debug("[%s] Requesting accessories info", self.serial_number)
|
||||
self._client.publish(self.topic_publish, json.dumps(message), qos=1)
|
||||
|
||||
def _prime_kprofile_request(self):
|
||||
"""Send a priming K-profile request on connect.
|
||||
|
||||
@@ -7502,22 +7488,6 @@ class BambuMQTTClient:
|
||||
speed = max(0, min(255, speed)) # Clamp to 0-255
|
||||
return self.send_gcode(f"M106 P{fan} S{speed}")
|
||||
|
||||
def set_part_fan(self, speed: int) -> bool:
|
||||
"""Set part cooling fan speed (0-255)."""
|
||||
return self.set_fan_speed(1, speed)
|
||||
|
||||
def set_aux_fan(self, speed: int) -> bool:
|
||||
"""Set auxiliary fan speed (0-255)."""
|
||||
return self.set_fan_speed(2, speed)
|
||||
|
||||
def set_chamber_fan(self, speed: int) -> bool:
|
||||
"""Set chamber fan speed (0-255)."""
|
||||
return self.set_fan_speed(3, speed)
|
||||
|
||||
def set_left_aux_fan(self, speed: int) -> bool:
|
||||
"""Set left auxiliary part cooling fan speed (0-255). P2S/X2D accessory."""
|
||||
return self.set_fan_speed(10, speed)
|
||||
|
||||
def set_airduct_mode(self, mode: str) -> bool:
|
||||
"""Set air conditioning mode (cooling or heating).
|
||||
|
||||
@@ -7610,56 +7580,6 @@ class BambuMQTTClient:
|
||||
)
|
||||
return True
|
||||
|
||||
def home_axes(self, axes: str = "XYZ") -> bool:
|
||||
"""Run the printer's full auto-home sequence.
|
||||
|
||||
The ``axes`` argument is ignored: a bare ``G28`` is always sent so
|
||||
Bambu firmware runs its safe multi-step routine (park toolhead →
|
||||
home XY → home Z). Partial-axis variants like ``G28 Z`` skip the
|
||||
toolhead-park step and can crash the bed into the toolhead on H2C
|
||||
/ H2D / H2S / X1 where Z-home moves the bed UP — see #1052.
|
||||
"""
|
||||
return self.send_gcode("G28")
|
||||
|
||||
def move_axis(self, axis: str, distance: float, speed: int = 3000) -> bool:
|
||||
"""Move an axis by a relative distance.
|
||||
|
||||
Args:
|
||||
axis: Axis to move ("X", "Y", or "Z")
|
||||
distance: Distance to move in mm (positive or negative)
|
||||
speed: Movement speed in mm/min
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
axis = axis.upper()
|
||||
if axis not in ("X", "Y", "Z"):
|
||||
logger.warning("[%s] Invalid axis: %s", self.serial_number, axis)
|
||||
return False
|
||||
|
||||
# G91 = relative mode, G0 = rapid move, G90 = back to absolute
|
||||
gcode = f"G91\nG0 {axis}{distance:.2f} F{speed}\nG90"
|
||||
return self.send_gcode(gcode)
|
||||
|
||||
def disable_motors(self) -> bool:
|
||||
"""Disable all stepper motors.
|
||||
|
||||
Warning: This will cause the printer to lose its position.
|
||||
A homing operation will be required before printing.
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
return self.send_gcode("M18")
|
||||
|
||||
def enable_motors(self) -> bool:
|
||||
"""Enable all stepper motors.
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
return self.send_gcode("M17")
|
||||
|
||||
def ams_load_filament(self, tray_id: int, extruder_id: int | None = None) -> bool:
|
||||
"""Load filament from a specific AMS tray.
|
||||
|
||||
@@ -8315,53 +8235,6 @@ class BambuMQTTClient:
|
||||
self._client.publish(self.topic_publish, command_json, qos=1)
|
||||
return True
|
||||
|
||||
def set_timelapse(self, enable: bool) -> bool:
|
||||
"""Enable or disable timelapse recording.
|
||||
|
||||
Args:
|
||||
enable: True to enable, False to disable
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning("[%s] Cannot set timelapse: not connected", self.serial_number)
|
||||
return False
|
||||
|
||||
command = {"pushing": {"command": "pushall", "sequence_id": "0"}}
|
||||
# First send the timelapse setting
|
||||
timelapse_cmd = {
|
||||
"print": {"command": "gcode_line", "param": f"M981 S{1 if enable else 0} P20000", "sequence_id": "0"}
|
||||
}
|
||||
self._client.publish(self.topic_publish, json.dumps(timelapse_cmd), qos=1)
|
||||
# Request status update
|
||||
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
|
||||
logger.info("[%s] Set timelapse %s", self.serial_number, "enabled" if enable else "disabled")
|
||||
return True
|
||||
|
||||
def set_liveview(self, enable: bool) -> bool:
|
||||
"""Enable or disable live view / camera streaming.
|
||||
|
||||
Args:
|
||||
enable: True to enable, False to disable
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning("[%s] Cannot set liveview: not connected", self.serial_number)
|
||||
return False
|
||||
|
||||
command = {
|
||||
"xcam": {"command": "ipcam_record_set", "control": "enable" if enable else "disable", "sequence_id": "0"}
|
||||
}
|
||||
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
|
||||
# Request status update
|
||||
pushall = {"pushing": {"command": "pushall", "sequence_id": "0"}}
|
||||
self._client.publish(self.topic_publish, json.dumps(pushall), qos=1)
|
||||
logger.info("[%s] Set liveview %s", self.serial_number, "enabled" if enable else "disabled")
|
||||
return True
|
||||
|
||||
def execute_hms_action(self, print_error: str, action: str, job_id: str | None = None) -> bool:
|
||||
"""Dispatch the user's choice from the HMS-error modal as a printer command.
|
||||
|
||||
|
||||
@@ -406,12 +406,6 @@ def is_chamber_image_model(model: str | None) -> bool:
|
||||
return not supports_rtsp(model)
|
||||
|
||||
|
||||
def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
|
||||
"""Build the RTSPS URL for the printer camera (RTSP models only)."""
|
||||
port = get_camera_port(model)
|
||||
return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
|
||||
|
||||
|
||||
def _create_chamber_auth_payload(access_code: str) -> bytes:
|
||||
"""Create the 80-byte authentication payload for chamber image protocol.
|
||||
|
||||
|
||||
@@ -330,80 +330,6 @@ BamBuddy Team
|
||||
return subject, text_body, html_body
|
||||
|
||||
|
||||
def create_password_reset_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
|
||||
"""Create password reset email content.
|
||||
|
||||
Args:
|
||||
username: Username of the user
|
||||
password: New auto-generated password
|
||||
login_url: URL to login page
|
||||
|
||||
Returns:
|
||||
Tuple of (subject, text_body, html_body)
|
||||
"""
|
||||
subject = "BamBuddy - Your Password Has Been Reset"
|
||||
|
||||
text_body = f"""Your BamBuddy password has been reset.
|
||||
|
||||
Your login details:
|
||||
|
||||
Username: {username}
|
||||
New Password: {password}
|
||||
|
||||
You can login at: {login_url}
|
||||
|
||||
For security reasons, please change your password after logging in.
|
||||
|
||||
If you did not request this password reset, please contact your administrator immediately.
|
||||
|
||||
Best regards,
|
||||
BamBuddy Team
|
||||
"""
|
||||
|
||||
html_body = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">Password Reset</h1>
|
||||
</div>
|
||||
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
|
||||
<p style="font-size: 16px;">Your BamBuddy password has been reset.</p>
|
||||
|
||||
<div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
|
||||
<p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
|
||||
<p style="margin: 0;"><strong>New Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{login_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 0; font-size: 14px; color: #856404;">
|
||||
<strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
|
||||
<strong>Security Note:</strong> For security reasons, please change your password after logging in.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #999; margin-top: 30px;">
|
||||
Best regards,<br>
|
||||
BamBuddy Team
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return subject, text_body, html_body
|
||||
|
||||
|
||||
def create_password_reset_link_email(username: str, reset_url: str) -> tuple[str, str, str]:
|
||||
"""Create a password-reset email that contains a secure link (not a plaintext password)."""
|
||||
subject = "BamBuddy - Password Reset Request"
|
||||
@@ -455,17 +381,11 @@ BamBuddy Team
|
||||
async def create_password_reset_link_email_from_template(
|
||||
db: AsyncSession, username: str, reset_url: str
|
||||
) -> tuple[str, str, str]:
|
||||
"""Create password-reset link email, using DB template if configured."""
|
||||
template = await get_notification_template(db, "password_reset_link")
|
||||
if template:
|
||||
variables = {"username": username, "reset_url": reset_url}
|
||||
subject = render_template(template.subject or "BamBuddy - Password Reset Request", variables)
|
||||
text_body = render_template(template.body or "", variables)
|
||||
html_body = render_template(template.html_body or "", variables) if template.html_body else None
|
||||
if not html_body:
|
||||
_, text_body, html_body = create_password_reset_link_email(username, reset_url)
|
||||
return subject, text_body, html_body
|
||||
return subject, text_body, html_body
|
||||
"""Create the password-reset link email.
|
||||
|
||||
There is no notification template for it (the seeded "password_reset" one is
|
||||
for the old emailed-password flow), so this is always the built-in email.
|
||||
"""
|
||||
return create_password_reset_link_email(username, reset_url)
|
||||
|
||||
|
||||
@@ -531,74 +451,6 @@ async def create_welcome_email_from_template(
|
||||
return create_welcome_email(username, password, login_url)
|
||||
|
||||
|
||||
async def create_password_reset_email_from_template(
|
||||
db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
|
||||
) -> tuple[str, str, str]:
|
||||
"""Create password reset email content using notification template from database.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
username: Username of the user
|
||||
password: New auto-generated password
|
||||
login_url: URL to login page
|
||||
app_name: Application name (default: BamBuddy)
|
||||
|
||||
Returns:
|
||||
Tuple of (subject, text_body, html_body)
|
||||
"""
|
||||
# Try to get template from database
|
||||
template = await get_notification_template(db, "password_reset")
|
||||
|
||||
if template:
|
||||
# Render template with variables
|
||||
variables = {
|
||||
"app_name": app_name,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"login_url": login_url,
|
||||
}
|
||||
|
||||
subject = render_template(template.title_template, variables)
|
||||
text_body = render_template(template.body_template, variables)
|
||||
|
||||
# Create HTML version with embedded login button
|
||||
# Escape text_body to prevent XSS vulnerabilities and convert newlines to <br> tags
|
||||
escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
|
||||
html_body = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 30px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
|
||||
</div>
|
||||
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
|
||||
<div style="font-size: 16px;">{escaped_text_body}</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{login_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 0; font-size: 14px; color: #856404;">
|
||||
<strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
logger.info("Using custom password reset email template from database")
|
||||
return subject, text_body, html_body
|
||||
else:
|
||||
# Fallback to hardcoded template
|
||||
logger.warning("No password reset email template found in database, using default")
|
||||
return create_password_reset_email(username, password, login_url)
|
||||
|
||||
|
||||
async def send_user_print_notification(
|
||||
db: AsyncSession,
|
||||
event_type: str,
|
||||
|
||||
@@ -153,19 +153,6 @@ def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "
|
||||
return None
|
||||
|
||||
|
||||
def _validate_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> bool:
|
||||
"""Validate camera URL format (legacy wrapper).
|
||||
|
||||
Args:
|
||||
url: URL to validate
|
||||
allowed_schemes: Tuple of allowed URL schemes
|
||||
|
||||
Returns:
|
||||
True if URL is valid, False otherwise
|
||||
"""
|
||||
return _sanitize_camera_url(url, allowed_schemes) is not None
|
||||
|
||||
|
||||
def list_usb_cameras() -> list[dict]:
|
||||
"""List available USB cameras (V4L2 devices on Linux).
|
||||
|
||||
|
||||
@@ -255,11 +255,6 @@ def _material_identity_spoolman(spool: dict | None) -> str:
|
||||
return f"unmatched:{spool_id}"
|
||||
|
||||
|
||||
def _ams_id_from_global(global_tray_id: int) -> int:
|
||||
"""Inverse of ``_global_to_ams_key`` returning ams_id only."""
|
||||
return _global_to_ams_key(global_tray_id)[0]
|
||||
|
||||
|
||||
def _extruder_side_for_ams(
|
||||
ams_id: int,
|
||||
ams_extruder_map: dict[str, int],
|
||||
|
||||
@@ -162,11 +162,6 @@ def _color_from_hex(hex_str: str | None, fallback: Color = HexColor(0x808080)) -
|
||||
return fallback
|
||||
|
||||
|
||||
def _luminance(color: Color) -> float:
|
||||
"""Perceived luminance of a ReportLab Color (0–1, WCAG-style approximation)."""
|
||||
return 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
|
||||
|
||||
|
||||
def _hex_code_label(rgba: str | None) -> str:
|
||||
"""Format ``data.rgba`` as a printable ``#RRGGBB`` string for the label.
|
||||
|
||||
|
||||
@@ -213,21 +213,6 @@ async def count_internal_spools_at_location(db: AsyncSession, location_id: int)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def count_spools_at_location_by_name(db: AsyncSession, name: str) -> int:
|
||||
normalized = name.strip()
|
||||
if not normalized:
|
||||
return 0
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Spool)
|
||||
.where(
|
||||
Spool.archived_at.is_(None),
|
||||
func.lower(func.trim(Spool.storage_location)) == normalized.lower(),
|
||||
)
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def enrich_spool_dicts_with_location_id(db: AsyncSession, spools: list[dict]) -> None:
|
||||
"""Attach location_id to mapped Spoolman-style spool dicts in place."""
|
||||
keys = {location_name_key(s["storage_location"]) for s in spools if (s.get("storage_location") or "").strip()}
|
||||
|
||||
@@ -382,16 +382,3 @@ def find_interface_for_ip(target_ip: str) -> dict | None:
|
||||
|
||||
logger.warning("No interface found for target IP %s", target_ip)
|
||||
return None
|
||||
|
||||
|
||||
def get_other_interfaces(exclude_ip: str) -> list[dict]:
|
||||
"""Get all interfaces except the one with the given IP.
|
||||
|
||||
Args:
|
||||
exclude_ip: IP address of interface to exclude
|
||||
|
||||
Returns:
|
||||
List of interface dicts
|
||||
"""
|
||||
interfaces = get_network_interfaces()
|
||||
return [iface for iface in interfaces if iface["ip"] != exclude_ip]
|
||||
|
||||
@@ -1624,7 +1624,6 @@ class NotificationService:
|
||||
event_type: str = "unknown",
|
||||
printer_id: int | None = None,
|
||||
printer_name: str | None = None,
|
||||
force_immediate: bool = False,
|
||||
image_data: bytes | None = None,
|
||||
variables: dict | None = None,
|
||||
):
|
||||
@@ -2041,7 +2040,6 @@ class NotificationService:
|
||||
"print_missing_spool_assignment",
|
||||
printer_id,
|
||||
printer_name,
|
||||
force_immediate=True,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
@@ -2074,7 +2072,6 @@ class NotificationService:
|
||||
"billing_charge_failed",
|
||||
printer_id,
|
||||
printer_name,
|
||||
force_immediate=True,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
@@ -2190,7 +2187,6 @@ class NotificationService:
|
||||
"plate_not_empty",
|
||||
printer_id,
|
||||
printer_name,
|
||||
force_immediate=True,
|
||||
image_data=image_data,
|
||||
variables=variables,
|
||||
)
|
||||
@@ -2297,7 +2293,7 @@ class NotificationService:
|
||||
threshold: float,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Handle AMS high humidity alarm event. Always sends immediately (bypasses digest)."""
|
||||
"""Handle AMS high humidity alarm event. Sent immediately, and also in the daily digest if enabled."""
|
||||
providers = await self._get_providers_for_event(db, "on_ams_humidity_high", printer_id)
|
||||
if not providers:
|
||||
return
|
||||
@@ -2310,7 +2306,6 @@ class NotificationService:
|
||||
}
|
||||
|
||||
title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
|
||||
# Alarms always send immediately, bypassing digest mode
|
||||
await self._send_to_providers(
|
||||
providers,
|
||||
title,
|
||||
@@ -2319,7 +2314,6 @@ class NotificationService:
|
||||
"ams_humidity_high",
|
||||
printer_id,
|
||||
printer_name,
|
||||
force_immediate=True,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
@@ -2332,7 +2326,7 @@ class NotificationService:
|
||||
threshold: float,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Handle AMS high temperature alarm event. Always sends immediately (bypasses digest)."""
|
||||
"""Handle AMS high temperature alarm event. Sent immediately, and also in the daily digest if enabled."""
|
||||
providers = await self._get_providers_for_event(db, "on_ams_temperature_high", printer_id)
|
||||
if not providers:
|
||||
return
|
||||
@@ -2345,7 +2339,6 @@ class NotificationService:
|
||||
}
|
||||
|
||||
title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
|
||||
# Alarms always send immediately, bypassing digest mode
|
||||
await self._send_to_providers(
|
||||
providers,
|
||||
title,
|
||||
@@ -2354,7 +2347,6 @@ class NotificationService:
|
||||
"ams_temperature_high",
|
||||
printer_id,
|
||||
printer_name,
|
||||
force_immediate=True,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
@@ -2370,7 +2362,8 @@ class NotificationService:
|
||||
):
|
||||
"""Handle automatic drying giving up on one AMS unit (#2770).
|
||||
|
||||
Sent immediately rather than folded into a digest: it reports that
|
||||
Worth sending immediately (as every notification is; a daily digest only
|
||||
repeats it): it reports that
|
||||
Bambuddy has STOPPED doing something, and a report of inaction that
|
||||
arrives with tomorrow's summary has already cost the user a day.
|
||||
"""
|
||||
@@ -2395,7 +2388,6 @@ class NotificationService:
|
||||
"ams_drying_suspended",
|
||||
printer_id,
|
||||
printer_name,
|
||||
force_immediate=True,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
@@ -2408,7 +2400,7 @@ class NotificationService:
|
||||
threshold: float,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Handle AMS-HT high humidity alarm event. Always sends immediately (bypasses digest)."""
|
||||
"""Handle AMS-HT high humidity alarm event. Sent immediately, and also in the daily digest if enabled."""
|
||||
providers = await self._get_providers_for_event(db, "on_ams_ht_humidity_high", printer_id)
|
||||
if not providers:
|
||||
return
|
||||
@@ -2422,7 +2414,6 @@ class NotificationService:
|
||||
|
||||
# Use the same template as regular AMS (can create separate templates later if needed)
|
||||
title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
|
||||
# Alarms always send immediately, bypassing digest mode
|
||||
await self._send_to_providers(
|
||||
providers,
|
||||
title,
|
||||
@@ -2431,7 +2422,6 @@ class NotificationService:
|
||||
"ams_ht_humidity_high",
|
||||
printer_id,
|
||||
printer_name,
|
||||
force_immediate=True,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
@@ -2444,7 +2434,7 @@ class NotificationService:
|
||||
threshold: float,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Handle AMS-HT high temperature alarm event. Always sends immediately (bypasses digest)."""
|
||||
"""Handle AMS-HT high temperature alarm event. Sent immediately, and also in the daily digest if enabled."""
|
||||
providers = await self._get_providers_for_event(db, "on_ams_ht_temperature_high", printer_id)
|
||||
if not providers:
|
||||
return
|
||||
@@ -2458,7 +2448,6 @@ class NotificationService:
|
||||
|
||||
# Use the same template as regular AMS (can create separate templates later if needed)
|
||||
title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
|
||||
# Alarms always send immediately, bypassing digest mode
|
||||
await self._send_to_providers(
|
||||
providers,
|
||||
title,
|
||||
@@ -2467,7 +2456,6 @@ class NotificationService:
|
||||
"ams_ht_temperature_high",
|
||||
printer_id,
|
||||
printer_name,
|
||||
force_immediate=True,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
@@ -2507,7 +2495,8 @@ class NotificationService:
|
||||
):
|
||||
"""A Home Assistant sensor bound to a printer entered its alert state (#1148).
|
||||
|
||||
Sent immediately rather than folded into a digest: the case this exists
|
||||
Worth sending immediately (as every notification is; a daily digest only
|
||||
repeats it): the case this exists
|
||||
for is an enclosure door left open, which is only worth telling someone
|
||||
about while they can still act on it.
|
||||
"""
|
||||
@@ -2530,7 +2519,6 @@ class NotificationService:
|
||||
"ha_sensor_alert",
|
||||
printer_id,
|
||||
printer_name,
|
||||
force_immediate=True,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
@@ -2543,7 +2531,7 @@ class NotificationService:
|
||||
):
|
||||
"""A Home Assistant sensor bound to a storage location entered its alert state (#2824).
|
||||
|
||||
Sent immediately rather than folded into a digest, for the same reason
|
||||
Worth sending immediately, for the same reason
|
||||
as on_ha_sensor_alert above: this is the "drybox went stale" case, only
|
||||
worth acting on while the humidity/temperature is still climbing.
|
||||
"""
|
||||
@@ -2568,7 +2556,6 @@ class NotificationService:
|
||||
message,
|
||||
db,
|
||||
"location_ha_sensor_alert",
|
||||
force_immediate=True,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
|
||||
@@ -134,17 +134,6 @@ class PlateDetector:
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
def _get_next_reference_slot(self, printer_id: int) -> Path:
|
||||
"""Get the path for the next reference image slot (cycles through slots)."""
|
||||
_get_calibration_dir().mkdir(parents=True, exist_ok=True)
|
||||
# Find first empty slot, or use oldest (slot 0) and shift others
|
||||
for i in range(self.MAX_REFERENCES):
|
||||
path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
|
||||
if not path.exists():
|
||||
return path
|
||||
# All slots full - return slot 0 (will be overwritten, but we rotate first)
|
||||
return _get_calibration_dir() / f"printer_{printer_id}_ref_0.jpg"
|
||||
|
||||
def _rotate_references(self, printer_id: int) -> None:
|
||||
"""Rotate references: delete oldest (0), shift others down."""
|
||||
# Delete slot 0
|
||||
|
||||
@@ -36,34 +36,6 @@ TAG_REFUSAL_RETRY_SECONDS = 600
|
||||
BAMBU_WEIGHT_USED_BASELINE_KEY = "bambu_weight_used_baseline"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpoolmanSpool:
|
||||
"""Represents a spool in Spoolman."""
|
||||
|
||||
id: int
|
||||
filament_id: int | None
|
||||
remaining_weight: float | None
|
||||
used_weight: float
|
||||
first_used: str | None
|
||||
last_used: str | None
|
||||
location: str | None
|
||||
lot_nr: str | None
|
||||
comment: str | None
|
||||
extra: dict | None # Contains tag_uid in extra.tag
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpoolmanFilament:
|
||||
"""Represents a filament type in Spoolman."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
vendor_id: int | None
|
||||
material: str | None
|
||||
color_hex: str | None
|
||||
weight: float | None # Net weight in grams
|
||||
|
||||
|
||||
@dataclass
|
||||
class AMSTray:
|
||||
"""Represents an AMS tray with filament data from Bambu printer."""
|
||||
@@ -1119,15 +1091,6 @@ class SpoolmanClient:
|
||||
return spool
|
||||
return None
|
||||
|
||||
def _find_spool_by_location(self, location: str, cached_spools: list[dict] | None) -> dict | None:
|
||||
"""Return the spool at the exact location string, or None; fallback when RFID is unavailable."""
|
||||
if not cached_spools:
|
||||
return None
|
||||
for spool in cached_spools:
|
||||
if spool.get("location") == location:
|
||||
return spool
|
||||
return None
|
||||
|
||||
async def find_spools_by_location_prefix(
|
||||
self, location_prefix: str, cached_spools: list[dict] | None = None
|
||||
) -> list[dict]:
|
||||
|
||||
@@ -82,147 +82,11 @@ MODEL_PRODUCT_NAMES = {
|
||||
}
|
||||
|
||||
|
||||
class VirtualPrinterMQTTServer:
|
||||
"""MQTT broker that accepts connections from slicers.
|
||||
|
||||
This is a minimal MQTT broker implementation that:
|
||||
- Accepts TLS connections on port 8883
|
||||
- Authenticates with username 'bblp' and the configured access code
|
||||
- Receives print commands on device/{serial}/request
|
||||
- Can publish status on device/{serial}/report
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
serial: str,
|
||||
access_code: str,
|
||||
cert_path: Path,
|
||||
key_path: Path,
|
||||
port: int = MQTT_PORT,
|
||||
on_print_command: Callable[[str, dict], None] | None = None,
|
||||
):
|
||||
"""Initialize the MQTT server.
|
||||
|
||||
Args:
|
||||
serial: Virtual printer serial number
|
||||
access_code: Password for authentication
|
||||
cert_path: Path to TLS certificate
|
||||
key_path: Path to TLS private key
|
||||
port: Port to listen on (default 8883)
|
||||
on_print_command: Callback when print command received (filename, data)
|
||||
"""
|
||||
self.serial = serial
|
||||
self.access_code = access_code
|
||||
self.cert_path = cert_path
|
||||
self.key_path = key_path
|
||||
self.port = port
|
||||
self.on_print_command = on_print_command
|
||||
self._running = False
|
||||
self._broker = None
|
||||
self._broker_task = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the MQTT broker."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Try to import amqtt
|
||||
try:
|
||||
from amqtt.broker import Broker
|
||||
except ImportError:
|
||||
logger.error("amqtt not installed. Run: pip install amqtt")
|
||||
return
|
||||
|
||||
logger.info("Starting virtual printer MQTT broker on port %s", self.port)
|
||||
|
||||
# Build broker configuration
|
||||
config = {
|
||||
"listeners": {
|
||||
"default": {
|
||||
"type": "tcp",
|
||||
"bind": f"0.0.0.0:{self.port}",
|
||||
"ssl": "on",
|
||||
"certfile": str(self.cert_path),
|
||||
"keyfile": str(self.key_path),
|
||||
},
|
||||
},
|
||||
"auth": {
|
||||
"allow-anonymous": False,
|
||||
"plugins": ["auth_custom"],
|
||||
},
|
||||
"topic-check": {
|
||||
"enabled": False, # Allow any topic
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
self._running = True
|
||||
|
||||
# Create and start broker
|
||||
self._broker = Broker(config)
|
||||
|
||||
# Register custom auth plugin
|
||||
self._broker.plugins_manager.plugins_handlers["auth_custom"] = self._authenticate
|
||||
|
||||
# Start the broker
|
||||
await self._broker.start()
|
||||
logger.info("MQTT broker started on port %s", self.port)
|
||||
|
||||
# Keep running
|
||||
while self._running:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except OSError as e:
|
||||
if e.errno == 98: # Address already in use
|
||||
logger.error("MQTT port %s is already in use", self.port)
|
||||
else:
|
||||
logger.error("MQTT broker error: %s", e)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("MQTT broker task cancelled")
|
||||
except Exception as e:
|
||||
logger.error("MQTT broker error: %s", e)
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
async def _authenticate(self, session) -> bool:
|
||||
"""Authenticate MQTT connection.
|
||||
|
||||
Args:
|
||||
session: MQTT session with username/password
|
||||
|
||||
Returns:
|
||||
True if authentication successful
|
||||
"""
|
||||
username = getattr(session, "username", None)
|
||||
password = getattr(session, "password", None)
|
||||
|
||||
# Bambu slicers use 'bblp' as username and access code as password
|
||||
if username == "bblp" and password == self.access_code:
|
||||
logger.debug("MQTT client authenticated from %s", session.remote_address)
|
||||
return True
|
||||
|
||||
logger.warning("MQTT auth failed for user '%s' from %s", username, session.remote_address)
|
||||
return False
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the MQTT broker."""
|
||||
logger.info("Stopping MQTT broker")
|
||||
self._running = False
|
||||
|
||||
if self._broker:
|
||||
try:
|
||||
await self._broker.shutdown()
|
||||
except OSError as e:
|
||||
logger.debug("Error shutting down MQTT broker: %s", e)
|
||||
self._broker = None
|
||||
|
||||
|
||||
class SimpleMQTTServer:
|
||||
"""Simplified MQTT server using raw sockets.
|
||||
"""The virtual printer's MQTT server, on raw sockets.
|
||||
|
||||
This is a fallback implementation that handles basic MQTT protocol
|
||||
without requiring the amqtt library. It's less feature-complete but
|
||||
more lightweight.
|
||||
Handles the part of the MQTT protocol slicers use with a printer; not a
|
||||
general-purpose broker.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -14,7 +14,6 @@ rewrite the printer's real IP with the proxy's bind IP in MQTT payloads.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import ssl
|
||||
import subprocess
|
||||
@@ -24,44 +23,6 @@ from pathlib import Path
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _SessionReuseSSLContext:
|
||||
"""Proxy around SSLContext that injects a TLS session into wrap_bio().
|
||||
|
||||
vsFTPd (used by some Bambu printers like X1C) requires TLS session reuse
|
||||
on FTP data channels — the data connection must reuse the TLS session from
|
||||
the control channel. Without this, the printer rejects the data connection
|
||||
with "522 SSL connection failed: session reuse required".
|
||||
|
||||
asyncio's open_connection() calls SSLContext.wrap_bio() internally but
|
||||
doesn't expose a session parameter. This wrapper intercepts wrap_bio()
|
||||
to inject the saved control-channel session, enabling session reuse.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: ssl.SSLContext, session: ssl.SSLSession) -> None:
|
||||
object.__setattr__(self, "_ctx", ctx)
|
||||
object.__setattr__(self, "_session", session)
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
return getattr(self._ctx, name)
|
||||
|
||||
def wrap_bio(
|
||||
self,
|
||||
incoming: ssl.MemoryBIO,
|
||||
outgoing: ssl.MemoryBIO,
|
||||
server_side: bool = False,
|
||||
server_hostname: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> ssl.SSLObject:
|
||||
return self._ctx.wrap_bio(
|
||||
incoming,
|
||||
outgoing,
|
||||
server_side=server_side,
|
||||
server_hostname=server_hostname,
|
||||
session=self._session,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def detect_port_redirect(port: int) -> int | None:
|
||||
"""Detect if iptables redirects a port to another port.
|
||||
|
||||
@@ -805,636 +766,6 @@ class TCPProxy:
|
||||
logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
|
||||
|
||||
|
||||
class FTPTLSProxy(TLSProxy):
|
||||
"""FTP-aware TLS proxy that handles passive data connections.
|
||||
|
||||
Extends TLSProxy to intercept PASV/EPSV responses on the FTP control
|
||||
channel, dynamically create TLS data proxies on local ports, and rewrite
|
||||
the responses so the slicer connects to the proxy instead of the printer.
|
||||
|
||||
Without this, FTP passive data connections bypass the proxy and go directly
|
||||
to the printer, which fails when the slicer can't reach the printer's IP.
|
||||
"""
|
||||
|
||||
PASV_PORT_MIN = 50000
|
||||
PASV_PORT_MAX = 50100
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop proxy and clean up data connection servers."""
|
||||
# Cancel any pending auto_close timeouts so they don't outlive the
|
||||
# proxy holding a dead server reference. Without this, up to 101
|
||||
# tasks lingered for ~60 s after stop(), each gripping a server
|
||||
# ref + an active_connections slot; under rapid mode-switch the
|
||||
# ports stayed bound long enough to fail the next start().
|
||||
for task in list(self._auto_close_tasks):
|
||||
task.cancel()
|
||||
if self._auto_close_tasks:
|
||||
await asyncio.gather(*self._auto_close_tasks, return_exceptions=True)
|
||||
self._auto_close_tasks.clear()
|
||||
# Close all data servers
|
||||
for server in list(self._data_servers):
|
||||
try:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
except OSError:
|
||||
pass # Best-effort cleanup of data proxy servers
|
||||
self._data_servers.clear()
|
||||
await super().stop()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the FTP TLS proxy."""
|
||||
self._data_servers: list[asyncio.Server] = []
|
||||
self._auto_close_tasks: list[asyncio.Task] = []
|
||||
await super().start()
|
||||
|
||||
async def _handle_client(
|
||||
self,
|
||||
client_reader: asyncio.StreamReader,
|
||||
client_writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
"""Handle FTP client with PASV/EPSV-aware response forwarding."""
|
||||
peername = client_writer.get_extra_info("peername")
|
||||
client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
|
||||
|
||||
logger.info("%s proxy: client connected from %s", self.name, client_id)
|
||||
|
||||
if self.on_connect:
|
||||
try:
|
||||
self.on_connect(client_id)
|
||||
except Exception:
|
||||
pass # Ignore connect callback errors; connection proceeds regardless
|
||||
|
||||
# Determine our local IP from the control connection socket
|
||||
sockname = client_writer.get_extra_info("sockname")
|
||||
local_ip = sockname[0] if sockname else "0.0.0.0" # nosec B104
|
||||
if local_ip in ("0.0.0.0", "::"): # nosec B104
|
||||
local_ip = "127.0.0.1"
|
||||
|
||||
# Connect to target printer with TLS
|
||||
try:
|
||||
printer_reader, printer_writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(
|
||||
self.target_host,
|
||||
self.target_port,
|
||||
ssl=self._client_ssl_context,
|
||||
),
|
||||
timeout=10.0,
|
||||
)
|
||||
logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
|
||||
except TimeoutError:
|
||||
logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
|
||||
client_writer.close()
|
||||
await client_writer.wait_closed()
|
||||
return
|
||||
except ssl.SSLError as e:
|
||||
logger.error(
|
||||
"%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
|
||||
)
|
||||
client_writer.close()
|
||||
await client_writer.wait_closed()
|
||||
return
|
||||
except OSError as e:
|
||||
logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
|
||||
client_writer.close()
|
||||
await client_writer.wait_closed()
|
||||
return
|
||||
|
||||
# Capture the TLS session from the control channel for data channel
|
||||
# reuse. vsFTPd (X1C) requires require_ssl_reuse — the data connection
|
||||
# must present the same TLS session as the control channel.
|
||||
ctrl_ssl_object = printer_writer.get_extra_info("ssl_object")
|
||||
ctrl_tls_session = ctrl_ssl_object.session if ctrl_ssl_object else None
|
||||
if ctrl_tls_session:
|
||||
logger.debug("%s proxy: captured TLS session for data channel reuse", self.name)
|
||||
|
||||
# Track data channel protection level per session.
|
||||
# PROT C = cleartext data, PROT P = TLS data.
|
||||
# Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
|
||||
# If the slicer sends PROT P, we switch to TLS for data connections.
|
||||
session_state: dict[str, str | ssl.SSLSession] = {"prot": "C"}
|
||||
if ctrl_tls_session:
|
||||
session_state["tls_session"] = ctrl_tls_session
|
||||
|
||||
# Client→Printer: intercept EPSV and replace with PASV
|
||||
# EPSV responses only contain a port (no IP), so the slicer reuses
|
||||
# the control connection IP. If that IP is the real printer (via
|
||||
# iptables REDIRECT), the data connection bypasses the proxy.
|
||||
# PASV responses include an explicit IP that we can rewrite.
|
||||
client_to_printer = asyncio.create_task(
|
||||
self._forward_ftp_commands(client_reader, printer_writer, f"{client_id}→printer", session_state),
|
||||
name=f"{self.name}_c2p_{client_id}",
|
||||
)
|
||||
# Printer→Client: intercept PASV/EPSV responses
|
||||
printer_to_client = asyncio.create_task(
|
||||
self._forward_ftp_control(printer_reader, client_writer, f"printer→{client_id}", local_ip, session_state),
|
||||
name=f"{self.name}_p2c_{client_id}",
|
||||
)
|
||||
|
||||
self._active_connections[client_id] = (client_to_printer, printer_to_client)
|
||||
|
||||
try:
|
||||
done, pending = await asyncio.wait(
|
||||
[client_to_printer, printer_to_client],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass # Expected when cancelling the other forwarding direction
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("%s proxy connection error: %s", self.name, e)
|
||||
finally:
|
||||
self._active_connections.pop(client_id, None)
|
||||
|
||||
for writer in [client_writer, printer_writer]:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except OSError:
|
||||
pass # Best-effort connection cleanup; peer may have disconnected
|
||||
|
||||
logger.info("%s proxy: client %s disconnected", self.name, client_id)
|
||||
|
||||
if self.on_disconnect:
|
||||
try:
|
||||
self.on_disconnect(client_id)
|
||||
except Exception:
|
||||
pass # Ignore disconnect callback errors; cleanup continues
|
||||
|
||||
async def _forward_ftp_commands(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
direction: str,
|
||||
session_state: dict[str, str | ssl.SSLSession],
|
||||
) -> None:
|
||||
"""Forward FTP client commands, replacing EPSV with PASV.
|
||||
|
||||
EPSV responses only contain a port number — the client reuses the
|
||||
control connection IP for data. When the control IP is the real
|
||||
printer (due to iptables REDIRECT), EPSV data connections bypass
|
||||
the proxy. PASV responses include an explicit IP that the proxy
|
||||
can rewrite to its own address.
|
||||
|
||||
Also tracks PROT P/C commands to know whether data connections
|
||||
should use TLS or cleartext.
|
||||
"""
|
||||
buffer = b""
|
||||
total_bytes = 0
|
||||
try:
|
||||
while self._running:
|
||||
data = await reader.read(65536)
|
||||
if not data:
|
||||
break
|
||||
|
||||
total_bytes += len(data)
|
||||
buffer += data
|
||||
output = b""
|
||||
|
||||
while b"\r\n" in buffer:
|
||||
idx = buffer.index(b"\r\n")
|
||||
line = buffer[:idx]
|
||||
buffer = buffer[idx + 2 :]
|
||||
|
||||
cmd_upper = line.strip().upper()
|
||||
|
||||
# Track PROT level for data channel encryption
|
||||
if cmd_upper == b"PROT P":
|
||||
session_state["prot"] = "P"
|
||||
logger.info("FTP data protection: PROT P (TLS)")
|
||||
elif cmd_upper == b"PROT C":
|
||||
session_state["prot"] = "C"
|
||||
logger.info("FTP data protection: PROT C (cleartext)")
|
||||
|
||||
output += line + b"\r\n"
|
||||
|
||||
if output:
|
||||
writer.write(output)
|
||||
await writer.drain()
|
||||
|
||||
logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass # Expected when the other forwarding direction closes first
|
||||
except ConnectionResetError:
|
||||
logger.debug("%s proxy %s: connection reset", self.name, direction)
|
||||
except BrokenPipeError:
|
||||
logger.debug("%s proxy %s: broken pipe", self.name, direction)
|
||||
except OSError as e:
|
||||
logger.debug("%s proxy %s error: %s", self.name, direction, e)
|
||||
|
||||
if buffer:
|
||||
try:
|
||||
writer.write(buffer)
|
||||
await writer.drain()
|
||||
except OSError:
|
||||
pass # Best-effort flush of remaining FTP command data
|
||||
|
||||
logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
|
||||
|
||||
async def _forward_ftp_control(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
direction: str,
|
||||
local_ip: str,
|
||||
session_state: dict[str, str | ssl.SSLSession],
|
||||
) -> None:
|
||||
"""Forward FTP control channel responses, rewriting PASV/EPSV.
|
||||
|
||||
FTP control channel is line-based (\\r\\n terminated). We buffer data
|
||||
and process complete lines, intercepting 227 (PASV) and 229 (EPSV)
|
||||
responses to create local data proxies.
|
||||
"""
|
||||
buffer = b""
|
||||
total_bytes = 0
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
data = await reader.read(65536)
|
||||
if not data:
|
||||
break
|
||||
|
||||
total_bytes += len(data)
|
||||
buffer += data
|
||||
output = b""
|
||||
|
||||
# Process all complete lines
|
||||
while b"\r\n" in buffer:
|
||||
idx = buffer.index(b"\r\n")
|
||||
line = buffer[:idx]
|
||||
buffer = buffer[idx + 2 :]
|
||||
|
||||
rewritten = await self._maybe_rewrite_pasv(line, local_ip, session_state)
|
||||
output += rewritten + b"\r\n"
|
||||
|
||||
if output:
|
||||
writer.write(output)
|
||||
await writer.drain()
|
||||
|
||||
logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass # Expected when the other forwarding direction closes first
|
||||
except ConnectionResetError:
|
||||
logger.debug("%s proxy %s: connection reset", self.name, direction)
|
||||
except BrokenPipeError:
|
||||
logger.debug("%s proxy %s: broken pipe", self.name, direction)
|
||||
except OSError as e:
|
||||
logger.debug("%s proxy %s error: %s", self.name, direction, e)
|
||||
|
||||
# Flush any remaining buffered data
|
||||
if buffer:
|
||||
try:
|
||||
writer.write(buffer)
|
||||
await writer.drain()
|
||||
except OSError:
|
||||
pass # Best-effort flush of remaining FTP control data
|
||||
|
||||
logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
|
||||
|
||||
async def _maybe_rewrite_pasv(
|
||||
self, line: bytes, local_ip: str, session_state: dict[str, str | ssl.SSLSession]
|
||||
) -> bytes:
|
||||
"""Rewrite PASV/EPSV response to point to a local data proxy."""
|
||||
try:
|
||||
text = line.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return line
|
||||
|
||||
# 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
|
||||
if text.startswith("227 "):
|
||||
match = re.search(r"\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", text)
|
||||
if match:
|
||||
h1, h2, h3, h4, p1, p2 = (int(x) for x in match.groups())
|
||||
printer_ip = f"{h1}.{h2}.{h3}.{h4}"
|
||||
printer_port = p1 * 256 + p2
|
||||
|
||||
local_port = await self._create_data_proxy(printer_ip, printer_port, session_state)
|
||||
if local_port:
|
||||
ip_parts = local_ip.split(".")
|
||||
lp1 = local_port // 256
|
||||
lp2 = local_port % 256
|
||||
rewritten = (
|
||||
f"227 Entering Passive Mode "
|
||||
f"({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{lp1},{lp2})"
|
||||
)
|
||||
logger.info("FTP PASV rewrite: %s:%s → %s:%s", printer_ip, printer_port, local_ip, local_port)
|
||||
return rewritten.encode("utf-8")
|
||||
else:
|
||||
logger.error("FTP PASV: failed to create data proxy for %s:%s", printer_ip, printer_port)
|
||||
else:
|
||||
logger.warning("FTP PASV: 227 response didn't match expected format: %s", text[:100])
|
||||
|
||||
# 229 Entering Extended Passive Mode (|||port|)
|
||||
elif text.startswith("229 "):
|
||||
match = re.search(r"\(\|\|\|(\d+)\|\)", text)
|
||||
if match:
|
||||
printer_port = int(match.group(1))
|
||||
|
||||
local_port = await self._create_data_proxy(self.target_host, printer_port, session_state)
|
||||
if local_port:
|
||||
rewritten = f"229 Entering Extended Passive Mode (|||{local_port}|)"
|
||||
logger.info("FTP EPSV rewrite: port %s → %s", printer_port, local_port)
|
||||
return rewritten.encode("utf-8")
|
||||
else:
|
||||
logger.error("FTP EPSV: failed to create data proxy for port %s", printer_port)
|
||||
else:
|
||||
logger.warning("FTP EPSV: 229 response didn't match expected format: %s", text[:100])
|
||||
|
||||
return line
|
||||
|
||||
async def _create_data_proxy(
|
||||
self, printer_ip: str, printer_port: int, session_state: dict[str, str | ssl.SSLSession]
|
||||
) -> int | None:
|
||||
"""Create a one-shot proxy for an FTP data connection.
|
||||
|
||||
Prefers the printer's original passive port so the port number stays
|
||||
the same in the rewritten PASV/EPSV response. This is critical when
|
||||
the slicer's FTP bounce-attack protection overrides the IP in the PASV
|
||||
response: the slicer connects to <control_IP>:<port>, and if iptables
|
||||
REDIRECT maps that port to the local machine, the data proxy must be
|
||||
listening on the *same* port number.
|
||||
|
||||
Falls back to a random port if the original is unavailable.
|
||||
|
||||
Uses TLS or cleartext based on the session's PROT level:
|
||||
- PROT P: TLS on both slicer and printer data connections
|
||||
- PROT C: cleartext on both sides (common for A1/H2D printers)
|
||||
|
||||
Returns the local port number, or None if binding failed.
|
||||
"""
|
||||
use_tls = session_state.get("prot") == "P"
|
||||
logger.info(
|
||||
"FTP data proxy: creating data proxy for %s:%s (printer-side %s)",
|
||||
printer_ip,
|
||||
printer_port,
|
||||
"TLS" if use_tls else "cleartext",
|
||||
)
|
||||
|
||||
# Get control channel TLS session for data channel reuse
|
||||
tls_session = session_state.get("tls_session") if use_tls else None
|
||||
|
||||
# Try the printer's original port first — this ensures the port
|
||||
# matches even when bounce protection or iptables REDIRECT is in play.
|
||||
try:
|
||||
await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls, tls_session)
|
||||
logger.info("FTP data proxy: using printer's port %s", printer_port)
|
||||
return printer_port
|
||||
except OSError as e:
|
||||
logger.debug(
|
||||
"FTP data proxy: printer port %s unavailable (%s), trying random",
|
||||
printer_port,
|
||||
e,
|
||||
)
|
||||
|
||||
for _attempt in range(10):
|
||||
port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
|
||||
try:
|
||||
await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls, tls_session)
|
||||
logger.info("FTP data proxy: using random port %s", port)
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
logger.error("Failed to bind FTP data proxy port after 10 attempts")
|
||||
return None
|
||||
|
||||
async def _start_data_proxy_server(
|
||||
self,
|
||||
port: int,
|
||||
printer_ip: str,
|
||||
printer_port: int,
|
||||
use_tls: bool,
|
||||
tls_session: ssl.SSLSession | None = None,
|
||||
) -> None:
|
||||
"""Start a one-shot server for one FTP data connection.
|
||||
|
||||
When the slicer connects, immediately connects to the printer's data
|
||||
port and buffers any slicer data until the printer connection is ready.
|
||||
This handles zero-byte uploads (verify_job) where the slicer closes
|
||||
the data channel before a naive proxy would finish its TLS handshake.
|
||||
|
||||
The slicer-side listener is ALWAYS cleartext. Even when the slicer
|
||||
sends PROT P on the control channel, Bambu Studio does not perform
|
||||
a TLS handshake on the data connection — it relies on the implicit
|
||||
FTPS control channel for authentication and sends data unencrypted.
|
||||
|
||||
The printer-side outbound connection follows the PROT level:
|
||||
- PROT P (use_tls=True): TLS to the printer's data port
|
||||
- PROT C (use_tls=False): cleartext to the printer's data port
|
||||
|
||||
This mirrors the control channel's TLS-termination architecture.
|
||||
|
||||
Raises OSError if the port is already in use.
|
||||
"""
|
||||
connected = asyncio.Event()
|
||||
server_holder: list[asyncio.Server] = []
|
||||
|
||||
# Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
|
||||
# the data channel even after sending PROT P.
|
||||
# Printer side: TLS if PROT P, cleartext if PROT C.
|
||||
# For TLS data connections, wrap the SSL context to reuse the
|
||||
# control channel's TLS session if available. vsFTPd (X1C) requires
|
||||
# require_ssl_reuse — without this, data connections are rejected
|
||||
# with "522 SSL connection failed: session reuse required".
|
||||
if use_tls and tls_session:
|
||||
client_ssl = _SessionReuseSSLContext(self._client_ssl_context, tls_session)
|
||||
logger.debug("FTP data proxy: using TLS session reuse for port %s", port)
|
||||
else:
|
||||
client_ssl = self._client_ssl_context if use_tls else None
|
||||
|
||||
# Slicer side is ALWAYS cleartext — Bambu Studio does not do TLS on
|
||||
# the data channel even after PROT P (confirmed for both H2D and X1C).
|
||||
printer_mode = "TLS" if use_tls else "cleartext"
|
||||
|
||||
async def handle_data(
|
||||
client_reader: asyncio.StreamReader,
|
||||
client_writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
"""Handle one FTP data connection, then close the server."""
|
||||
peername = client_writer.get_extra_info("peername")
|
||||
data_client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
|
||||
logger.info(
|
||||
"FTP data proxy port %s (slicer=cleartext, printer=%s): client connected from %s, bridging to %s:%s",
|
||||
port,
|
||||
printer_mode,
|
||||
data_client,
|
||||
printer_ip,
|
||||
printer_port,
|
||||
)
|
||||
connected.set()
|
||||
# One-shot: close server after accepting first connection
|
||||
if server_holder:
|
||||
server_holder[0].close()
|
||||
|
||||
printer_writer = None
|
||||
try:
|
||||
# Buffer any slicer data while connecting to printer.
|
||||
# This handles the race where the slicer sends data (or closes
|
||||
# for zero-byte files) before the TLS handshake completes.
|
||||
slicer_buffer = bytearray()
|
||||
slicer_eof = False
|
||||
|
||||
async def buffer_slicer():
|
||||
nonlocal slicer_eof
|
||||
while True:
|
||||
chunk = await client_reader.read(65536)
|
||||
if not chunk:
|
||||
slicer_eof = True
|
||||
return
|
||||
slicer_buffer.extend(chunk)
|
||||
|
||||
buffer_task = asyncio.create_task(buffer_slicer())
|
||||
|
||||
# Connect to printer's data port
|
||||
printer_reader, printer_writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(printer_ip, printer_port, ssl=client_ssl),
|
||||
timeout=10.0,
|
||||
)
|
||||
logger.info(
|
||||
"FTP data proxy port %s (printer=%s): connected to printer %s:%s",
|
||||
port,
|
||||
printer_mode,
|
||||
printer_ip,
|
||||
printer_port,
|
||||
)
|
||||
|
||||
# Stop buffering
|
||||
buffer_task.cancel()
|
||||
try:
|
||||
await buffer_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Flush buffered slicer data to printer
|
||||
logger.info(
|
||||
"FTP data proxy port %s: buffer=%s bytes, slicer_eof=%s",
|
||||
port,
|
||||
len(slicer_buffer),
|
||||
slicer_eof,
|
||||
)
|
||||
if slicer_buffer:
|
||||
printer_writer.write(bytes(slicer_buffer))
|
||||
await printer_writer.drain()
|
||||
|
||||
# Forward remaining slicer data to printer, then close the
|
||||
# printer side to signal upload complete.
|
||||
#
|
||||
# Bambu Studio does NOT close the FTP data channel after sending
|
||||
# STOR data — it keeps the connection open and waits for the
|
||||
# printer to close its side + send 226 on the control channel.
|
||||
# A naive bidirectional proxy deadlocks here because the proxy
|
||||
# waits for the slicer EOF that never comes.
|
||||
#
|
||||
# Fix: read slicer data with an idle timeout. Once data has been
|
||||
# received and the slicer goes quiet, close the printer side so
|
||||
# the printer can send 226. For RETR (download), the printer
|
||||
# sends data and closes — the slicer reads until EOF — so this
|
||||
# unidirectional approach works for both directions.
|
||||
total_c2p = len(slicer_buffer)
|
||||
if not slicer_eof:
|
||||
# Read remaining slicer data with idle detection.
|
||||
# Must be short — Bambu Studio expects 226 almost instantly
|
||||
# after sending data. Too long and the slicer times out.
|
||||
idle_timeout = 0.3
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(client_reader.read(65536), timeout=idle_timeout)
|
||||
except TimeoutError:
|
||||
if total_c2p > 0:
|
||||
# Slicer sent data then went idle — upload done
|
||||
logger.debug(
|
||||
"FTP data proxy port %s: slicer idle after %s bytes, closing printer side",
|
||||
port,
|
||||
total_c2p,
|
||||
)
|
||||
break
|
||||
continue # No data yet, keep waiting
|
||||
if not chunk:
|
||||
break # Slicer closed
|
||||
printer_writer.write(chunk)
|
||||
await printer_writer.drain()
|
||||
total_c2p += len(chunk)
|
||||
|
||||
logger.debug("FTP proxy data_c2p: total %s bytes", total_c2p)
|
||||
|
||||
# Close printer side to signal upload complete.
|
||||
# For TLS, close() sends close_notify which the printer treats
|
||||
# as end-of-data. The printer then sends 226 on the control
|
||||
# channel. For RETR, this is a no-op since the printer closes
|
||||
# first and we'd have exited the loop above via EOF.
|
||||
try:
|
||||
printer_writer.close()
|
||||
await printer_writer.wait_closed()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Wait for 226 response to propagate through the FTP control
|
||||
# channel before closing the slicer's data channel.
|
||||
#
|
||||
# Without this delay, the data channel FIN arrives at the
|
||||
# slicer before the 226 response on the control channel.
|
||||
# BambuStudio reacts to the data channel FIN within <1ms
|
||||
# by sending QUIT + closing the control channel — before
|
||||
# 226 arrives (~2-3ms network RTT). This causes verify_job
|
||||
# to be treated as failed and shows the login modal.
|
||||
#
|
||||
# In a direct connection, the printer sends 226 AND closes
|
||||
# the data channel simultaneously, so the slicer gets both
|
||||
# at once. The delay here emulates that timing.
|
||||
if total_c2p > 0:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("FTP data proxy port %s: error: %s", port, e)
|
||||
finally:
|
||||
for w in [client_writer, printer_writer]:
|
||||
if w:
|
||||
try:
|
||||
w.close()
|
||||
await w.wait_closed()
|
||||
except OSError:
|
||||
pass # Best-effort data connection cleanup
|
||||
logger.info("FTP data proxy port %s: connection closed", port)
|
||||
|
||||
server = await asyncio.start_server(
|
||||
handle_data,
|
||||
"0.0.0.0", # nosec B104
|
||||
port,
|
||||
# No TLS on slicer side — Bambu Studio doesn't do TLS on data
|
||||
# channel even after PROT P (confirmed by connection hang test).
|
||||
)
|
||||
server_holder.append(server)
|
||||
self._data_servers.append(server)
|
||||
|
||||
# Auto-close after 60s if no connection arrives
|
||||
async def auto_close() -> None:
|
||||
try:
|
||||
await asyncio.wait_for(connected.wait(), timeout=60.0)
|
||||
except TimeoutError:
|
||||
logger.debug("FTP data proxy on port %s timed out, closing", port)
|
||||
try:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
except OSError:
|
||||
pass # Best-effort timeout cleanup
|
||||
finally:
|
||||
if server in self._data_servers:
|
||||
self._data_servers.remove(server)
|
||||
|
||||
# Track the auto_close task so stop() can cancel it; otherwise the
|
||||
# 60 s timeout would hold a server reference past proxy teardown.
|
||||
ac_task = asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
|
||||
self._auto_close_tasks.append(ac_task)
|
||||
ac_task.add_done_callback(lambda t, tasks=self._auto_close_tasks: tasks.remove(t) if t in tasks else None)
|
||||
|
||||
logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port)
|
||||
|
||||
|
||||
class SlicerProxyManager:
|
||||
"""Manages FTP and MQTT TLS proxies for a single printer target."""
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.notification_template import NotificationTemplate
|
||||
from backend.app.services.email_service import (
|
||||
create_password_reset_email_from_template,
|
||||
create_welcome_email_from_template,
|
||||
generate_secure_password,
|
||||
render_template,
|
||||
@@ -68,46 +67,6 @@ class TestEmailTemplateFormatting:
|
||||
assert "Hello testuser!<br>" in html_body
|
||||
assert "Your password is: testpass123<br>" in html_body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_password_reset_email_newlines_converted_to_br(self):
|
||||
"""Verify that newlines in password reset email body are converted to <br> tags."""
|
||||
# Mock database session
|
||||
db = AsyncMock(spec=AsyncSession)
|
||||
|
||||
# Mock template with newlines
|
||||
template = NotificationTemplate(
|
||||
event_type="password_reset",
|
||||
name="Password Reset",
|
||||
title_template="{app_name} - Password Reset",
|
||||
body_template="Hello {username},\n\nYour password has been reset.\nNew password: {password}\n\nLogin at: {login_url}",
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
# Patch get_notification_template to return our template
|
||||
with patch("backend.app.services.email_service.get_notification_template", return_value=template):
|
||||
# Generate email
|
||||
subject, text_body, html_body = await create_password_reset_email_from_template(
|
||||
db=db,
|
||||
username="testuser",
|
||||
password="newpass456",
|
||||
login_url="http://example.com/login",
|
||||
app_name="TestApp",
|
||||
)
|
||||
|
||||
# Verify subject
|
||||
assert subject == "TestApp - Password Reset"
|
||||
|
||||
# Verify text body has newlines
|
||||
assert "\n\n" in text_body
|
||||
assert "Hello testuser," in text_body
|
||||
|
||||
# Verify HTML body has <br> tags
|
||||
assert "<br>" in html_body
|
||||
# Should not use white-space: pre-wrap
|
||||
assert "white-space: pre-wrap" not in html_body
|
||||
# Should have security alert
|
||||
assert "Security Alert" in html_body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_header_padding(self):
|
||||
"""Verify that email header has proper padding to prevent cutoff."""
|
||||
|
||||
@@ -130,7 +130,6 @@ class TestNotificationService:
|
||||
},
|
||||
)
|
||||
assert mock_send.await_args.args[4:7] == ("billing_charge_failed", 7, "Printer B")
|
||||
assert mock_send.await_args.kwargs["force_immediate"] is True
|
||||
|
||||
# ========================================================================
|
||||
# Tests for on_print_complete (status routing)
|
||||
@@ -347,9 +346,6 @@ class TestNotificationService:
|
||||
)
|
||||
|
||||
mock_send.assert_called_once()
|
||||
# Verify force_immediate is True for alarms
|
||||
call_kwargs = mock_send.call_args[1]
|
||||
assert call_kwargs.get("force_immediate") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_ams_temperature_high_sends_notification(self, service, mock_provider, mock_db):
|
||||
@@ -374,9 +370,6 @@ class TestNotificationService:
|
||||
)
|
||||
|
||||
mock_send.assert_called_once()
|
||||
# Verify force_immediate is True for alarms
|
||||
call_kwargs = mock_send.call_args[1]
|
||||
assert call_kwargs.get("force_immediate") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ams_alarm_skipped_when_toggle_disabled(self, service, mock_provider, mock_db):
|
||||
@@ -431,33 +424,6 @@ class TestNotificationService:
|
||||
# but internally it will queue instead of send immediately
|
||||
mock_send.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_force_immediate_bypasses_digest(self, service, mock_provider, mock_db):
|
||||
"""Verify force_immediate=True bypasses digest mode."""
|
||||
mock_provider.daily_digest_enabled = True
|
||||
mock_provider.on_ams_humidity_high = True
|
||||
|
||||
with (
|
||||
patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
|
||||
patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
|
||||
patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
|
||||
):
|
||||
mock_get.return_value = [mock_provider]
|
||||
mock_build.return_value = ("Alert", "Alert message")
|
||||
|
||||
await service.on_ams_humidity_high(
|
||||
printer_id=1,
|
||||
printer_name="Test",
|
||||
ams_label="AMS-A",
|
||||
humidity=75.0,
|
||||
threshold=60.0,
|
||||
db=mock_db,
|
||||
)
|
||||
|
||||
# Verify force_immediate is passed
|
||||
call_kwargs = mock_send.call_args[1]
|
||||
assert call_kwargs.get("force_immediate") is True
|
||||
|
||||
|
||||
class TestDigestModeAlwaysSendsImmediately:
|
||||
"""CRITICAL: Tests that notifications always send immediately regardless of digest setting."""
|
||||
@@ -2437,9 +2403,6 @@ class TestPlateNotEmptyNotifications:
|
||||
|
||||
mock_get.assert_called_once()
|
||||
mock_send.assert_called_once()
|
||||
# Verify force_immediate is True (critical alert)
|
||||
call_kwargs = mock_send.call_args[1]
|
||||
assert call_kwargs.get("force_immediate") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_plate_not_empty_skipped_when_disabled(self, service, mock_provider, mock_db):
|
||||
|
||||
@@ -284,16 +284,10 @@ class TestLeftAuxFanCommand:
|
||||
assert mqtt_client.set_fan_speed(10, 204) is True
|
||||
assert sent == ["M106 P10 S204"]
|
||||
|
||||
def test_set_left_aux_fan_helper(self, mqtt_client, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
|
||||
assert mqtt_client.set_left_aux_fan(255) is True
|
||||
assert sent == ["M106 P10 S255"]
|
||||
|
||||
def test_speed_clamped_to_255(self, mqtt_client, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
|
||||
mqtt_client.set_left_aux_fan(999)
|
||||
mqtt_client.set_fan_speed(10, 999)
|
||||
assert sent == ["M106 P10 S255"]
|
||||
|
||||
def test_invalid_fan_index_rejected(self, mqtt_client, monkeypatch):
|
||||
|
||||
@@ -95,7 +95,7 @@ async def test_restore_drops_tables_with_cascade_not_metadata_drop_all():
|
||||
"backend.app.core.database._create_engine",
|
||||
new=MagicMock(return_value=mock_engine),
|
||||
):
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path)
|
||||
|
||||
# 1. CASCADE drop is emitted, hitting every public-schema table.
|
||||
cascade_drops = [s for s in executed_sql if "CASCADE" in s and "pg_tables" in s]
|
||||
@@ -157,7 +157,7 @@ async def test_restore_cascade_drop_targets_only_public_schema():
|
||||
"backend.app.core.database._create_engine",
|
||||
new=MagicMock(return_value=mock_engine),
|
||||
):
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path)
|
||||
|
||||
cascade = next((s for s in executed_sql if "CASCADE" in s), None)
|
||||
assert cascade is not None
|
||||
@@ -236,7 +236,7 @@ async def test_restore_drops_every_foreign_key_before_importing_rows():
|
||||
"backend.app.core.database._create_engine",
|
||||
new=MagicMock(return_value=_mock_pg_engine(executed_sql)),
|
||||
):
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path)
|
||||
|
||||
fk_drops = [i for i, s in enumerate(executed_sql) if "pg_constraint" in s and "DROP CONSTRAINT" in s]
|
||||
assert fk_drops, (
|
||||
@@ -283,9 +283,9 @@ async def test_restore_never_mutates_the_process_wide_orm_metadata(create_all_fa
|
||||
with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
|
||||
if create_all_fails:
|
||||
with pytest.raises(RuntimeError, match="create_all exploded"):
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path)
|
||||
else:
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path)
|
||||
|
||||
assert _fk_names(table) == before, "The restore must not add or remove constraints on the shared ORM metadata"
|
||||
finally:
|
||||
@@ -314,7 +314,7 @@ async def test_unrestorable_fk_is_reported_by_its_columns(caplog):
|
||||
patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)),
|
||||
caplog.at_level(logging.INFO, logger="backend.app.api.routes.settings"),
|
||||
):
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
|
||||
await settings_module._import_sqlite_to_postgres(sqlite_path)
|
||||
|
||||
warning = next((r.getMessage() for r in caplog.records if r.levelno == logging.WARNING), None)
|
||||
assert warning is not None, "a failed FK restore must be reported"
|
||||
|
||||
@@ -250,7 +250,7 @@ async def test_the_import_refuses_before_it_drops_anything(tmp_path):
|
||||
patch("backend.app.core.database._create_engine", new=create_engine),
|
||||
pytest.raises(BackupSchemaIncompatible),
|
||||
):
|
||||
await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test")
|
||||
await settings_module._import_sqlite_to_postgres(path)
|
||||
|
||||
assert executed == [], f"SQL ran against the destination before the refusal: {executed}"
|
||||
create_engine.assert_not_called()
|
||||
@@ -272,7 +272,7 @@ async def test_a_missing_defaulted_column_is_inserted_with_its_default(tmp_path)
|
||||
|
||||
engine, executed = _mock_engine()
|
||||
with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
|
||||
await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test")
|
||||
await settings_module._import_sqlite_to_postgres(path)
|
||||
|
||||
inserts = [(sql, params) for sql, params in executed if sql.startswith("INSERT INTO cost_centers")]
|
||||
assert inserts, f"no INSERT was emitted: {[sql[:60] for sql, _ in executed]}"
|
||||
|
||||
@@ -31,10 +31,10 @@ from backend.app.main import app
|
||||
# Substring patterns identifying auth-bearing callable qualnames in the
|
||||
# resolved Depends() tree. Inner functions returned by factories carry
|
||||
# the outer factory's name in their qualname (e.g.
|
||||
# ``require_permission.<locals>.permission_checker``), so a substring
|
||||
# ``require_permission_if_auth_enabled.<locals>.permission_checker``), so a substring
|
||||
# check is enough; we don't have to enumerate the inner names.
|
||||
_AUTH_QUALNAME_PATTERNS: tuple[str, ...] = (
|
||||
"require_", # require_permission, require_permission_if_auth_enabled, require_role, require_admin_*, require_auth_*, require_any_*, require_ownership_*, require_camera_stream_token_*, require_energy_cost_update
|
||||
"require_", # require_permission_if_auth_enabled, require_admin_*, require_auth_*, require_any_*, require_ownership_*, require_camera_stream_token_*, require_energy_cost_update
|
||||
"cloud_caller", # cloud.py route-level dep
|
||||
"_cloud_api_key_gate", # cloud.py router-level dep
|
||||
"resolve_api_key_cloud_owner", # used by slicer routes that need the API key's owner
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
// The browser loads the service worker by URL (public/sw-register.js, which
|
||||
// index.html includes), so no import leads to it.
|
||||
"entry": ["public/sw.js"],
|
||||
"ignore": [
|
||||
// Tools run by hand (node scripts/...), not part of the app.
|
||||
"scripts/**",
|
||||
// Types the sibling vendor .js; TypeScript reads it for that import.
|
||||
"src/lib/vendor/toolpathRenderer.d.ts"
|
||||
],
|
||||
// Keeps browserslist's baseline data current; nothing imports it.
|
||||
"ignoreDependencies": ["baseline-browser-mapping"],
|
||||
// Unused exports and types are left out: most are only used in their own file.
|
||||
"include": ["files", "dependencies", "devDependencies", "unlisted", "unresolved", "binaries"]
|
||||
}
|
||||
Generated
+831
-93
File diff suppressed because it is too large
Load Diff
@@ -14,13 +14,13 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"check:i18n": "node scripts/check-i18n-parity.mjs",
|
||||
"check:knip": "knip",
|
||||
"check:baseline": "node scripts/check-browser-baseline.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/dom": "^1.7.5",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tiptap/extension-color": "^3.11.1",
|
||||
"@tiptap/extension-image": "^3.11.1",
|
||||
@@ -77,13 +77,13 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"knip": "^6.36.0",
|
||||
"msw": "^2.6.0",
|
||||
"postcss": "^8.5.6",
|
||||
"remark-parse": "^11.0.0",
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
interface WifiSignalProps {
|
||||
signal: number | null | undefined; // dBm value
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WiFi signal icon with 4 bars that fill based on signal strength.
|
||||
* - 4 bars: >= -50 dBm (excellent)
|
||||
* - 3 bars: >= -60 dBm (good)
|
||||
* - 2 bars: >= -70 dBm (fair)
|
||||
* - 1 bar: < -70 dBm (weak)
|
||||
* - 0 bars: no signal data
|
||||
*/
|
||||
export function WifiSignal({ signal, className = "w-4 h-4" }: WifiSignalProps) {
|
||||
let bars = 0;
|
||||
if (signal != null) {
|
||||
if (signal >= -50) bars = 4;
|
||||
else if (signal >= -60) bars = 3;
|
||||
else if (signal >= -70) bars = 2;
|
||||
else bars = 1;
|
||||
}
|
||||
|
||||
const activeColor = "#00ae42"; // bambu-green
|
||||
const inactiveColor = "#4a4a4a"; // dark gray
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
{/* Dot at bottom */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="20"
|
||||
r="1"
|
||||
fill={bars >= 1 ? activeColor : inactiveColor}
|
||||
stroke={bars >= 1 ? activeColor : inactiveColor}
|
||||
/>
|
||||
{/* First arc (smallest) */}
|
||||
<path
|
||||
d="M8.5 16.5a5 5 0 0 1 7 0"
|
||||
stroke={bars >= 2 ? activeColor : inactiveColor}
|
||||
fill="none"
|
||||
/>
|
||||
{/* Second arc */}
|
||||
<path
|
||||
d="M5 13a10 10 0 0 1 14 0"
|
||||
stroke={bars >= 3 ? activeColor : inactiveColor}
|
||||
fill="none"
|
||||
/>
|
||||
{/* Third arc (largest) */}
|
||||
<path
|
||||
d="M1.5 9.5a15 15 0 0 1 21 0"
|
||||
stroke={bars >= 4 ? activeColor : inactiveColor}
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,810 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft, RotateCcw } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { UserCreate, UserUpdate, UserResponse } from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { Button } from '../components/Button';
|
||||
import { Card, CardContent, CardHeader } from '../components/Card';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';
|
||||
import { LdapUserPicker } from '../components/LdapUserPicker';
|
||||
|
||||
interface FormData extends UserCreate {
|
||||
group_ids: number[];
|
||||
confirmPassword: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { user: currentUser, hasPermission } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
// Basic-mode (non-advanced-auth) modal: track which tab is active so the
|
||||
// LDAP picker can replace the local form when LDAP is enabled.
|
||||
const [basicCreateTab, setBasicCreateTab] = useState<'local' | 'ldap'>('local');
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingUserId, setEditingUserId] = useState<number | null>(null);
|
||||
const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
confirmPassword: '',
|
||||
role: 'user',
|
||||
group_ids: [],
|
||||
});
|
||||
|
||||
// Check if advanced auth is enabled
|
||||
const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false } } = useQuery({
|
||||
queryKey: ['advancedAuthStatus'],
|
||||
queryFn: () => api.getAdvancedAuthStatus(),
|
||||
});
|
||||
|
||||
// LDAP status — drives whether the LDAP tab is rendered in the create modal.
|
||||
const { data: ldapStatus = { ldap_enabled: false, ldap_configured: false } } = useQuery({
|
||||
queryKey: ['ldapStatus'],
|
||||
queryFn: () => api.getLDAPStatus(),
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showCreateModal) {
|
||||
setShowCreateModal(false);
|
||||
setBasicCreateTab('local');
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
}
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
setEditingUserId(null);
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showCreateModal, showEditModal]);
|
||||
|
||||
const { data: users = [], isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => api.getUsers(),
|
||||
enabled: hasPermission('users:read'),
|
||||
});
|
||||
|
||||
const { data: groups = [] } = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: () => api.getGroups(),
|
||||
enabled: hasPermission('groups:read'),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: UserCreate) => api.createUser(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
||||
setShowCreateModal(false);
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
showToast(t('users.toast.created'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
||||
setShowEditModal(false);
|
||||
setEditingUserId(null);
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
showToast(t('users.toast.updated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.deleteUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
showToast(t('users.toast.deleted'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: (userId: number) => api.resetUserPassword({ user_id: userId }),
|
||||
onSuccess: (data) => {
|
||||
showToast(data.message, 'success');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Validation for create user button
|
||||
const isCreateButtonDisabled = useMemo(() => {
|
||||
if (createMutation.isPending || !formData.username) {
|
||||
return true;
|
||||
}
|
||||
if (advancedAuthStatus?.advanced_auth_enabled) {
|
||||
// When advanced auth is enabled, require email (password is auto-generated)
|
||||
return !formData.email;
|
||||
}
|
||||
// When advanced auth is disabled, require valid password
|
||||
return !formData.password || formData.password !== formData.confirmPassword || formData.password.length < 6;
|
||||
}, [
|
||||
createMutation.isPending,
|
||||
formData.username,
|
||||
formData.email,
|
||||
formData.password,
|
||||
formData.confirmPassword,
|
||||
advancedAuthStatus?.advanced_auth_enabled
|
||||
]);
|
||||
|
||||
const handleCreate = () => {
|
||||
// Use the status from the query hook
|
||||
const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false;
|
||||
|
||||
if (!formData.username) {
|
||||
const errorMsg = t('users.toast.fillRequired');
|
||||
showToast(errorMsg, 'error');
|
||||
if (advancedAuthEnabled) {
|
||||
console.error('[Advanced Auth] Create user failed: Username is required');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Email is required when advanced auth is enabled
|
||||
if (advancedAuthEnabled && !formData.email) {
|
||||
const errorMsg = 'Email is required when advanced authentication is enabled';
|
||||
showToast(errorMsg, 'error');
|
||||
console.error('[Advanced Auth] Create user failed: Email is required when advanced authentication is enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Password validation only when advanced auth is disabled
|
||||
if (!advancedAuthEnabled) {
|
||||
if (!formData.password) {
|
||||
showToast(t('users.toast.fillRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
showToast(t('users.toast.passwordsDoNotMatch'), 'error');
|
||||
return;
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
showToast(t('users.toast.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
createMutation.mutate({
|
||||
username: formData.username,
|
||||
password: advancedAuthEnabled ? undefined : formData.password,
|
||||
email: formData.email || undefined,
|
||||
role: formData.role,
|
||||
group_ids: formData.group_ids.length > 0 ? formData.group_ids : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdate = (id: number) => {
|
||||
// Validate password confirmation if a new password is being set
|
||||
if (formData.password) {
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
showToast(t('users.toast.passwordsDoNotMatch'), 'error');
|
||||
return;
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
showToast(t('users.toast.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const updateData: UserUpdate = {
|
||||
username: formData.username || undefined,
|
||||
password: formData.password || undefined,
|
||||
email: formData.email || undefined,
|
||||
role: formData.role,
|
||||
group_ids: formData.group_ids,
|
||||
};
|
||||
// Remove password if empty
|
||||
if (!updateData.password) {
|
||||
delete updateData.password;
|
||||
}
|
||||
// Remove email if empty
|
||||
if (!updateData.email) {
|
||||
delete updateData.email;
|
||||
}
|
||||
updateMutation.mutate({ id, data: updateData });
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
setDeleteUserId(id);
|
||||
};
|
||||
|
||||
const startEdit = (user: UserResponse) => {
|
||||
setEditingUserId(user.id);
|
||||
setFormData({
|
||||
username: user.username,
|
||||
password: '',
|
||||
email: user.email || '',
|
||||
confirmPassword: '',
|
||||
role: user.role,
|
||||
group_ids: user.groups?.map(g => g.id) || [],
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
setShowEditModal(false);
|
||||
setEditingUserId(null);
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
};
|
||||
|
||||
const toggleGroup = (groupId: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
group_ids: prev.group_ids.includes(groupId)
|
||||
? prev.group_ids.filter(id => id !== groupId)
|
||||
: [...prev.group_ids, groupId],
|
||||
}));
|
||||
};
|
||||
|
||||
if (!hasPermission('users:read')) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<div className="flex items-center gap-3 text-red-700 dark:text-red-400">
|
||||
<Shield className="w-5 h-5" />
|
||||
<p className="text-white">{t('users.noPermission')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/settings?tab=users')}
|
||||
className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
|
||||
title={t('users.backToSettings')}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<UsersIcon className="w-6 h-6 text-bambu-green" />
|
||||
{t('users.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-bambu-gray mt-1">
|
||||
{t('users.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCreateModal(true);
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('users.createUser')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-bambu-dark-tertiary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
{t('users.table.username')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
{t('users.table.groups')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
{t('users.table.status')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
{t('users.table.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-bambu-dark-tertiary">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-bambu-dark-tertiary/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
|
||||
{user.username}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.is_admin && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300">
|
||||
{t('users.admin')}
|
||||
</span>
|
||||
)}
|
||||
{user.groups?.map(group => (
|
||||
<span
|
||||
key={group.id}
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
group.name === 'Administrators'
|
||||
? 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300'
|
||||
: group.name === 'Operators'
|
||||
? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'
|
||||
: group.name === 'Viewers'
|
||||
? 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300'
|
||||
: 'bg-gray-500/20 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
))}
|
||||
{(!user.groups || user.groups.length === 0) && !user.is_admin && (
|
||||
<span className="text-bambu-gray">{t('users.noGroups')}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
user.is_active
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{user.is_active ? t('users.active') : t('users.inactive')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => startEdit(user)}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
{t('users.edit')}
|
||||
</Button>
|
||||
{user.id !== currentUser?.id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('users.delete')}
|
||||
</Button>
|
||||
)}
|
||||
{advancedAuthStatus?.advanced_auth_enabled && user.email && user.id !== currentUser?.id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => resetPasswordMutation.mutate(user.id)}
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t('users.form.resetPassword') || 'Reset Password'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create User Modal */}
|
||||
{showCreateModal && !advancedAuthStatus?.advanced_auth_enabled && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setBasicCreateTab('local');
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className="w-full max-w-md"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">{t('users.modal.createUser')}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setBasicCreateTab('local');
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
}}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{ldapStatus?.ldap_enabled && (
|
||||
<div
|
||||
className="mb-4 flex items-center gap-1 p-1 bg-bambu-dark-secondary rounded-lg"
|
||||
role="tablist"
|
||||
aria-label={t('users.modal.tabsAriaLabel')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={basicCreateTab === 'local'}
|
||||
onClick={() => setBasicCreateTab('local')}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
basicCreateTab === 'local'
|
||||
? 'bg-bambu-green/15 text-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t('users.modal.localTab')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={basicCreateTab === 'ldap'}
|
||||
onClick={() => setBasicCreateTab('ldap')}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
basicCreateTab === 'ldap'
|
||||
? 'bg-bambu-green/15 text-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t('users.modal.ldapTab')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basicCreateTab === 'ldap' && ldapStatus?.ldap_enabled ? (
|
||||
<>
|
||||
<LdapUserPicker
|
||||
onSuccess={(user) => {
|
||||
setShowCreateModal(false);
|
||||
setBasicCreateTab('local');
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
showToast(t('users.toast.ldapProvisioned', { username: user.username }));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setBasicCreateTab('local');
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
}}
|
||||
>
|
||||
{t('users.modal.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
{t('users.form.username')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={t('users.form.usernamePlaceholder')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
{t('users.form.password')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={t('users.form.passwordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
{t('users.form.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
|
||||
formData.confirmPassword && formData.password !== formData.confirmPassword
|
||||
? 'border-red-500'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
placeholder={t('users.form.confirmPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||
<p className="text-red-700 dark:text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
{t('users.form.groups')}
|
||||
</label>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
|
||||
{groups.map(group => (
|
||||
<label
|
||||
key={group.id}
|
||||
className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.group_ids.includes(group.id)}
|
||||
onChange={() => toggleGroup(group.id)}
|
||||
className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
|
||||
/>
|
||||
<span className="text-sm text-white">{group.name}</span>
|
||||
{group.is_system && (
|
||||
<span className="text-xs text-yellow-700 dark:text-yellow-400">({t('users.system')})</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
}}
|
||||
>
|
||||
{t('users.modal.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreateButtonDisabled}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('users.modal.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('users.modal.createUser')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create User Modal - Advanced Authentication */}
|
||||
{showCreateModal && advancedAuthStatus?.advanced_auth_enabled && (
|
||||
<CreateUserAdvancedAuthModal
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
groups={groups}
|
||||
onClose={() => {
|
||||
setShowCreateModal(false);
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
}}
|
||||
onCreate={handleCreate}
|
||||
isCreating={createMutation.isPending}
|
||||
isCreateButtonDisabled={isCreateButtonDisabled}
|
||||
ldapEnabled={ldapStatus?.ldap_enabled}
|
||||
onLdapProvisioned={(user) => {
|
||||
setShowCreateModal(false);
|
||||
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
showToast(t('users.toast.ldapProvisioned', { username: user.username }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit User Modal */}
|
||||
{showEditModal && editingUserId !== null && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
|
||||
onClick={closeEditModal}
|
||||
>
|
||||
<Card
|
||||
className="w-full max-w-md"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Edit2 className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">{t('users.modal.editUser')}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={closeEditModal}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
{t('users.form.username')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={t('users.form.usernamePlaceholder')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
{t('users.form.email') || 'Email'} <span className="text-bambu-gray font-normal">({t('users.form.optional') || 'optional'})</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
{t('users.form.password')} <span className="text-bambu-gray font-normal">({t('users.form.leaveBlankToKeep')})</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value, confirmPassword: '' })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={t('users.form.newPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
{formData.password && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
{t('users.form.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
|
||||
formData.confirmPassword && formData.password !== formData.confirmPassword
|
||||
? 'border-red-500'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
placeholder={t('users.form.confirmNewPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||
<p className="text-red-700 dark:text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
{t('users.form.groups')}
|
||||
</label>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
|
||||
{groups.map(group => (
|
||||
<label
|
||||
key={group.id}
|
||||
className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.group_ids.includes(group.id)}
|
||||
onChange={() => toggleGroup(group.id)}
|
||||
className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
|
||||
/>
|
||||
<span className="text-sm text-white">{group.name}</span>
|
||||
{group.is_system && (
|
||||
<span className="text-xs text-yellow-700 dark:text-yellow-400">({t('users.system')})</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={closeEditModal}
|
||||
>
|
||||
{t('users.modal.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleUpdate(editingUserId)}
|
||||
disabled={updateMutation.isPending || !formData.username || !!(formData.password && (formData.password !== formData.confirmPassword || formData.password.length < 6))}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('users.modal.saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
{t('users.modal.saveChanges')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteUserId !== null && (
|
||||
<ConfirmModal
|
||||
title={t('users.deleteModal.title')}
|
||||
message={t('users.deleteModal.message')}
|
||||
confirmText={t('users.deleteModal.confirm')}
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
deleteMutation.mutate(deleteUserId);
|
||||
setDeleteUserId(null);
|
||||
}}
|
||||
onCancel={() => setDeleteUserId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+9
-5
@@ -1,5 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Typecheck and lint run in parallel (both are read-only and independent),
|
||||
# Typecheck, lint and knip run in parallel (all read-only and independent),
|
||||
# then the tests. Exits non-zero if any step fails, so a type or lint error
|
||||
# can't scroll past unnoticed behind a green test run.
|
||||
|
||||
@@ -9,21 +9,25 @@ npm run typecheck > /tmp/bambuddy-typecheck.$$ 2>&1 &
|
||||
typecheck_pid=$!
|
||||
npm run lint > /tmp/bambuddy-lint.$$ 2>&1 &
|
||||
lint_pid=$!
|
||||
npm run check:knip > /tmp/bambuddy-knip.$$ 2>&1 &
|
||||
knip_pid=$!
|
||||
|
||||
wait $typecheck_pid
|
||||
typecheck_rc=$?
|
||||
wait $lint_pid
|
||||
lint_rc=$?
|
||||
wait $knip_pid
|
||||
knip_rc=$?
|
||||
|
||||
cat /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$
|
||||
rm -f /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$
|
||||
cat /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$ /tmp/bambuddy-knip.$$
|
||||
rm -f /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$ /tmp/bambuddy-knip.$$
|
||||
|
||||
npm run test:run
|
||||
test_rc=$?
|
||||
|
||||
cd ..
|
||||
|
||||
if [ $typecheck_rc -ne 0 ] || [ $lint_rc -ne 0 ] || [ $test_rc -ne 0 ]; then
|
||||
echo "FAILED: typecheck=$typecheck_rc lint=$lint_rc tests=$test_rc"
|
||||
if [ $typecheck_rc -ne 0 ] || [ $lint_rc -ne 0 ] || [ $knip_rc -ne 0 ] || [ $test_rc -ne 0 ]; then
|
||||
echo "FAILED: typecheck=$typecheck_rc lint=$lint_rc knip=$knip_rc tests=$test_rc"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user