Files
LEDMatrix/web_interface/blueprints/api_v3/fonts.py
T
ChuckandClaude Opus 5 bdb9a94033 refactor(api-v3): split the 10,469-line blueprint into a package (#553)
* refactor(api-v3): split the 10,469-line blueprint into a package

web_interface/blueprints/api_v3.py held 111 routes, 56 helpers and 181
functions in one module -- 9% of the core by line count and three times the
next largest file. It becomes a package of nine route modules grouped by path
segment, plus __init__.py for the shared imports, constants, Blueprint and
helpers.

Every route module decorates the SAME api_v3 Blueprint object, so endpoint
names stay api_v3.<function>, the URL map is unchanged and app.py is untouched.
Verified: 111 routes before, 111 after, byte-identical rules, endpoints and
methods, and every endpoint still on the one blueprint.

  plugins   3,867   config    1,178   starlark  692   system  619
  fonts       452   misc        398   wifi      361   display 326   backup 212
  __init__  1,787 (imports, constants, Blueprint, 56 helpers)

Two things the URL-map check could not catch, both found by running the suite:

1. PROJECT_ROOT = Path(__file__).parent.parent.parent. Moving the code one
   directory deeper made that resolve to web_interface/ instead of the project
   root. Nothing failed at import; it surfaced as ~110 tests failing with 404s
   and "installation script not found", because every path built from it was
   one level too shallow. Now parents[3], and test_api_v3_url_map.py asserts
   PROJECT_ROOT/run.py exists so the next move cannot repeat it.

2. Module-attribute patching. Tests do
   monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", ...) and a route
   module that binds such a name by value never sees the patch. The shared code
   therefore stays in __init__.py rather than moving to a _common submodule --
   it has to live on the module the tests patch -- and the eleven names tests
   patch are read back through the package (_pkg.X) instead of bound by value.
   Those eleven were found by AST-scanning every setattr in the test tree, not
   by guessing; "time" is among them, used to drive a fake clock through the
   second-resolution credential-backup filenames.

Test changes are confined to what genuinely moved: patch targets that now name
the owning route module, imports of helpers, and six tests that scan the api_v3
source as a file and now read the package directory.

Full suite: 4,278 passed, 68 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(api-v3): address CodeRabbit findings from the blueprint-split review

Fixes to the api_v3 package split (PR #553), one per finding verified
against the actual code:

- __init__.py: _redact_credentials only blanked scalar values under a
  credential-named key; a bare list of secrets under such a key (e.g.
  tokens: ["a", "b"]) passed through untouched, since the list branch
  recursed with no memory that its key looked like a credential. Nested
  dicts still walk normally (a documented, tested behaviour -- a container
  like secrets: {api_key: ..., note: ...} is a section name, not a value to
  blank outright), but any value reached under a credential-shaped key is
  now actually blanked.

- __init__.py: the OAuth helper script's raw stderr/stdout went to
  logger.error unredacted (CWE-532) right next to a comment claiming this
  was deliberate; the HTTP response already used the existing redact_text
  helper. Routed the log line through the same helper.

- __init__.py / starlark.py: the standalone Starlark manifest fallback
  (used when the plugin instance isn't loaded) read-modified-wrote
  manifest.json with no lock, unlike StarlarkAppsPlugin._update_manifest_safe
  (plugin-repos/starlark-apps/manager.py), which already holds an flock for
  the same file when the plugin is loaded. Added _starlark_manifest_lock,
  mirroring that pattern, and wrapped every standalone read-modify-write
  call site in it. The app-config update route also wrote config.json and
  the manifest as two separate, non-transactional writes (a second,
  distinct finding at the same call site); config.json is now rolled back
  if the manifest write that follows it fails.

- backup.py: restore options used bare bool() on values from the request,
  so {"restore_secrets": "false"} restored secrets anyway (bool("false") is
  True). Switched to the existing _coerce_to_bool helper already used for
  this exact purpose elsewhere in the package.

- config.py: an automated import-rewrite mangled four user-facing
  validation strings and their neighbouring comments -- "Invalid start
  time" had become "Invalid start _pkg.time" (and likewise for "end time")
  in both the schedule and dim-schedule per-day validation paths.

- display.py: `import _pkg.time as time_module` -- _pkg is a local alias
  for the package, not a real importable module, so this raised
  ModuleNotFoundError whenever a caller restarted an already-running
  display service via /display/on-demand/start, after the on-demand
  request was already written to cache. Fixed to `import time`. Audited
  the rest of the package for the same `_pkg.<module>` import mistake;
  every other `_pkg.` reference is a legitimate attribute read-through
  (`_pkg.time.time()`, `_pkg._get_starlark_plugin()`, ...), not a broken
  import statement.

- fonts.py: validate_file_upload's max_size_mb parameter is silently
  unused by that helper (it only checks filename/extension) -- the font
  upload route saved arbitrarily large files as a result. Added the same
  seek-and-check pattern already used for the sibling .star upload.

- wifi.py: two ad hoc, inconsistent bool coercions. POST
  /wifi/ap/auto-enable used bare bool(), so a JSON string "false" enabled
  it. POST /wifi/radio's enabled/force parsing recognized real bool and
  some strings but not int 1/0 (1 is True is False in Python). Factored one
  small _parse_bool_ish helper local to this file and used it at all three
  sites.

Not changed: the "unknown/misspelled restore option keys default to True"
half of the backup.py finding -- the file's own comment documents that a
missing key deliberately means "restore everything," matching the
already-existing JSON-parse-failure guard a few lines above it; only the
bool-coercion defect was a real bug.

Added or extended regression tests for every fix, following each area's
existing test conventions. Full suite: 4328 passed, 62 skipped, 2 failed
on both this branch and origin/main (missing tzdata package breaks two
timezone-alias tests in test_onboarding_checklist.py, unrelated to this
change) -- no new failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3bPMESe2TfrGvbs1ef9c5

* fix(api-v3): reject unknown restore option keys

CodeRabbit's review of the blueprint split (#553) asked that
POST /backup/restore reject option keys outside RestoreOptions'
known set. The follow-up commit fixed the bool("false")-is-True
bug with _coerce_to_bool but never added the key check: a typo'd
or renamed key (e.g. "restoreSecrets") is silently ignored by
opts_dict.get(key, True), so the flag stays at its True default
and secrets get restored despite the caller's request saying
otherwise -- with no indication anything was wrong.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vmcwf5vMgYqdt8bJTZtiwb

* fix(api-v3): address CodeRabbit findings on the blueprint split

- _redact_credentials: blank scalar descendants of objects reached
  through a credential-owned list (e.g. tokens: [{"value": "secret"}])
  regardless of field name -- the existing name-based walk only
  protected direct dict values under a credential key, not list items.
- wifi.py: reject enabled/force/auto_enable_ap_mode values
  _parse_bool_ish can't recognize (400) instead of silently treating
  them as False, which could disable Wi-Fi or the radio itself.
- Starlark manifest locking: lock a stable manifest.json.lock sidecar
  instead of manifest.json itself, in both the standalone route path
  (_starlark_manifest_lock) and the plugin path
  (StarlarkAppsPlugin._save_manifest / _update_manifest_safe).
  manifest.json is replaced by an atomic rename on every write, which
  swaps in a fresh inode; a lock held on the old inode does not
  exclude a second locker that opens the path afresh right after the
  rename and gets the new inode, so two writers could race despite
  each holding "a lock". A sidecar that no write ever touches always
  resolves to the same inode for every locker.

Skipped as stale: the "serialize the complete manifest
read-modify-write" finding at api_v3/__init__.py -- every standalone
handler that calls _write_starlark_manifest is already wrapped in
_starlark_manifest_lock() on this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(api-v3): re-check reconciliation findings by the reconciler's own rules

Both CodeRabbit findings on the merge commit, verified against the code first.

Major, plugins.py: the stale-findings filter derived its own notion of "in
config" and "on disk", and both were looser than the reconciliation module's.
set(load_config()) also contains system keys, the secrets-file keys load_config()
merges in, and non-dict values; and any directory holding a manifest.json
counted as installed even when that manifest does not parse. Either looseness
clears a finding that is still true -- and a secrets key read as a plugin is the
precise bug the filter exists to stop reporting, so reintroducing that asymmetry
while re-checking was the wrong way round.

The two extractions now live in state_reconciliation.py as config_plugin_ids()
and disk_plugin_ids(), with ignored_config_keys() and secrets_top_level_keys()
alongside. _get_config_state() and _get_disk_state() use them too, so there is
one definition rather than two that can drift. _get_disk_state() re-reads each
manifest for version/name after taking membership from the shared extractor;
that costs one extra small read per plugin on a path that runs once per boot.

Minor, the new test: the fixture assigned api_v3.config_manager and
api_v3.plugin_manager directly. Those live on a module-level blueprint
singleton, so the mocks leaked into every later test that imports api_v3 --
pointing at a tmp_path already deleted. Both now go through monkeypatch.setattr,
which restores them. This is the same pollution class that made an earlier test
in this session break seven unrelated ones, so it is worth getting right.

Five cases added for the parity itself: a secrets key, a system key and a
non-dict value must not clear an "installed but missing from config" finding,
and neither an unparseable manifest nor a .standalone-backup- directory may
count as installed. All five fail against the looser version.

Linux CI on the preceding commit: Core unit tests, plugin harness, CodeQL and
CodeRabbit all pass. Codacy reads action_required on every commit of this
branch including the first, so it is pre-existing and not from this work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:07:38 -04:00

468 lines
20 KiB
Python

"""Font catalogue, upload, preview and deletion.
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
PROJECT_ROOT, Path, Response, SYSTEM_FONTS, api_v3, describe_exception,
jsonify, logger, os, re, request, validate_file_upload,
)
@api_v3.route('/fonts/catalog', methods=['GET'])
def get_fonts_catalog():
"""Get fonts catalog"""
try:
# Check cache first (5 minute TTL)
try:
from web_interface.cache import get_cached, set_cached
cached_result = get_cached('fonts_catalog', ttl_seconds=300)
if cached_result is not None:
return jsonify({'status': 'success', 'data': {'catalog': cached_result}})
except ImportError:
# Cache not available, continue without caching
get_cached = None
set_cached = None
# Try to import freetype, but continue without it if unavailable
try:
import freetype
freetype_available = True
except ImportError:
freetype_available = False
# Scan assets/fonts directory for actual font files
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
catalog = {}
if fonts_dir.exists() and fonts_dir.is_dir():
for filename in os.listdir(fonts_dir):
if filename.endswith(('.ttf', '.otf', '.bdf')):
filepath = fonts_dir / filename
# Generate family name from filename (without extension)
family_name = os.path.splitext(filename)[0]
# Try to get font metadata using freetype (for TTF/OTF)
metadata = {}
if filename.endswith(('.ttf', '.otf')) and freetype_available:
try:
face = freetype.Face(str(filepath))
if face.valid:
# Get font family name from font file
family_name_from_font = face.family_name.decode('utf-8') if face.family_name else family_name
metadata = {
'family': family_name_from_font,
'style': face.style_name.decode('utf-8') if face.style_name else 'Regular',
'num_glyphs': face.num_glyphs,
'units_per_em': face.units_per_EM
}
# Use font's family name if available
if family_name_from_font:
family_name = family_name_from_font
except Exception:
# If freetype fails, use filename-based name
pass
# Store relative path from project root
relative_path = str(filepath.relative_to(PROJECT_ROOT))
font_type = 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf'
# Generate human-readable display name from family_name
display_name = family_name.replace('-', ' ').replace('_', ' ')
# Add space before capital letters for camelCase names
display_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', display_name)
# Add space before numbers that follow letters
display_name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', display_name)
# Clean up multiple spaces
display_name = ' '.join(display_name.split())
# Use filename (without extension) as unique key to avoid collisions
# when multiple files share the same family_name from font metadata
catalog_key = os.path.splitext(filename)[0]
# Check if this is a system font (cannot be deleted)
is_system = catalog_key.lower() in SYSTEM_FONTS
catalog[catalog_key] = {
'filename': filename,
'family_name': family_name,
'display_name': display_name,
'path': relative_path,
'type': font_type,
'is_system': is_system,
'metadata': metadata if metadata else None
}
# Cache the result (5 minute TTL) if available
if set_cached:
try:
set_cached('fonts_catalog', catalog, ttl_seconds=300)
except Exception:
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
return jsonify({'status': 'success', 'data': {'catalog': catalog}})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)}), 500
@api_v3.route('/fonts/tokens', methods=['GET'])
def get_font_tokens():
"""Get font size tokens"""
try:
# This would integrate with the actual font system
# For now, return sample tokens
tokens = {
'xs': 6,
'sm': 8,
'md': 10,
'lg': 12,
'xl': 14,
'xxl': 16
}
return jsonify({'status': 'success', 'data': {'tokens': tokens}})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides', methods=['GET'])
def get_fonts_overrides():
"""Get font overrides"""
try:
# This would integrate with the actual font system
# For now, return empty overrides
overrides = {}
return jsonify({'status': 'success', 'data': {'overrides': overrides}})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides', methods=['POST'])
def save_fonts_overrides():
"""Save font overrides"""
try:
data = request.get_json(silent=True)
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# This would integrate with the actual font system
return jsonify({'status': 'success', 'message': 'Font overrides saved'})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides/<element_key>', methods=['DELETE'])
def delete_font_override(element_key):
"""Delete font override"""
try:
# This would integrate with the actual font system
return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/upload', methods=['POST'])
def upload_font():
"""Upload font file"""
try:
if 'font_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No font file provided'}), 400
font_file = request.files['font_file']
if font_file.filename == '':
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
# Validate filename. validate_file_upload takes max_size_mb but only
# checks the filename/extension with it -- it never looks at the
# actual upload size, so the size limit below is enforced separately
# before the file is saved (same pattern as the .star upload above).
MAX_FONT_SIZE_MB = 10
is_valid, error_msg = validate_file_upload(
font_file.filename,
max_size_mb=MAX_FONT_SIZE_MB,
allowed_extensions=['.ttf', '.otf', '.bdf']
)
if not is_valid:
return jsonify({'status': 'error', 'message': error_msg}), 400
# Check file size (stated limit is MAX_FONT_SIZE_MB)
font_file.seek(0, 2) # Seek to end
file_size = font_file.tell()
font_file.seek(0) # Reset to beginning
max_font_size_bytes = MAX_FONT_SIZE_MB * 1024 * 1024
if file_size > max_font_size_bytes:
return jsonify({
'status': 'error',
'message': f'File too large (max {MAX_FONT_SIZE_MB}MB, got {file_size / 1024 / 1024:.1f}MB)'
}), 400
font_family = request.form.get('font_family', '')
if not font_family:
return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400
# Validate font family name
if not font_family.replace('_', '').replace('-', '').isalnum():
return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400
# Save the font file to assets/fonts directory
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
# Create filename from family name
original_ext = os.path.splitext(font_file.filename)[1].lower()
safe_filename = f"{font_family}{original_ext}"
filepath = fonts_dir / safe_filename
# Check if file already exists
if filepath.exists():
return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400
# Save the file
font_file.save(str(filepath))
# Clear font catalog cache
try:
from web_interface.cache import delete_cached
delete_cached('fonts_catalog')
except ImportError as e:
logger.warning("[FontUpload] Cache module not available: %s", e)
except Exception:
logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True)
return jsonify({
'status': 'success',
'message': f'Font {font_family} uploaded successfully',
'font_family': font_family,
'filename': safe_filename,
'path': f'assets/fonts/{safe_filename}'
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/preview', methods=['GET'])
def get_font_preview() -> tuple[Response, int] | Response:
"""Generate a preview image of text rendered with a specific font"""
try:
from PIL import Image, ImageDraw, ImageFont
import io
import base64
# Limits to prevent DoS via large image generation on constrained devices
MAX_TEXT_CHARS = 100
MAX_TEXT_LINES = 3
MAX_DIM = 1024 # Max width or height in pixels
MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700)
font_filename = request.args.get('font', '')
text = request.args.get('text', 'Sample Text 123')
bg_color = request.args.get('bg', '000000')
fg_color = request.args.get('fg', 'ffffff')
# Validate text length and line count early
if len(text) > MAX_TEXT_CHARS:
return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400
if text.count('\n') >= MAX_TEXT_LINES:
return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400
# Safe integer parsing for size
try:
size = int(request.args.get('size', 12))
except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
if not font_filename:
return jsonify({'status': 'error', 'message': 'Font filename required'}), 400
# Validate size
if size < 4 or size > 72:
return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400
# Security: Validate font_filename to prevent path traversal
# Only allow alphanumeric, hyphen, underscore, and dot (for extension)
safe_name = Path(font_filename).name # Strip any directory components
if safe_name != font_filename or '..' in font_filename:
return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400
# Validate extension
allowed_extensions = ['.ttf', '.otf', '.bdf']
has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions)
name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name
# Find the font file
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
if not fonts_dir.exists():
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
font_path = fonts_dir / safe_name
if not font_path.exists() and not has_valid_ext:
# Try finding by family name (without extension)
for ext in allowed_extensions:
potential_path = fonts_dir / f"{name_without_ext}{ext}"
if potential_path.exists():
font_path = potential_path
break
# Final security check: ensure path is within fonts_dir
try:
font_path.resolve().relative_to(fonts_dir.resolve())
except ValueError:
return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400
if not font_path.exists():
return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404
# Parse colors
try:
bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4))
fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4))
except (ValueError, IndexError):
bg_rgb = (0, 0, 0)
fg_rgb = (255, 255, 255)
# Load font
font = None
if str(font_path).endswith('.bdf'):
# BDF fonts require complex per-glyph rendering via freetype
# Return explicit error rather than showing misleading preview with default font
return jsonify({
'status': 'error',
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
}), 400
else:
# TTF/OTF fonts
try:
font = ImageFont.truetype(str(font_path), size)
except (IOError, OSError) as e:
# IOError/OSError raised for invalid/corrupt font files
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
font = ImageFont.load_default()
# Calculate text size
temp_img = Image.new('RGB', (1, 1))
temp_draw = ImageDraw.Draw(temp_img)
bbox = temp_draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Create image with padding
padding = 10
img_width = max(text_width + padding * 2, 100)
img_height = max(text_height + padding * 2, 30)
# Validate resulting image size to prevent memory/CPU spikes
if img_width > MAX_DIM or img_height > MAX_DIM:
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
if img_width * img_height > MAX_PIXELS:
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
img = Image.new('RGB', (img_width, img_height), bg_rgb)
draw = ImageDraw.Draw(img)
# Center text
x = (img_width - text_width) // 2
y = (img_height - text_height) // 2
draw.text((x, y), text, font=font, fill=fg_rgb)
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return jsonify({
'status': 'success',
'data': {
'image': f'data:image/png;base64,{img_base64}',
'width': img_width,
'height': img_height
}
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
def delete_font(font_family: str) -> tuple[Response, int] | Response:
"""Delete a user-uploaded font file"""
try:
# Security: Validate font_family to prevent path traversal
# Reject if it contains path separators or ..
if '..' in font_family or '/' in font_family or '\\' in font_family:
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
# Only allow safe characters: alphanumeric, hyphen, underscore, dot
if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family):
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
# Check if this is a system font (uses module-level SYSTEM_FONTS frozenset)
if font_family.lower() in SYSTEM_FONTS:
return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403
# Find and delete the font file
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
# Ensure fonts directory exists
if not fonts_dir.exists() or not fonts_dir.is_dir():
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
deleted = False
deleted_filename = None
# Only try valid font extensions (no empty string to avoid matching directories)
for ext in ['.ttf', '.otf', '.bdf']:
potential_path = fonts_dir / f"{font_family}{ext}"
# Security: Verify path is within fonts_dir
try:
potential_path.resolve().relative_to(fonts_dir.resolve())
except ValueError:
continue # Path escapes fonts_dir, skip
if potential_path.exists() and potential_path.is_file():
potential_path.unlink()
deleted = True
deleted_filename = f"{font_family}{ext}"
break
if not deleted:
# Try case-insensitive match within fonts directory
font_family_lower = font_family.lower()
for filename in os.listdir(fonts_dir):
# Only consider files with valid font extensions
if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']):
continue
name_without_ext = os.path.splitext(filename)[0]
if name_without_ext.lower() == font_family_lower:
filepath = fonts_dir / filename
# Security: Verify path is within fonts_dir
try:
filepath.resolve().relative_to(fonts_dir.resolve())
except ValueError:
continue # Path escapes fonts_dir, skip
if filepath.is_file():
filepath.unlink()
deleted = True
deleted_filename = filename
break
if not deleted:
return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404
# Clear font catalog cache
try:
from web_interface.cache import delete_cached
delete_cached('fonts_catalog')
except ImportError as e:
logger.warning("[FontDelete] Cache module not available: %s", e)
except Exception:
logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True)
return jsonify({
'status': 'success',
'message': f'Font {deleted_filename} deleted successfully'
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500