feat: complete issue fixes and implement remaining features
## Critical Issues (CRIT-001~003) - All Fixed
- JWT secret key validation with pydantic field_validator
- Login audit logging for success/failure attempts
- Frontend API path prefix removal
## High Priority Issues (HIGH-001~008) - All Fixed
- Project soft delete using is_active flag
- Redis session token bytes handling
- Rate limiting with slowapi (5 req/min for login)
- Attachment API permission checks
- Kanban view with drag-and-drop
- Workload heatmap UI (WorkloadPage, WorkloadHeatmap)
- TaskDetailModal integrating Comments/Attachments
- UserSelect component for task assignment
## Medium Priority Issues (MED-001~012) - All Fixed
- MED-001~005: DB commits, N+1 queries, datetime, error format, blocker flag
- MED-006: Project health dashboard (HealthService, ProjectHealthPage)
- MED-007: Capacity update API (PUT /api/users/{id}/capacity)
- MED-008: Schedule triggers (cron parsing, deadline reminders)
- MED-009: Watermark feature (image/PDF watermarking)
- MED-010~012: useEffect deps, DOM operations, PDF export
## New Files
- backend/app/api/health/ - Project health API
- backend/app/services/health_service.py
- backend/app/services/trigger_scheduler.py
- backend/app/services/watermark_service.py
- backend/app/core/rate_limiter.py
- frontend/src/pages/ProjectHealthPage.tsx
- frontend/src/components/ProjectHealthCard.tsx
- frontend/src/components/KanbanBoard.tsx
- frontend/src/components/WorkloadHeatmap.tsx
## Tests
- 113 new tests passing (health: 32, users: 14, triggers: 35, watermark: 32)
## OpenSpec Archives
- add-project-health-dashboard
- add-capacity-update-api
- add-schedule-triggers
- add-watermark-feature
- add-rate-limiting
- enhance-frontend-ux
- add-resource-management-ui
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
327
backend/app/services/watermark_service.py
Normal file
327
backend/app/services/watermark_service.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Watermark Service for MED-009: Dynamic Watermark for Downloads
|
||||
|
||||
This service provides functions to add watermarks to image and PDF files
|
||||
containing user information for audit and tracking purposes.
|
||||
|
||||
Watermark content includes:
|
||||
- User name
|
||||
- Employee ID (or email as fallback)
|
||||
- Download timestamp
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import fitz # PyMuPDF
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WatermarkService:
|
||||
"""Service for adding watermarks to downloaded files."""
|
||||
|
||||
# Watermark configuration
|
||||
WATERMARK_OPACITY = 0.3 # 30% opacity for semi-transparency
|
||||
WATERMARK_ANGLE = -45 # Diagonal angle in degrees
|
||||
WATERMARK_FONT_SIZE = 24
|
||||
WATERMARK_COLOR = (128, 128, 128) # Gray color for watermark
|
||||
WATERMARK_SPACING = 200 # Spacing between repeated watermarks
|
||||
|
||||
@staticmethod
|
||||
def _format_watermark_text(
|
||||
user_name: str,
|
||||
employee_id: Optional[str] = None,
|
||||
download_time: Optional[datetime] = None
|
||||
) -> str:
|
||||
"""
|
||||
Format the watermark text with user information.
|
||||
|
||||
Args:
|
||||
user_name: Name of the user
|
||||
employee_id: Employee ID (工號) - uses 'N/A' if not provided
|
||||
download_time: Time of download (defaults to now)
|
||||
|
||||
Returns:
|
||||
Formatted watermark text
|
||||
"""
|
||||
if download_time is None:
|
||||
download_time = datetime.now()
|
||||
time_str = download_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
emp_id = employee_id if employee_id else "N/A"
|
||||
return f"{user_name} ({emp_id}) - {time_str}"
|
||||
|
||||
@staticmethod
|
||||
def _get_font(size: int = 24) -> ImageFont.FreeTypeFont:
|
||||
"""Get a font for the watermark. Falls back to default if custom font not available."""
|
||||
try:
|
||||
# Try to use a common system font (macOS)
|
||||
return ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
|
||||
except (OSError, IOError):
|
||||
try:
|
||||
# Try Linux font
|
||||
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
|
||||
except (OSError, IOError):
|
||||
try:
|
||||
# Try Windows font
|
||||
return ImageFont.truetype("C:/Windows/Fonts/arial.ttf", size)
|
||||
except (OSError, IOError):
|
||||
# Fall back to default bitmap font
|
||||
return ImageFont.load_default()
|
||||
|
||||
def add_image_watermark(
|
||||
self,
|
||||
image_bytes: bytes,
|
||||
user_name: str,
|
||||
employee_id: Optional[str] = None,
|
||||
download_time: Optional[datetime] = None
|
||||
) -> Tuple[bytes, str]:
|
||||
"""
|
||||
Add a semi-transparent diagonal watermark to an image.
|
||||
|
||||
Args:
|
||||
image_bytes: The original image as bytes
|
||||
user_name: Name of the user downloading the file
|
||||
employee_id: Employee ID of the user (工號)
|
||||
download_time: Time of download (defaults to now)
|
||||
|
||||
Returns:
|
||||
Tuple of (watermarked image bytes, output format)
|
||||
|
||||
Raises:
|
||||
Exception: If watermarking fails
|
||||
"""
|
||||
# Open the image
|
||||
original = Image.open(io.BytesIO(image_bytes))
|
||||
|
||||
# Convert to RGBA if necessary for transparency support
|
||||
if original.mode != 'RGBA':
|
||||
image = original.convert('RGBA')
|
||||
else:
|
||||
image = original.copy()
|
||||
|
||||
# Create a transparent overlay for the watermark
|
||||
watermark_layer = Image.new('RGBA', image.size, (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(watermark_layer)
|
||||
|
||||
# Get watermark text
|
||||
watermark_text = self._format_watermark_text(user_name, employee_id, download_time)
|
||||
|
||||
# Get font
|
||||
font = self._get_font(self.WATERMARK_FONT_SIZE)
|
||||
|
||||
# Calculate text size
|
||||
bbox = draw.textbbox((0, 0), watermark_text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Create a larger canvas for the rotated text pattern
|
||||
diagonal = int(math.sqrt(image.size[0]**2 + image.size[1]**2))
|
||||
pattern_size = diagonal * 2
|
||||
|
||||
# Create pattern layer
|
||||
pattern = Image.new('RGBA', (pattern_size, pattern_size), (255, 255, 255, 0))
|
||||
pattern_draw = ImageDraw.Draw(pattern)
|
||||
|
||||
# Draw repeated watermark text across the pattern
|
||||
opacity = int(255 * self.WATERMARK_OPACITY)
|
||||
watermark_color = (*self.WATERMARK_COLOR, opacity)
|
||||
|
||||
y = 0
|
||||
row = 0
|
||||
while y < pattern_size:
|
||||
x = -text_width if row % 2 else 0 # Offset alternate rows
|
||||
while x < pattern_size:
|
||||
pattern_draw.text((x, y), watermark_text, font=font, fill=watermark_color)
|
||||
x += text_width + self.WATERMARK_SPACING
|
||||
y += text_height + self.WATERMARK_SPACING
|
||||
row += 1
|
||||
|
||||
# Rotate the pattern
|
||||
rotated_pattern = pattern.rotate(
|
||||
self.WATERMARK_ANGLE,
|
||||
expand=False,
|
||||
center=(pattern_size // 2, pattern_size // 2)
|
||||
)
|
||||
|
||||
# Crop to original image size (centered)
|
||||
crop_x = (pattern_size - image.size[0]) // 2
|
||||
crop_y = (pattern_size - image.size[1]) // 2
|
||||
cropped_pattern = rotated_pattern.crop((
|
||||
crop_x, crop_y,
|
||||
crop_x + image.size[0],
|
||||
crop_y + image.size[1]
|
||||
))
|
||||
|
||||
# Composite the watermark onto the image
|
||||
watermarked = Image.alpha_composite(image, cropped_pattern)
|
||||
|
||||
# Determine output format
|
||||
original_format = original.format or 'PNG'
|
||||
if original_format.upper() == 'JPEG':
|
||||
# Convert back to RGB for JPEG (no alpha channel)
|
||||
watermarked = watermarked.convert('RGB')
|
||||
output_format = 'JPEG'
|
||||
else:
|
||||
output_format = 'PNG'
|
||||
|
||||
# Save to bytes
|
||||
output = io.BytesIO()
|
||||
watermarked.save(output, format=output_format, quality=95)
|
||||
output.seek(0)
|
||||
|
||||
logger.info(
|
||||
f"Image watermark applied successfully for user {user_name} "
|
||||
f"(employee_id: {employee_id})"
|
||||
)
|
||||
|
||||
return output.getvalue(), output_format.lower()
|
||||
|
||||
def add_pdf_watermark(
|
||||
self,
|
||||
pdf_bytes: bytes,
|
||||
user_name: str,
|
||||
employee_id: Optional[str] = None,
|
||||
download_time: Optional[datetime] = None
|
||||
) -> bytes:
|
||||
"""
|
||||
Add a semi-transparent diagonal watermark to a PDF using PyMuPDF.
|
||||
|
||||
Args:
|
||||
pdf_bytes: The original PDF as bytes
|
||||
user_name: Name of the user downloading the file
|
||||
employee_id: Employee ID of the user (工號)
|
||||
download_time: Time of download (defaults to now)
|
||||
|
||||
Returns:
|
||||
Watermarked PDF as bytes
|
||||
|
||||
Raises:
|
||||
Exception: If watermarking fails
|
||||
"""
|
||||
# Get watermark text
|
||||
watermark_text = self._format_watermark_text(user_name, employee_id, download_time)
|
||||
|
||||
# Open the PDF with PyMuPDF
|
||||
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||
page_count = len(doc)
|
||||
|
||||
# Process each page
|
||||
for page_num in range(page_count):
|
||||
page = doc[page_num]
|
||||
page_rect = page.rect
|
||||
page_width = page_rect.width
|
||||
page_height = page_rect.height
|
||||
|
||||
# Calculate text width for spacing estimation
|
||||
text_length = fitz.get_text_length(
|
||||
watermark_text,
|
||||
fontname="helv",
|
||||
fontsize=self.WATERMARK_FONT_SIZE
|
||||
)
|
||||
|
||||
# Calculate diagonal for watermark coverage
|
||||
diagonal = math.sqrt(page_width**2 + page_height**2)
|
||||
|
||||
# Set watermark color with opacity (gray with 30% opacity)
|
||||
color = (0.5, 0.5, 0.5) # Gray
|
||||
|
||||
# Calculate rotation angle in radians
|
||||
angle_rad = math.radians(self.WATERMARK_ANGLE)
|
||||
|
||||
# Draw watermark pattern using shape with proper rotation
|
||||
# We use insert_textbox with a morph transform for rotation
|
||||
spacing_x = text_length + self.WATERMARK_SPACING
|
||||
spacing_y = self.WATERMARK_FONT_SIZE + self.WATERMARK_SPACING
|
||||
|
||||
# Create watermark by drawing rotated text lines
|
||||
# We'll use a simpler approach: draw text and apply rotation via morph
|
||||
shape = page.new_shape()
|
||||
|
||||
# Calculate grid positions to cover the page when rotated
|
||||
center = fitz.Point(page_width / 2, page_height / 2)
|
||||
|
||||
# Calculate start and end points for coverage
|
||||
start = -diagonal
|
||||
end = diagonal * 2
|
||||
|
||||
y = start
|
||||
row = 0
|
||||
while y < end:
|
||||
x = start + (spacing_x / 2 if row % 2 else 0)
|
||||
while x < end:
|
||||
# Create text position
|
||||
text_point = fitz.Point(x, y)
|
||||
|
||||
# Apply rotation around center
|
||||
cos_a = math.cos(angle_rad)
|
||||
sin_a = math.sin(angle_rad)
|
||||
|
||||
# Translate to origin, rotate, translate back
|
||||
rx = text_point.x - center.x
|
||||
ry = text_point.y - center.y
|
||||
|
||||
new_x = rx * cos_a - ry * sin_a + center.x
|
||||
new_y = rx * sin_a + ry * cos_a + center.y
|
||||
|
||||
# Check if the rotated point is within page bounds (with margin)
|
||||
margin = 50
|
||||
if (-margin <= new_x <= page_width + margin and
|
||||
-margin <= new_y <= page_height + margin):
|
||||
# Insert text using shape with rotation via morph
|
||||
text_rect = fitz.Rect(new_x, new_y, new_x + text_length + 10, new_y + 30)
|
||||
|
||||
# Use insert_textbox with morph for rotation
|
||||
pivot = fitz.Point(new_x, new_y)
|
||||
morph = (pivot, fitz.Matrix(1, 0, 0, 1, 0, 0).prerotate(self.WATERMARK_ANGLE))
|
||||
|
||||
shape.insert_textbox(
|
||||
text_rect,
|
||||
watermark_text,
|
||||
fontname="helv",
|
||||
fontsize=self.WATERMARK_FONT_SIZE,
|
||||
color=color,
|
||||
fill_opacity=self.WATERMARK_OPACITY,
|
||||
morph=morph
|
||||
)
|
||||
|
||||
x += spacing_x
|
||||
y += spacing_y
|
||||
row += 1
|
||||
|
||||
# Commit the shape drawings
|
||||
shape.commit(overlay=True)
|
||||
|
||||
# Save to bytes
|
||||
output = io.BytesIO()
|
||||
doc.save(output)
|
||||
doc.close()
|
||||
output.seek(0)
|
||||
|
||||
logger.info(
|
||||
f"PDF watermark applied successfully for user {user_name} "
|
||||
f"(employee_id: {employee_id}), pages: {page_count}"
|
||||
)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
def is_supported_image(self, mime_type: str) -> bool:
|
||||
"""Check if the mime type is a supported image format."""
|
||||
supported_types = {'image/png', 'image/jpeg', 'image/jpg'}
|
||||
return mime_type.lower() in supported_types
|
||||
|
||||
def is_supported_pdf(self, mime_type: str) -> bool:
|
||||
"""Check if the mime type is a PDF."""
|
||||
return mime_type.lower() == 'application/pdf'
|
||||
|
||||
def supports_watermark(self, mime_type: str) -> bool:
|
||||
"""Check if the file type supports watermarking."""
|
||||
return self.is_supported_image(mime_type) or self.is_supported_pdf(mime_type)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
watermark_service = WatermarkService()
|
||||
Reference in New Issue
Block a user