Phase 1 (Foundation): - Project restructure (presenton-main → backend/ + frontend/) - Database schema (8 new models, Alembic config, seed script) - Auth (Azure AD SSO + dev bypass, JWT sessions, AuthMiddleware) - RBAC (access_service, rbac_middleware, admin routers) - Audit logging (fire-and-forget, AuditMiddleware, admin router) - i18n (react-i18next with 5 namespace files) Phase 2 (Admin Panel & Client Management): - Admin panel shell (sidebar layout, role guard, 12 pages) - Redux admin slice with 18 async thunks - User management (role changes, deactivation) - Client management (CRUD, brand config, team management) - Brand config editor (colors, fonts, logos, voice rules) - Master deck upload & parser (PPTX → HTML → React pipeline) - Audit log viewer with filters and CSV/JSON export Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
290 lines
No EOL
9.6 KiB
Python
290 lines
No EOL
9.6 KiB
Python
import os
|
|
import uuid
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional
|
|
from fastapi import APIRouter, HTTPException, File, UploadFile
|
|
from pydantic import BaseModel
|
|
from utils.asset_directory_utils import get_app_data_directory_env
|
|
import uuid
|
|
|
|
try:
|
|
from fontTools.ttLib import TTFont
|
|
from fontTools.ttLib.tables._n_a_m_e import table__n_a_m_e
|
|
FONTTOOLS_AVAILABLE = True
|
|
except ImportError:
|
|
FONTTOOLS_AVAILABLE = False
|
|
|
|
FONTS_ROUTER = APIRouter(prefix="/fonts", tags=["fonts"])
|
|
|
|
# Supported font file extensions
|
|
SUPPORTED_FONT_EXTENSIONS = {
|
|
'.ttf': 'font/ttf',
|
|
'.otf': 'font/otf',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
'.eot': 'application/vnd.ms-fontobject'
|
|
}
|
|
|
|
class FontUploadResponse(BaseModel):
|
|
success: bool
|
|
font_name: str
|
|
font_url: str
|
|
font_path: str
|
|
message: Optional[str] = None
|
|
|
|
class FontListResponse(BaseModel):
|
|
success: bool
|
|
fonts: List[dict]
|
|
message: Optional[str] = None
|
|
|
|
|
|
def get_fonts_directory() -> str:
|
|
"""Get the fonts directory path, create if it doesn't exist"""
|
|
app_data_dir = get_app_data_directory_env() or "/tmp/presenton"
|
|
fonts_dir = os.path.join(app_data_dir, "fonts")
|
|
os.makedirs(fonts_dir, exist_ok=True)
|
|
return fonts_dir
|
|
|
|
|
|
def is_valid_font_file(file: UploadFile) -> bool:
|
|
"""Validate font file by extension and MIME type"""
|
|
if not file.filename:
|
|
return False
|
|
|
|
file_ext = os.path.splitext(file.filename)[1].lower()
|
|
if file_ext not in SUPPORTED_FONT_EXTENSIONS:
|
|
return False
|
|
|
|
# Check MIME type
|
|
content_type = file.content_type or ""
|
|
valid_mime_types = [
|
|
"font/ttf", "font/otf", "font/woff", "font/woff2",
|
|
"application/font-ttf", "application/font-otf",
|
|
"application/font-woff", "application/font-woff2",
|
|
"application/x-font-ttf", "application/x-font-otf",
|
|
"font/truetype", "font/opentype"
|
|
]
|
|
|
|
return content_type in valid_mime_types
|
|
|
|
|
|
def extract_font_name_from_file(file_path: str) -> str:
|
|
"""Extract the actual font family name from font file metadata"""
|
|
if not FONTTOOLS_AVAILABLE:
|
|
# Fallback to filename parsing if fonttools not available
|
|
filename = os.path.basename(file_path)
|
|
base_name = os.path.splitext(filename)[0]
|
|
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
|
|
# Remove UUID part
|
|
parts = filename.split('_')
|
|
if len(parts) > 1:
|
|
return '_'.join(parts[:-1])
|
|
return base_name
|
|
|
|
try:
|
|
font = TTFont(file_path)
|
|
|
|
# Try to get font family name from name table
|
|
if 'name' in font:
|
|
name_table = font['name']
|
|
|
|
# Preferred order: Family name (ID 1), then Full name (ID 4), then PostScript name (ID 6)
|
|
for name_id in [1, 4, 6]:
|
|
for record in name_table.names:
|
|
if record.nameID == name_id:
|
|
# Prefer English names
|
|
if record.langID == 0x409 or record.langID == 0: # English
|
|
font_name = record.toUnicode().strip()
|
|
if font_name:
|
|
font.close()
|
|
return font_name
|
|
|
|
# If no English name found, use any available family name
|
|
for record in name_table.names:
|
|
if record.nameID == 1: # Family name
|
|
font_name = record.toUnicode().strip()
|
|
if font_name:
|
|
font.close()
|
|
return font_name
|
|
|
|
font.close()
|
|
except Exception as e:
|
|
# If font parsing fails, fallback to filename
|
|
print(f"Error reading font metadata from {file_path}: {e}")
|
|
|
|
# Fallback to filename parsing
|
|
filename = os.path.basename(file_path)
|
|
base_name = os.path.splitext(filename)[0]
|
|
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
|
|
# Remove UUID part
|
|
parts = filename.split('_')
|
|
if len(parts) > 1:
|
|
return '_'.join(parts[:-1])
|
|
return base_name
|
|
|
|
|
|
@FONTS_ROUTER.post("/upload", response_model=FontUploadResponse)
|
|
async def upload_font(
|
|
font_file: UploadFile = File(..., description="Font file to upload (.ttf, .otf, .woff, .woff2, .eot)")
|
|
):
|
|
"""
|
|
Upload a font file and save it to the fonts directory.
|
|
|
|
Args:
|
|
font_file: Uploaded font file
|
|
|
|
Returns:
|
|
FontUploadResponse with font details and accessible URL
|
|
|
|
Raises:
|
|
HTTPException: If file validation fails or upload error occurs
|
|
"""
|
|
try:
|
|
# Validate file
|
|
if not font_file.filename:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="No file name provided"
|
|
)
|
|
|
|
if not is_valid_font_file(font_file):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}"
|
|
)
|
|
|
|
# Generate unique filename to avoid conflicts
|
|
file_ext = os.path.splitext(font_file.filename)[1].lower()
|
|
base_name = os.path.splitext(font_file.filename)[0]
|
|
unique_filename = f"{base_name}_{str(uuid.uuid4())[:8]}{file_ext}"
|
|
|
|
# Get fonts directory
|
|
fonts_dir = get_fonts_directory()
|
|
font_path = os.path.join(fonts_dir, unique_filename)
|
|
|
|
# Save the uploaded file
|
|
with open(font_path, "wb") as buffer:
|
|
shutil.copyfileobj(font_file.file, buffer)
|
|
|
|
# Generate accessible URL
|
|
font_url = f"/app_data/fonts/{unique_filename}"
|
|
|
|
return FontUploadResponse(
|
|
success=True,
|
|
font_name=base_name,
|
|
font_url=font_url,
|
|
font_path=font_path,
|
|
message=f"Font '{base_name}' uploaded successfully"
|
|
)
|
|
|
|
except HTTPException:
|
|
# Re-raise HTTP exceptions as-is
|
|
raise
|
|
except Exception as e:
|
|
print(f"Error uploading font: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Error uploading font: {str(e)}"
|
|
)
|
|
|
|
|
|
@FONTS_ROUTER.get("/list", response_model=FontListResponse)
|
|
async def list_fonts():
|
|
"""
|
|
List all uploaded fonts with their accessible URLs.
|
|
|
|
Returns:
|
|
FontListResponse with list of available fonts
|
|
"""
|
|
try:
|
|
fonts_dir = get_fonts_directory()
|
|
fonts = []
|
|
|
|
# Get all font files in the directory
|
|
if os.path.exists(fonts_dir):
|
|
for filename in os.listdir(fonts_dir):
|
|
file_path = os.path.join(fonts_dir, filename)
|
|
|
|
if os.path.isfile(file_path):
|
|
file_ext = os.path.splitext(filename)[1].lower()
|
|
|
|
if file_ext in SUPPORTED_FONT_EXTENSIONS:
|
|
# Get the real font name from file metadata
|
|
font_name = extract_font_name_from_file(file_path)
|
|
|
|
# Extract original name (remove UUID suffix for display)
|
|
base_name = filename
|
|
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
|
|
# Remove UUID part for original_name display
|
|
parts = filename.split('_')
|
|
if len(parts) > 1:
|
|
base_name = '_'.join(parts[:-1]) + file_ext
|
|
|
|
fonts.append({
|
|
"filename": filename,
|
|
"font_name": font_name, # Real font family name from metadata
|
|
"original_name": base_name,
|
|
"font_url": f"/app_data/fonts/{filename}",
|
|
"font_type": SUPPORTED_FONT_EXTENSIONS.get(file_ext, 'unknown'),
|
|
"file_size": os.path.getsize(file_path)
|
|
})
|
|
|
|
return FontListResponse(
|
|
success=True,
|
|
fonts=fonts,
|
|
message=f"Found {len(fonts)} font files"
|
|
)
|
|
|
|
except Exception as e:
|
|
print(f"Error listing fonts: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Error listing fonts: {str(e)}"
|
|
)
|
|
|
|
|
|
@FONTS_ROUTER.delete("/delete/{filename}")
|
|
async def delete_font(filename: str):
|
|
"""
|
|
Delete a font file from the fonts directory.
|
|
|
|
Args:
|
|
filename: Name of the font file to delete
|
|
|
|
Returns:
|
|
Success message
|
|
"""
|
|
try:
|
|
fonts_dir = get_fonts_directory()
|
|
font_path = os.path.join(fonts_dir, filename)
|
|
|
|
if not os.path.exists(font_path):
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Font file '{filename}' not found"
|
|
)
|
|
|
|
# Validate it's actually a font file before deleting
|
|
file_ext = os.path.splitext(filename.lower())[1]
|
|
if file_ext not in SUPPORTED_FONT_EXTENSIONS:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="File is not a recognized font format"
|
|
)
|
|
|
|
os.remove(font_path)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Font '{filename}' deleted successfully"
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
print(f"Error deleting font: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Error deleting font: {str(e)}"
|
|
) |