diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dbb22fa --- /dev/null +++ b/.env.example @@ -0,0 +1,69 @@ +# QR Builder Environment Configuration +# Copy this file to .env and customize for your environment + +# ============================================================================= +# Environment +# ============================================================================= +# Options: development, production +QR_BUILDER_ENV=development + +# Enable debug mode (more verbose logging) +QR_BUILDER_DEBUG=false + +# ============================================================================= +# Server Configuration +# ============================================================================= +QR_BUILDER_HOST=0.0.0.0 +QR_BUILDER_PORT=8000 + +# Enable hot reload (development only) +QR_BUILDER_RELOAD=true + +# Number of worker processes (production) +QR_BUILDER_WORKERS=4 + +# Log level: debug, info, warning, error +QR_BUILDER_LOG_LEVEL=info + +# ============================================================================= +# Security Configuration +# ============================================================================= +# Enable API key authentication +QR_BUILDER_AUTH_ENABLED=false + +# Backend secret for webhook authentication +# REQUIRED in production - generate with: python -c "import secrets; print(secrets.token_urlsafe(32))" +QR_BUILDER_BACKEND_SECRET= + +# Backend URL for API key validation (Odoo/Next.js backend) +QR_BUILDER_BACKEND_URL=https://api.aiqso.io + +# Allowed CORS origins (comma-separated) +# Use specific domains in production, not '*' +QR_BUILDER_ALLOWED_ORIGINS=* + +# ============================================================================= +# File Upload Limits +# ============================================================================= +# Maximum upload file size in MB +QR_BUILDER_MAX_UPLOAD_MB=10 + +# ============================================================================= +# QR Code Generation Limits +# ============================================================================= +# Maximum data length for QR codes +QR_BUILDER_MAX_DATA_LENGTH=4296 + +# QR size limits in pixels +QR_BUILDER_MAX_QR_SIZE=4000 +QR_BUILDER_MIN_QR_SIZE=21 +QR_BUILDER_DEFAULT_SIZE=500 + +# Maximum images in batch request +QR_BUILDER_MAX_BATCH_SIZE=100 + +# ============================================================================= +# Web Interface Server (server.py) +# ============================================================================= +QR_SERVER_HOST=0.0.0.0 +QR_SERVER_PORT=8080 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c38caca --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,328 @@ +# QR Builder Architecture + +This document describes the architecture and design decisions of the QR Builder project. + +## Overview + +QR Builder is a Python package for generating QR codes with various artistic styles. It provides three interfaces: + +1. **Python Library** - For programmatic use in Python applications +2. **CLI** - Command-line interface for scripts and automation +3. **REST API** - HTTP API for web integrations + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Client Layer │ +├─────────────────┬─────────────────┬─────────────────┬───────────────────────┤ +│ CLI Client │ Python Import │ HTTP Client │ WordPress Widget │ +│ (qr-builder) │ (library) │ (cURL, etc) │ (JS Frontend) │ +└────────┬────────┴────────┬────────┴────────┬────────┴───────────┬───────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Interface Layer │ +├─────────────────┬─────────────────┬─────────────────────────────────────────┤ +│ cli.py │ core.py │ api.py │ +│ (CLI Parser) │ (Direct Use) │ (FastAPI Routes) │ +└────────┬────────┴────────┬────────┴────────┬────────────────────────────────┘ + │ │ │ + └────────────────┬┴─────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Core Layer │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ core.py │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ generate_qr │ │generate_qr_ │ │generate_qr_ │ │generate_ │ │ +│ │ │ │ with_logo │ │ with_text │ │ artistic_qr │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │generate_qart│ │embed_qr_in_ │ │ validate_* │ │ +│ │ │ │ image │ │ functions │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +└────────────────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ External Libraries │ +├─────────────┬─────────────┬─────────────┬─────────────┬─────────────────────┤ +│ qrcode │ Pillow │ amzqr │ pyqart │ segno │ +│ (QR Gen) │ (Image Ops) │ (Artistic) │ (Halftone) │ (Advanced QR) │ +└─────────────┴─────────────┴─────────────┴─────────────┴─────────────────────┘ +``` + +## Module Structure + +``` +qr_builder/ +├── __init__.py # Package exports and version +├── config.py # Centralized configuration management +├── core.py # Core QR generation functions +├── cli.py # Command-line interface +├── api.py # FastAPI REST API +├── auth.py # Authentication and rate limiting +└── utils.py # Utility functions (file validation, etc.) +``` + +### Module Responsibilities + +#### `config.py` +- Centralized configuration loading from environment variables +- Configuration validation +- Type-safe configuration objects +- Singleton pattern for global config access + +#### `core.py` +- All QR code generation logic +- Image manipulation functions +- Input validation functions +- Style-specific generators (basic, logo, text, artistic, qart, embed) + +#### `cli.py` +- argparse-based CLI +- Subcommands for each QR style +- Batch processing support +- Logging configuration + +#### `api.py` +- FastAPI application +- REST endpoints for all styles +- File upload handling +- OpenAPI documentation +- CORS configuration + +#### `auth.py` +- API key authentication +- Tier-based access control (free, pro, business) +- Rate limiting per tier +- Session management +- Backend webhook integration + +#### `utils.py` +- File upload validation +- MIME type detection +- Temporary file management +- Context managers for resource cleanup + +## QR Code Styles + +The system supports five distinct QR code styles: + +| Style | Description | Required Input | Tier | +|-------|-------------|----------------|------| +| **Basic** | Simple QR with custom colors | Data only | Free | +| **Text** | Text/words in QR center | Data + text | Free | +| **Logo** | Logo embedded in center | Data + logo image | Pro | +| **Artistic** | Image blended into QR pattern | Data + image | Pro | +| **QArt** | Halftone/dithered style | Data + image | Pro | +| **Embed** | QR placed on background | Data + background | Pro | + +## Authentication Flow + +``` +┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ +│ Client │──────│ API │──────│ Auth │──────│ Backend │ +│ │ │ Gateway │ │ Module │ │ (aiqso) │ +└───────────┘ └───────────┘ └───────────┘ └───────────┘ + │ │ │ │ + │ Request + Key │ │ │ + │─────────────────>│ │ │ + │ │ Validate Key │ │ + │ │─────────────────>│ │ + │ │ │ POST /validate │ + │ │ │─────────────────>│ + │ │ │ │ + │ │ │ User + Tier │ + │ │ │<─────────────────│ + │ │ Session + Tier │ │ + │ │<─────────────────│ │ + │ │ │ │ + │ │ Check Rate Limit│ │ + │ │─────────────────>│ │ + │ │ │ │ + │ │ Check Style │ │ + │ │ Access │ │ + │ │─────────────────>│ │ + │ │ │ │ + │ Response │ │ │ + │<─────────────────│ │ │ +``` + +## Rate Limiting + +Rate limits are enforced per tier: + +| Tier | Requests/Minute | Requests/Day | Max Size | Batch Limit | +|------|-----------------|--------------|----------|-------------| +| Free | 5 | 10 | 500px | 0 | +| Pro | 30 | 500 | 2000px | 10 | +| Business | 100 | 5000 | 4000px | 50 | +| Admin | 1000 | 100000 | 4000px | 100 | + +## Data Flow + +### QR Generation Flow + +``` +User Input + │ + ▼ +┌─────────────────┐ +│ Input Validation│ ◄── validate_data(), validate_size() +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ QR Code Creation│ ◄── qrcode library +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Style Processing│ ◄── Style-specific logic (logo, artistic, etc.) +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Image Output │ ◄── Pillow save to PNG +└────────┬────────┘ + │ + ▼ +PNG Binary / File +``` + +### API Request Flow + +``` +HTTP Request + │ + ▼ +┌─────────────────┐ +│ CORS Middleware │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Authentication │ ◄── get_current_user() +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Rate Limiting │ ◄── check_rate_limit() +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Style Access │ ◄── require_style() +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ File Validation │ ◄── validate_upload_file() +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Core Generation │ ◄── generate_* functions +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Usage Logging │ ◄── session_store.log_usage() +└────────┬────────┘ + │ + ▼ +HTTP Response (PNG) +``` + +## Configuration + +Configuration is managed through environment variables with a centralized `config.py` module: + +```python +from qr_builder.config import get_config + +config = get_config() +print(config.server.port) # 8000 +print(config.security.auth_enabled) # True/False +print(config.qr.max_qr_size) # 4000 +``` + +See `.env.example` for all available configuration options. + +## External Dependencies + +### Runtime Dependencies +- **qrcode** - Base QR code generation +- **Pillow** - Image processing +- **FastAPI** - REST API framework +- **uvicorn** - ASGI server +- **httpx** - HTTP client for backend validation +- **amzqr** - Artistic QR generation +- **pyqart** - Halftone/dithered QR generation +- **segno** - Advanced QR features + +### Development Dependencies +- **pytest** - Testing framework +- **pytest-cov** - Coverage reporting +- **ruff** - Linting +- **mypy** - Type checking + +## Security Considerations + +1. **Input Validation** - All user inputs are validated before processing +2. **File Uploads** - MIME type detection, size limits, and content validation +3. **Subprocess Security** - External commands use validated parameters with timeouts +4. **Authentication** - API key validation with backend integration +5. **Rate Limiting** - Per-tier limits to prevent abuse +6. **CORS** - Configurable origins for production +7. **Secrets** - Constant-time comparison for webhook secrets +8. **Non-root Container** - Docker runs as non-root user + +## Deployment Architecture + +### Docker Deployment + +``` +┌─────────────────────────────────────────────────────┐ +│ Docker Host │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ QR Builder Container │ │ +│ │ ┌──────────────────────────────────────────┐ │ │ +│ │ │ Python 3.11 │ │ │ +│ │ │ ┌────────────────────────────────────┐ │ │ │ +│ │ │ │ FastAPI Application │ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ - Uvicorn ASGI Server │ │ │ │ +│ │ │ │ - QR Builder Core │ │ │ │ +│ │ │ │ - Auth Module │ │ │ │ +│ │ │ └────────────────────────────────────┘ │ │ │ +│ │ └──────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Port 8000 │ +└────────────────────────┬────────────────────────────┘ + │ + ▼ + External Load Balancer / Reverse Proxy +``` + +### Production Considerations + +1. **Scaling** - Horizontal scaling via container orchestration +2. **Load Balancing** - Use nginx or cloud load balancer +3. **TLS** - Terminate SSL at reverse proxy +4. **Monitoring** - Health check endpoint at `/health` +5. **Logging** - Structured logging with configurable levels +6. **Memory** - Recommended 512MB per container +7. **Storage** - Stateless design; temp files cleaned up automatically + +## Future Considerations + +1. **Redis Integration** - For distributed rate limiting and caching +2. **Async Workers** - Celery for long-running batch operations +3. **CDN** - Cache generated QR codes at edge +4. **Metrics** - Prometheus integration for monitoring +5. **Webhooks** - Async notifications for batch completion diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c42e6b6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,208 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - 2024-12-14 + +### Added +- **Centralized Configuration** (`config.py`) + - Type-safe configuration management via dataclasses + - Environment variable loading with validation + - Configuration validation for production deployments + - Singleton pattern for global config access + +- **File Upload Validation** (`utils.py`) + - MIME type detection via magic bytes + - File size limits (configurable) + - Content validation for uploaded images + - Context managers for temporary file cleanup + +- **Security Improvements** + - Constant-time comparison for webhook secrets (prevents timing attacks) + - SHA256 instead of MD5 for anonymous user key generation + - Subprocess timeout and validation in `generate_qart()` + - Input validation for all external command parameters + - Non-root user in Docker container + +- **Documentation** + - `ARCHITECTURE.md` - System design and module documentation + - `INSTALL.md` - Comprehensive installation guide + - `CHANGELOG.md` - This changelog + - `.env.example` - Environment variable documentation + +- **Docker Improvements** + - Health check configuration + - Non-root user for security + - Production-ready environment defaults + - Resource limits in docker-compose + +### Changed +- **Version Consistency** + - Aligned version to 0.3.0 across all files + - Updated Python requirement to >=3.10 + +- **Dependencies** + - Added `httpx` to main dependencies (required for auth) + +- **Configuration** + - Auth module now uses centralized config + - Dynamic configuration loading for runtime flexibility + +### Fixed +- Version mismatch between `pyproject.toml` and `__init__.py` +- Docker port inconsistency (now consistently uses 8000) +- Subprocess security in `generate_qart()` - added validation and timeout + +### Security +- Replaced MD5 with SHA256 for IP-based anonymous key hashing +- Added constant-time string comparison for webhook validation +- Added parameter validation before subprocess execution +- Added file upload content validation + +## [0.2.0] - 2024-12-13 + +### Added +- **Authentication System** (`auth.py`) + - API key authentication + - Tier-based access control (free, pro, business, admin) + - Rate limiting per tier + - Session management + - Backend webhook integration for tier updates + +- **New QR Styles** + - `generate_qr_with_text()` - QR with text in center + - `generate_artistic_qr()` - Image blended into QR pattern + - `generate_qart()` - Halftone/dithered artistic style + +- **API Endpoints** + - `/qr/text` - QR with text + - `/qr/logo` - QR with logo + - `/qr/artistic` - Artistic QR + - `/qr/qart` - QArt halftone + - `/batch/artistic` - Batch artistic QR + - `/webhooks/*` - Backend integration + - `/usage/*` - Usage tracking + - `/styles` - List available styles + - `/tiers` - List tier features + - `/me` - Current user info + +- **Unified Interface** + - `QRConfig` dataclass for configuration + - `QRStyle` enum for style selection + - `generate_qr_unified()` for single-function access + +### Changed +- API version updated to 0.3.0 +- Enhanced CORS configuration +- Added rate limit headers in responses + +## [0.1.0] - 2024-12-12 + +### Added +- **Core Module** (`core.py`) + - `generate_qr()` - Basic QR code generation + - `generate_qr_only()` - Save standalone QR to file + - `embed_qr_in_image()` - Embed QR into background image + - `generate_qr_with_logo()` - QR with logo in center + - `calculate_position()` - Position calculation helper + - `validate_data()` - Data validation + - `validate_size()` - Size validation + - `parse_color()` - Color string parsing + +- **CLI** (`cli.py`) + - `qr` subcommand - Generate basic QR + - `embed` subcommand - Embed QR in image + - `logo` subcommand - QR with logo + - `batch-embed` subcommand - Batch processing + +- **REST API** (`api.py`) + - FastAPI-based REST API + - `/health` - Health check + - `/qr` - Basic QR generation + - `/embed` - Embed QR in image + - `/batch/embed` - Batch embed + - OpenAPI documentation at `/docs` + - CORS support + +- **Web Interface** (`server.py`) + - Tab-based UI for all styles + - Color picker integration + - Live preview + - Download functionality + +- **Testing** + - `test_core.py` - Core function tests + - `test_api.py` - API endpoint tests + +- **Documentation** + - `README.md` - Project documentation + - `CLAUDE.md` - Development reference + - `docs/API.md` - API documentation + - WordPress integration guide + +- **CI/CD** + - GitHub Actions workflow + - Linting with ruff + - Multi-version Python testing + - Docker build testing + +- **Docker Support** + - Dockerfile for containerization + - docker-compose.yml for easy deployment + +### Dependencies +- qrcode[pil]>=7.4.2 +- Pillow>=10.0.0 +- fastapi>=0.115.0 +- uvicorn[standard]>=0.30.0 +- python-multipart>=0.0.9 +- amzqr>=0.0.1 +- pyqart>=0.1.0 +- segno>=1.6.0 +- qrcode-artistic>=3.0.0 + +--- + +## Migration Guide + +### From 0.2.x to 0.3.0 + +1. **Configuration Changes** + - Environment variables now use `QR_BUILDER_` prefix + - Old `BACKEND_SECRET` → `QR_BUILDER_BACKEND_SECRET` + - See `.env.example` for all variables + +2. **Import Changes** + - New imports available from `qr_builder`: + ```python + from qr_builder import get_config, AppConfig + ``` + +3. **Docker Changes** + - Default port is now 8000 (was 8080 for API) + - Container runs as non-root user + - Set `QR_BUILDER_AUTH_ENABLED=true` for production + +### From 0.1.x to 0.2.x + +1. **New Required Dependencies** + - `amzqr>=0.0.1` + - `pyqart>=0.1.0` + +2. **API Changes** + - New authentication system + - Rate limiting headers in responses + - New endpoints for tiers and usage + +--- + +## Unreleased + +### Planned +- Redis integration for distributed rate limiting +- Celery workers for async batch processing +- Prometheus metrics endpoint +- WebSocket support for progress updates diff --git a/Dockerfile b/Dockerfile index c774ed4..529e17a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,19 +2,38 @@ FROM python:3.11-slim WORKDIR /app +# Environment configuration ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +ENV QR_BUILDER_HOST=0.0.0.0 +ENV QR_BUILDER_PORT=8000 +ENV QR_BUILDER_ENV=production +ENV QR_BUILDER_AUTH_ENABLED=false +# Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* +# Copy application files COPY pyproject.toml README.md ./ COPY qr_builder ./qr_builder COPY server.py ./ +# Install Python dependencies RUN pip install --no-cache-dir -e . -EXPOSE 8080 +# Create non-root user for security +RUN useradd --create-home --shell /bin/bash appuser && \ + chown -R appuser:appuser /app +USER appuser -CMD ["python", "server.py"] +# Expose the application port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import httpx; httpx.get('http://localhost:8000/health', timeout=5).raise_for_status()" || exit 1 + +# Run the API server (can also use server.py for web interface) +CMD ["uvicorn", "qr_builder.api:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..5797ccf --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,344 @@ +# QR Builder Installation Guide + +This guide covers various methods to install and run QR Builder. + +## Table of Contents + +- [Requirements](#requirements) +- [Quick Start](#quick-start) +- [Installation Methods](#installation-methods) + - [From Source (Development)](#from-source-development) + - [Using pip](#using-pip) + - [Using Docker](#using-docker) + - [Using Docker Compose](#using-docker-compose) +- [Configuration](#configuration) +- [Running the Application](#running-the-application) +- [Verification](#verification) +- [Troubleshooting](#troubleshooting) + +## Requirements + +### System Requirements +- Python 3.10 or higher +- pip (Python package manager) +- 512MB RAM minimum (1GB recommended) +- 500MB disk space + +### Optional Requirements +- Docker and Docker Compose (for containerized deployment) +- Git (for source installation) + +## Quick Start + +```bash +# Clone the repository +git clone https://github.com/AIQSO/qr-builder.git +cd qr-builder + +# Create and activate virtual environment +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate + +# Install with development dependencies +pip install -e ".[dev]" + +# Run the API server +uvicorn qr_builder.api:app --reload --port 8000 + +# Visit http://localhost:8000/docs for API documentation +``` + +## Installation Methods + +### From Source (Development) + +This is the recommended method for development and testing. + +```bash +# 1. Clone the repository +git clone https://github.com/AIQSO/qr-builder.git +cd qr-builder + +# 2. Create a virtual environment +python -m venv .venv + +# 3. Activate the virtual environment +# On Linux/macOS: +source .venv/bin/activate +# On Windows: +.venv\Scripts\activate + +# 4. Install in development mode with dev dependencies +pip install -e ".[dev]" + +# 5. Verify installation +qr-builder --help +python -c "from qr_builder import __version__; print(f'QR Builder v{__version__}')" +``` + +### Using pip + +For production use without modifying the source: + +```bash +# Install from source directory +pip install /path/to/qr-builder + +# Or if published to PyPI (future): +# pip install qr-builder +``` + +### Using Docker + +Build and run using Docker: + +```bash +# Build the Docker image +docker build -t qr-builder . + +# Run the container +docker run -p 8000:8000 qr-builder + +# Run with environment variables +docker run -p 8000:8000 \ + -e QR_BUILDER_AUTH_ENABLED=false \ + -e QR_BUILDER_MAX_UPLOAD_MB=20 \ + qr-builder + +# Run with web interface instead of API +docker run -p 8080:8080 \ + -e QR_SERVER_PORT=8080 \ + qr-builder python server.py +``` + +### Using Docker Compose + +For easy deployment with configuration: + +```bash +# 1. Copy the example environment file +cp .env.example .env + +# 2. Edit .env with your settings +nano .env + +# 3. Start the services +docker compose up -d + +# 4. View logs +docker compose logs -f + +# 5. Stop the services +docker compose down +``` + +## Configuration + +### Environment Variables + +Create a `.env` file or set environment variables: + +```bash +# Copy example configuration +cp .env.example .env +``` + +Key configuration options: + +| Variable | Default | Description | +|----------|---------|-------------| +| `QR_BUILDER_ENV` | `development` | Environment (development/production) | +| `QR_BUILDER_HOST` | `0.0.0.0` | Server bind host | +| `QR_BUILDER_PORT` | `8000` | Server port | +| `QR_BUILDER_AUTH_ENABLED` | `false` (dev) | Enable API key authentication | +| `QR_BUILDER_BACKEND_SECRET` | - | Secret for webhook auth (required in production) | +| `QR_BUILDER_ALLOWED_ORIGINS` | `*` | CORS allowed origins | +| `QR_BUILDER_MAX_UPLOAD_MB` | `10` | Maximum upload file size | + +See `.env.example` for all available options. + +### Production Configuration + +For production deployments: + +```bash +# Required settings +export QR_BUILDER_ENV=production +export QR_BUILDER_AUTH_ENABLED=true +export QR_BUILDER_BACKEND_SECRET=$(python -c "import secrets; print(secrets.token_urlsafe(32))") +export QR_BUILDER_ALLOWED_ORIGINS=https://yourdomain.com,https://api.yourdomain.com +``` + +## Running the Application + +### REST API Server + +```bash +# Development (with hot reload) +uvicorn qr_builder.api:app --reload --port 8000 + +# Production +uvicorn qr_builder.api:app --host 0.0.0.0 --port 8000 --workers 4 + +# Using the CLI entry point +qr-builder-api +``` + +Visit `http://localhost:8000/docs` for interactive API documentation. + +### Web Interface + +```bash +# Start the web interface server +python server.py + +# Or with custom port +QR_SERVER_PORT=8080 python server.py +``` + +Visit `http://localhost:8080` for the web interface. + +### CLI Usage + +```bash +# Generate a basic QR code +qr-builder qr "https://example.com" output.png + +# Generate QR with logo +qr-builder logo logo.png "https://example.com" output.png + +# See all commands +qr-builder --help +``` + +## Verification + +### Test the Installation + +```bash +# Run the test suite +pytest + +# Run with coverage +pytest --cov=qr_builder --cov-report=html + +# Run linting +ruff check qr_builder/ +``` + +### Verify API + +```bash +# Check health endpoint +curl http://localhost:8000/health + +# Generate a QR code +curl -X POST "http://localhost:8000/qr" \ + -F "data=https://example.com" \ + --output test_qr.png + +# Check the generated file +file test_qr.png # Should show: PNG image data, 500 x 500 +``` + +### Verify CLI + +```bash +# Generate a QR code +qr-builder qr "Hello World" hello.png + +# Check the output +ls -la hello.png +``` + +## Troubleshooting + +### Common Issues + +#### Import Errors + +``` +ModuleNotFoundError: No module named 'qr_builder' +``` + +**Solution:** Ensure you've activated your virtual environment and installed the package: +```bash +source .venv/bin/activate +pip install -e . +``` + +#### Permission Denied (Docker) + +``` +PermissionError: [Errno 13] Permission denied +``` + +**Solution:** The Docker container runs as non-root user. Ensure mounted volumes have correct permissions: +```bash +chmod -R 755 /path/to/mounted/volume +``` + +#### pyqart Not Found + +``` +RuntimeError: pyqart command not found +``` + +**Solution:** Install the pyqart package: +```bash +pip install pyqart +``` + +#### amzqr Import Error + +``` +ModuleNotFoundError: No module named 'amzqr' +``` + +**Solution:** Install the amzqr package: +```bash +pip install amzqr +``` + +#### Port Already in Use + +``` +OSError: [Errno 98] Address already in use +``` + +**Solution:** Either stop the existing process or use a different port: +```bash +# Find the process +lsof -i :8000 + +# Kill it +kill -9 + +# Or use a different port +uvicorn qr_builder.api:app --port 8001 +``` + +### Getting Help + +- **GitHub Issues:** https://github.com/AIQSO/qr-builder/issues +- **Documentation:** See `README.md` and `docs/API.md` +- **Architecture:** See `ARCHITECTURE.md` + +### Logs + +Enable debug logging for troubleshooting: + +```bash +export QR_BUILDER_LOG_LEVEL=debug +export QR_BUILDER_DEBUG=true +uvicorn qr_builder.api:app --log-level debug +``` + +## Next Steps + +After installation: + +1. Read the [README.md](README.md) for usage examples +2. Review [ARCHITECTURE.md](ARCHITECTURE.md) for system design +3. Check [docs/API.md](docs/API.md) for API reference +4. Set up authentication for production use diff --git a/docker-compose.yml b/docker-compose.yml index 817da07..6d21b7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,4 +7,33 @@ services: - "8000:8000" environment: - PYTHONUNBUFFERED=1 + - QR_BUILDER_ENV=production + - QR_BUILDER_AUTH_ENABLED=${QR_BUILDER_AUTH_ENABLED:-false} + - QR_BUILDER_BACKEND_SECRET=${QR_BUILDER_BACKEND_SECRET:-} + - QR_BUILDER_BACKEND_URL=${QR_BUILDER_BACKEND_URL:-https://api.aiqso.io} + - QR_BUILDER_ALLOWED_ORIGINS=${QR_BUILDER_ALLOWED_ORIGINS:-*} + - QR_BUILDER_MAX_UPLOAD_MB=${QR_BUILDER_MAX_UPLOAD_MB:-10} restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health', timeout=5).raise_for_status()"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + + # Web interface (optional - uncomment to run alongside API) + # qr-builder-web: + # build: . + # command: python server.py + # ports: + # - "8080:8080" + # environment: + # - QR_SERVER_HOST=0.0.0.0 + # - QR_SERVER_PORT=8080 + # restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml index 74782d5..e4a1057 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "qr-builder" -version = "0.1.0" +version = "0.3.0" description = "Generate and embed QR codes into images via Python, CLI, and FastAPI." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = { text = "MIT" } authors = [ { name = "AIQSO", email = "dev@aiqso.io" } @@ -31,6 +31,7 @@ dependencies = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.30.0", "python-multipart>=0.0.9", + "httpx>=0.27.0", "amzqr>=0.0.1", "pyqart>=0.1.0", "segno>=1.6.0", @@ -56,6 +57,11 @@ Source = "https://github.com/aiqso/qr-builder" Issues = "https://github.com/aiqso/qr-builder/issues" Documentation = "https://github.com/aiqso/qr-builder#readme" +[tool.setuptools.packages.find] +where = ["."] +include = ["qr_builder*"] +exclude = ["tests*"] + [tool.ruff] line-length = 100 target-version = "py310" diff --git a/qr_builder/__init__.py b/qr_builder/__init__.py index 1833a87..405e463 100644 --- a/qr_builder/__init__.py +++ b/qr_builder/__init__.py @@ -1,36 +1,41 @@ +from .auth import ( + TIER_LIMITS, + TierLimits, + UserSession, + UserTier, + get_all_tiers_info, + get_tier_info, +) +from .config import ( + AppConfig, + SecurityConfig, + ServerConfig, + get_config, +) from .core import ( - # Basic functions - generate_qr, - generate_qr_only, - embed_qr_in_image, - calculate_position, - validate_data, - validate_size, - parse_color, - # Advanced styles - generate_qr_with_logo, - generate_qr_with_text, - generate_artistic_qr, - generate_qart, - # Unified interface - generate_qr_unified, - QRConfig, - QRStyle, ARTISTIC_PRESETS, # Constants MAX_DATA_LENGTH, MAX_QR_SIZE, MIN_QR_SIZE, VALID_POSITIONS, -) - -from .auth import ( - UserTier, - TierLimits, - UserSession, - TIER_LIMITS, - get_all_tiers_info, - get_tier_info, + QRConfig, + QRStyle, + calculate_position, + embed_qr_in_image, + generate_artistic_qr, + generate_qart, + # Basic functions + generate_qr, + generate_qr_only, + # Unified interface + generate_qr_unified, + # Advanced styles + generate_qr_with_logo, + generate_qr_with_text, + parse_color, + validate_data, + validate_size, ) __version__ = "0.3.0" @@ -61,6 +66,11 @@ "TIER_LIMITS", "get_all_tiers_info", "get_tier_info", + # Configuration + "get_config", + "AppConfig", + "ServerConfig", + "SecurityConfig", # Constants "MAX_DATA_LENGTH", "MAX_QR_SIZE", diff --git a/qr_builder/api.py b/qr_builder/api.py index d4e733b..9a4fa2e 100644 --- a/qr_builder/api.py +++ b/qr_builder/api.py @@ -32,41 +32,34 @@ import io import logging -import zipfile import tempfile -import time -from pathlib import Path -from typing import List, Optional +import zipfile from enum import Enum +from pathlib import Path -from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query, Depends, Body +from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, Query, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse, JSONResponse - -from .core import ( - generate_qr, - calculate_position, - generate_qr_with_logo, - generate_qr_with_text, - generate_artistic_qr, - generate_qart, - QRStyle, - ARTISTIC_PRESETS, -) +from fastapi.responses import StreamingResponse from .auth import ( + ALLOWED_ORIGINS, + AUTH_ENABLED, UserSession, UserTier, + get_all_tiers_info, get_current_user, - require_auth, - check_rate_limit, require_style, - verify_backend_webhook, session_store, - get_tier_info, - get_all_tiers_info, - ALLOWED_ORIGINS, - AUTH_ENABLED, + verify_backend_webhook, +) +from .core import ( + ARTISTIC_PRESETS, + calculate_position, + generate_artistic_qr, + generate_qart, + generate_qr, + generate_qr_with_logo, + generate_qr_with_text, ) logger = logging.getLogger(__name__) @@ -379,7 +372,7 @@ async def create_qr_with_text_endpoint( async def create_artistic_qr( image: UploadFile = File(..., description="Image to blend into QR pattern."), data: str = Form(..., description="Text or URL to encode."), - preset: Optional[PresetEnum] = Form(None, description="Quality preset (small/medium/large/hd)."), + preset: PresetEnum | None = Form(None, description="Quality preset (small/medium/large/hd)."), version: int = Form(10, description="QR version 1-40 (higher = more detail)."), contrast: float = Form(1.0, description="Image contrast (try 1.2-1.5)."), brightness: float = Form(1.0, description="Image brightness (try 1.1-1.2)."), @@ -563,7 +556,7 @@ async def embed_qr( @app.post("/batch/embed", tags=["batch"]) async def batch_embed_qr( - backgrounds: List[UploadFile] = File(..., description="Multiple background images."), + backgrounds: list[UploadFile] = File(..., description="Multiple background images."), data: str = Form(..., description="Text or URL to encode."), scale: float = Form(0.3, description="Fraction of background width to use for QR."), position: str = Form("center"), @@ -649,9 +642,9 @@ async def batch_embed_qr( @app.post("/batch/artistic", tags=["batch"]) async def batch_artistic_qr( - images: List[UploadFile] = File(..., description="Multiple images to transform."), + images: list[UploadFile] = File(..., description="Multiple images to transform."), data: str = Form(..., description="Text or URL to encode."), - preset: Optional[PresetEnum] = Form(PresetEnum.large, description="Quality preset."), + preset: PresetEnum | None = Form(PresetEnum.large, description="Quality preset."), user: UserSession = Depends(require_style("artistic")), ): """ @@ -871,6 +864,7 @@ async def cleanup_old_logs( def run() -> None: """Convenience entrypoint for `qr-builder-api` script.""" import os + import uvicorn host = os.getenv("QR_BUILDER_HOST", "0.0.0.0") diff --git a/qr_builder/auth.py b/qr_builder/auth.py index 7b215df..82ff90c 100644 --- a/qr_builder/auth.py +++ b/qr_builder/auth.py @@ -18,38 +18,54 @@ from __future__ import annotations -import os -import time import hashlib import logging -from enum import Enum -from typing import Optional, Callable -from dataclasses import dataclass, field +import time from collections import defaultdict +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum -from fastapi import Request, HTTPException, Header, Depends +from fastapi import Depends, Header, HTTPException, Request from fastapi.security import APIKeyHeader logger = logging.getLogger(__name__) # ============================================================================= -# Configuration (from environment variables) +# Configuration (from centralized config module) # ============================================================================= -# Backend secret for webhook authentication (set this in your deployment) -BACKEND_SECRET = os.getenv("QR_BUILDER_BACKEND_SECRET", "change-me-in-production") +from .config import get_config + + +def get_backend_secret() -> str: + """Get backend secret from config.""" + return get_config().security.backend_secret -# Your backend URL for token validation (Odoo/Next.js backend) -BACKEND_VALIDATION_URL = os.getenv("QR_BUILDER_BACKEND_URL", "https://api.aiqso.io") -# Enable/disable authentication (disable for local development) -AUTH_ENABLED = os.getenv("QR_BUILDER_AUTH_ENABLED", "true").lower() == "true" +def get_backend_url() -> str: + """Get backend URL from config.""" + return get_config().security.backend_url -# Allowed origins for CORS -ALLOWED_ORIGINS = os.getenv( - "QR_BUILDER_ALLOWED_ORIGINS", - "https://aiqso.io,https://www.aiqso.io,https://api.aiqso.io" -).split(",") + +def is_auth_enabled() -> bool: + """Check if auth is enabled from config.""" + return get_config().security.auth_enabled + + +def get_allowed_origins() -> list: + """Get allowed origins from config.""" + return get_config().security.allowed_origins + + +# Backward compatibility module-level "constants" +# NOTE: These are evaluated at import time. For runtime values, use the functions above. +# For most use cases in this module, we use the functions directly. +_config = get_config() +BACKEND_SECRET = _config.security.backend_secret +BACKEND_VALIDATION_URL = _config.security.backend_url +AUTH_ENABLED = _config.security.auth_enabled +ALLOWED_ORIGINS = _config.security.allowed_origins # ============================================================================= @@ -127,7 +143,7 @@ class UserSession: user_id: str tier: UserTier api_key: str - email: Optional[str] = None + email: str | None = None # Rate limiting tracking requests_this_minute: int = 0 @@ -201,7 +217,7 @@ def get_or_create_session( user_id: str, tier: UserTier, api_key: str, - email: Optional[str] = None, + email: str | None = None, ) -> UserSession: """Get existing session or create new one.""" if api_key not in self._sessions: @@ -230,7 +246,7 @@ def log_usage( user_id: str, style: str, success: bool, - metadata: Optional[dict] = None, + metadata: dict | None = None, ) -> None: """Log usage for Odoo sync.""" self._usage_log.append({ @@ -279,7 +295,7 @@ def clear_old_logs(self, days: int = 30) -> int: api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) -async def validate_api_key_with_backend(api_key: str) -> Optional[dict]: +async def validate_api_key_with_backend(api_key: str) -> dict | None: """ Validate API key with your backend (Odoo/Next.js). @@ -297,8 +313,13 @@ async def validate_api_key_with_backend(api_key: str) -> Optional[dict]: "error": "Invalid or expired API key" } """ + # Get config dynamically to support runtime changes + auth_enabled = is_auth_enabled() + backend_secret = get_backend_secret() + backend_url = get_backend_url() + # For development/testing without backend - if not AUTH_ENABLED: + if not auth_enabled: return { "valid": True, "user_id": "dev_user", @@ -309,7 +330,7 @@ async def validate_api_key_with_backend(api_key: str) -> Optional[dict]: # Check for internal/admin keys (for your backend services) if api_key.startswith("qrb_admin_"): expected_hash = hashlib.sha256( - f"{BACKEND_SECRET}:{api_key}".encode() + f"{backend_secret}:{api_key}".encode() ).hexdigest()[:16] if api_key.endswith(expected_hash): return { @@ -324,9 +345,9 @@ async def validate_api_key_with_backend(api_key: str) -> Optional[dict]: import httpx async with httpx.AsyncClient() as client: response = await client.post( - f"{BACKEND_VALIDATION_URL}/api/qr-builder/validate-key", + f"{backend_url}/api/qr-builder/validate-key", json={"api_key": api_key}, - headers={"Authorization": f"Bearer {BACKEND_SECRET}"}, + headers={"Authorization": f"Bearer {backend_secret}"}, timeout=5.0, ) if response.status_code == 200: @@ -339,7 +360,7 @@ async def validate_api_key_with_backend(api_key: str) -> Optional[dict]: async def get_current_user( request: Request, - api_key: Optional[str] = Depends(api_key_header), + api_key: str | None = Depends(api_key_header), ) -> UserSession: """ Dependency to get current authenticated user. @@ -350,9 +371,11 @@ async def create_logo_qr(user: UserSession = Depends(get_current_user)): if not user.can_access_style("logo"): raise HTTPException(403, "Upgrade to Pro for logo QR codes") """ + auth_enabled = is_auth_enabled() + # Allow unauthenticated access for free tier (with limits) if not api_key: - if not AUTH_ENABLED: + if not auth_enabled: # Dev mode - return business tier return session_store.get_or_create_session( user_id="anonymous", @@ -361,8 +384,9 @@ async def create_logo_qr(user: UserSession = Depends(get_current_user)): ) # Production - anonymous users get free tier + # Use SHA256 instead of MD5 for IP hashing (more secure) client_ip = request.client.host if request.client else "unknown" - anonymous_key = f"anon_{hashlib.md5(client_ip.encode()).hexdigest()[:8]}" + anonymous_key = f"anon_{hashlib.sha256(client_ip.encode()).hexdigest()[:12]}" return session_store.get_or_create_session( user_id=f"anonymous_{client_ip}", tier=UserTier.FREE, @@ -495,7 +519,10 @@ async def verify_backend_webhook( Your backend should include the secret in the X-Webhook-Secret header. """ - if x_webhook_secret != BACKEND_SECRET: + backend_secret = get_backend_secret() + # Use constant-time comparison to prevent timing attacks + import secrets + if not secrets.compare_digest(x_webhook_secret, backend_secret): raise HTTPException( status_code=401, detail="Invalid webhook secret", diff --git a/qr_builder/cli.py b/qr_builder/cli.py index eb4a9ee..60f9ae5 100644 --- a/qr_builder/cli.py +++ b/qr_builder/cli.py @@ -15,7 +15,14 @@ import logging from pathlib import Path -from .core import generate_qr_only, embed_qr_in_image, generate_qr_with_logo, generate_artistic_qr, generate_qart, generate_qr_with_text +from .core import ( + embed_qr_in_image, + generate_artistic_qr, + generate_qart, + generate_qr_only, + generate_qr_with_logo, + generate_qr_with_text, +) def build_parser() -> argparse.ArgumentParser: @@ -228,7 +235,7 @@ def main() -> None: elif args.command == "batch-embed": from glob import glob from os import makedirs - from os.path import basename, splitext, join + from os.path import basename, splitext input_dir = Path(args.input_dir) output_dir = Path(args.output_dir) diff --git a/qr_builder/config.py b/qr_builder/config.py new file mode 100644 index 0000000..7a10d73 --- /dev/null +++ b/qr_builder/config.py @@ -0,0 +1,185 @@ +""" +qr_builder.config +----------------- + +Centralized configuration management for QR Builder. + +All configuration is loaded from environment variables with sensible defaults +for development. In production, these should be set via environment variables +or a .env file. +""" + +from __future__ import annotations + +import logging +import os +import secrets +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +def _parse_bool(value: str) -> bool: + """Parse boolean from environment variable string.""" + return value.lower() in ("true", "1", "yes", "on") + + +def _parse_list(value: str) -> list[str]: + """Parse comma-separated list from environment variable.""" + return [item.strip() for item in value.split(",") if item.strip()] + + +@dataclass(frozen=True) +class ServerConfig: + """Server configuration settings.""" + host: str = "0.0.0.0" + port: int = 8000 + reload: bool = False + workers: int = 1 + log_level: str = "info" + + +@dataclass(frozen=True) +class SecurityConfig: + """Security-related configuration.""" + auth_enabled: bool = True + backend_secret: str = field(default_factory=lambda: secrets.token_urlsafe(32)) + backend_url: str = "https://api.aiqso.io" + allowed_origins: list[str] = field(default_factory=list) + + # File upload limits + max_upload_size_mb: int = 10 + allowed_image_types: tuple = ("image/png", "image/jpeg", "image/gif", "image/webp") + + def __post_init__(self): + # Warn about insecure defaults in production + if self.auth_enabled and self.backend_secret == "change-me-in-production": + logger.warning( + "SECURITY WARNING: Using default backend secret. " + "Set QR_BUILDER_BACKEND_SECRET environment variable in production." + ) + + +@dataclass(frozen=True) +class QRConfig: + """QR code generation limits and defaults.""" + max_data_length: int = 4296 + max_qr_size: int = 4000 + min_qr_size: int = 21 + default_size: int = 500 + max_batch_size: int = 100 + + +@dataclass +class AppConfig: + """Main application configuration.""" + server: ServerConfig + security: SecurityConfig + qr: QRConfig + + # Environment + environment: str = "development" + debug: bool = False + + @classmethod + def from_env(cls) -> AppConfig: + """Load configuration from environment variables.""" + environment = os.getenv("QR_BUILDER_ENV", "development") + is_production = environment == "production" + + # Server config + server = ServerConfig( + host=os.getenv("QR_BUILDER_HOST", "0.0.0.0"), + port=int(os.getenv("QR_BUILDER_PORT", "8000")), + reload=_parse_bool(os.getenv("QR_BUILDER_RELOAD", "false" if is_production else "true")), + workers=int(os.getenv("QR_BUILDER_WORKERS", "4" if is_production else "1")), + log_level=os.getenv("QR_BUILDER_LOG_LEVEL", "info"), + ) + + # Security config + backend_secret = os.getenv("QR_BUILDER_BACKEND_SECRET", "") + if not backend_secret: + if is_production: + raise ValueError( + "QR_BUILDER_BACKEND_SECRET must be set in production environment" + ) + backend_secret = "dev-secret-not-for-production" + + allowed_origins_default = ( + "https://aiqso.io,https://www.aiqso.io,https://api.aiqso.io" + if is_production + else "*" + ) + + security = SecurityConfig( + auth_enabled=_parse_bool(os.getenv("QR_BUILDER_AUTH_ENABLED", "true" if is_production else "false")), + backend_secret=backend_secret, + backend_url=os.getenv("QR_BUILDER_BACKEND_URL", "https://api.aiqso.io"), + allowed_origins=_parse_list(os.getenv("QR_BUILDER_ALLOWED_ORIGINS", allowed_origins_default)), + max_upload_size_mb=int(os.getenv("QR_BUILDER_MAX_UPLOAD_MB", "10")), + ) + + # QR config + qr = QRConfig( + max_data_length=int(os.getenv("QR_BUILDER_MAX_DATA_LENGTH", "4296")), + max_qr_size=int(os.getenv("QR_BUILDER_MAX_QR_SIZE", "4000")), + min_qr_size=int(os.getenv("QR_BUILDER_MIN_QR_SIZE", "21")), + default_size=int(os.getenv("QR_BUILDER_DEFAULT_SIZE", "500")), + max_batch_size=int(os.getenv("QR_BUILDER_MAX_BATCH_SIZE", "100")), + ) + + return cls( + server=server, + security=security, + qr=qr, + environment=environment, + debug=_parse_bool(os.getenv("QR_BUILDER_DEBUG", "false")), + ) + + def validate(self) -> list[str]: + """Validate configuration and return list of issues.""" + issues = [] + + if self.server.port < 1 or self.server.port > 65535: + issues.append(f"Invalid port: {self.server.port}") + + if self.qr.min_qr_size >= self.qr.max_qr_size: + issues.append("min_qr_size must be less than max_qr_size") + + if self.qr.max_batch_size < 1: + issues.append("max_batch_size must be at least 1") + + if self.security.max_upload_size_mb < 1: + issues.append("max_upload_size_mb must be at least 1") + + if self.environment == "production": + if "*" in self.security.allowed_origins: + issues.append("Wildcard CORS origins not allowed in production") + if self.security.backend_secret == "dev-secret-not-for-production": + issues.append("Backend secret not set for production") + + return issues + + +# Global configuration instance (lazy loaded) +_config: AppConfig | None = None + + +def get_config() -> AppConfig: + """Get the application configuration (lazy loaded singleton).""" + global _config + if _config is None: + _config = AppConfig.from_env() + issues = _config.validate() + if issues: + for issue in issues: + logger.error(f"Configuration error: {issue}") + if _config.environment == "production": + raise ValueError(f"Configuration validation failed: {issues}") + return _config + + +def reset_config() -> None: + """Reset configuration (useful for testing).""" + global _config + _config = None diff --git a/qr_builder/core.py b/qr_builder/core.py index 9cee541..a206a27 100644 --- a/qr_builder/core.py +++ b/qr_builder/core.py @@ -15,10 +15,9 @@ from __future__ import annotations import logging -from pathlib import Path -from typing import Literal, Optional from dataclasses import dataclass from enum import Enum +from pathlib import Path import qrcode from PIL import Image @@ -55,7 +54,7 @@ class QRConfig: """Configuration for QR code generation.""" data: str style: QRStyle = QRStyle.BASIC - output_path: Optional[str] = None + output_path: str | None = None # Basic options size: int = 500 @@ -63,7 +62,7 @@ class QRConfig: back_color: str = "white" # Image-based options (for artistic, qart, logo, embed) - image_path: Optional[str] = None + image_path: str | None = None # Logo options logo_scale: float = 0.25 @@ -73,7 +72,7 @@ class QRConfig: contrast: float = 1.0 brightness: float = 1.0 version: int = 10 - preset: Optional[str] = None # small, medium, large, hd + preset: str | None = None # small, medium, large, hd # QArt options point_size: int = 8 @@ -434,7 +433,7 @@ def generate_qart( point_size: int = 8, dither: bool = True, only_data: bool = False, - fill_color: tuple = None, + fill_color: tuple | None = None, ) -> Path: """ Generate a QArt-style QR code using halftone/dithering techniques. @@ -458,7 +457,10 @@ def generate_qart( Raises: FileNotFoundError: If image file doesn't exist. + ValueError: If parameters are invalid. + RuntimeError: If pyqart command fails. """ + import shutil import subprocess image_path = Path(image_path) @@ -467,13 +469,36 @@ def generate_qart( if not image_path.exists(): raise FileNotFoundError(f"Image not found: {image_path}") + # Validate parameters to prevent injection + if not 1 <= version <= 40: + raise ValueError(f"Version must be between 1 and 40, got {version}") + if not 1 <= point_size <= 100: + raise ValueError(f"Point size must be between 1 and 100, got {point_size}") + if fill_color is not None: + if len(fill_color) != 3: + raise ValueError("fill_color must be an RGB tuple of 3 integers") + for i, c in enumerate(fill_color): + if not 0 <= c <= 255: + raise ValueError(f"Color component {i} must be between 0 and 255, got {c}") + + # Validate data doesn't contain shell metacharacters (defense in depth) + validate_data(data) + + # Check if pyqart is available + pyqart_path = shutil.which("pyqart") + if pyqart_path is None: + raise RuntimeError( + "pyqart command not found. Install it with: pip install pyqart" + ) + logger.info("Generating QArt from image: %s", image_path) + # Build command with validated parameters cmd = [ - "pyqart", - "-v", str(version), - "-p", str(point_size), - "-o", str(output_path), + pyqart_path, + "-v", str(int(version)), + "-p", str(int(point_size)), + "-o", str(output_path.resolve()), ] if dither: @@ -481,11 +506,33 @@ def generate_qart( if only_data: cmd.append("-y") if fill_color: - cmd.extend(["-c", str(fill_color[0]), str(fill_color[1]), str(fill_color[2])]) + cmd.extend([ + "-c", + str(int(fill_color[0])), + str(int(fill_color[1])), + str(int(fill_color[2])) + ]) + + cmd.extend([data, str(image_path.resolve())]) + + try: + result = subprocess.run( + cmd, + check=True, + capture_output=True, + timeout=60, # 60 second timeout + ) + logger.debug("pyqart output: %s", result.stdout.decode()) + except subprocess.TimeoutExpired as e: + raise RuntimeError("QArt generation timed out after 60 seconds") from e + except subprocess.CalledProcessError as e: + error_msg = e.stderr.decode() if e.stderr else str(e) + logger.error("pyqart failed: %s", error_msg) + raise RuntimeError(f"QArt generation failed: {error_msg}") from e - cmd.extend([data, str(image_path)]) + if not output_path.exists(): + raise RuntimeError("QArt generation completed but output file not created") - subprocess.run(cmd, check=True, capture_output=True) logger.info("Saved QArt: %s", output_path) return output_path @@ -554,10 +601,10 @@ def generate_qr_with_text( # Try to load a nice font, fall back to default try: font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size) - except (IOError, OSError): + except OSError: try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size) - except (IOError, OSError): + except OSError: font = ImageFont.load_default() # Get text bounding box diff --git a/qr_builder/utils.py b/qr_builder/utils.py new file mode 100644 index 0000000..a0f36e3 --- /dev/null +++ b/qr_builder/utils.py @@ -0,0 +1,204 @@ +""" +qr_builder.utils +---------------- + +Utility functions for QR Builder. +""" + +from __future__ import annotations + +import contextlib +import logging +import tempfile +from collections.abc import Generator +from pathlib import Path + +from fastapi import HTTPException, UploadFile + +from .config import get_config + +logger = logging.getLogger(__name__) + +# Valid image MIME types +VALID_IMAGE_TYPES = { + "image/png", + "image/jpeg", + "image/jpg", + "image/gif", + "image/webp", + "image/bmp", +} + +# Magic bytes for image file detection +IMAGE_MAGIC_BYTES = { + b'\x89PNG\r\n\x1a\n': 'image/png', + b'\xff\xd8\xff': 'image/jpeg', + b'GIF87a': 'image/gif', + b'GIF89a': 'image/gif', + b'RIFF': 'image/webp', # WebP (partial check) + b'BM': 'image/bmp', +} + + +def detect_image_type(data: bytes) -> str | None: + """Detect image type from magic bytes.""" + for magic, mime_type in IMAGE_MAGIC_BYTES.items(): + if data.startswith(magic): + return mime_type + # WebP has RIFF header but needs additional check + if data.startswith(b'RIFF') and len(data) > 12 and data[8:12] == b'WEBP': + return 'image/webp' + return None + + +async def validate_upload_file( + file: UploadFile, + max_size_mb: int | None = None, + allowed_types: set | None = None, +) -> bytes: + """ + Validate and read an uploaded file. + + Args: + file: The uploaded file to validate. + max_size_mb: Maximum file size in MB (uses config default if None). + allowed_types: Set of allowed MIME types (uses VALID_IMAGE_TYPES if None). + + Returns: + The file contents as bytes. + + Raises: + HTTPException: If validation fails. + """ + config = get_config() + + if max_size_mb is None: + max_size_mb = config.security.max_upload_size_mb + + if allowed_types is None: + allowed_types = VALID_IMAGE_TYPES + + max_size_bytes = max_size_mb * 1024 * 1024 + + # Read file content + try: + content = await file.read() + except Exception as e: + logger.warning(f"Failed to read uploaded file: {e}") + raise HTTPException( + status_code=400, + detail="Failed to read uploaded file" + ) from e + + # Check file size + if len(content) > max_size_bytes: + raise HTTPException( + status_code=413, + detail=f"File too large. Maximum size is {max_size_mb}MB" + ) + + if len(content) == 0: + raise HTTPException( + status_code=400, + detail="Uploaded file is empty" + ) + + # Detect actual content type from magic bytes + detected_type = detect_image_type(content) + + # Check declared content type + declared_type = file.content_type or "" + + # Validate content type + if detected_type is None: + # Fall back to declared type if we can't detect + if declared_type not in allowed_types: + raise HTTPException( + status_code=415, + detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}" + ) + else: + # Verify detected type matches or is valid + if detected_type not in allowed_types: + raise HTTPException( + status_code=415, + detail=f"Invalid image type detected. Allowed types: {', '.join(allowed_types)}" + ) + + logger.debug( + f"Validated upload: {file.filename}, " + f"size={len(content)} bytes, " + f"declared={declared_type}, " + f"detected={detected_type}" + ) + + return content + + +@contextlib.contextmanager +def temp_file_context( + content: bytes, + suffix: str = ".png", +) -> Generator[Path, None, None]: + """ + Context manager for creating a temporary file with content. + + Ensures the file is cleaned up even if an exception occurs. + + Args: + content: Bytes to write to the temp file. + suffix: File extension to use. + + Yields: + Path to the temporary file. + """ + tmp_path = None + try: + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(content) + tmp_path = Path(tmp.name) + yield tmp_path + finally: + if tmp_path and tmp_path.exists(): + try: + tmp_path.unlink() + except OSError as e: + logger.warning(f"Failed to delete temp file {tmp_path}: {e}") + + +@contextlib.contextmanager +def temp_output_context(suffix: str = ".png") -> Generator[Path, None, None]: + """ + Context manager for a temporary output file path. + + Creates a temp file path and ensures cleanup. + + Args: + suffix: File extension to use. + + Yields: + Path where output can be written. + """ + tmp_path = None + try: + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp_path = Path(tmp.name) + yield tmp_path + finally: + if tmp_path and tmp_path.exists(): + try: + tmp_path.unlink() + except OSError as e: + logger.warning(f"Failed to delete temp file {tmp_path}: {e}") + + +def read_and_cleanup(path: Path) -> bytes: + """Read file content and delete the file.""" + try: + with open(path, "rb") as f: + return f.read() + finally: + try: + path.unlink(missing_ok=True) + except OSError as e: + logger.warning(f"Failed to delete file {path}: {e}") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..46df3ed --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,194 @@ +"""Tests for qr_builder.config module.""" + +import os +import pytest +from unittest.mock import patch + +# Reset config before importing +os.environ.setdefault("QR_BUILDER_ENV", "development") +os.environ.setdefault("QR_BUILDER_AUTH_ENABLED", "false") + +from qr_builder.config import ( + AppConfig, + ServerConfig, + SecurityConfig, + QRConfig, + get_config, + reset_config, + _parse_bool, + _parse_list, +) + + +class TestParseHelpers: + """Tests for parsing helper functions.""" + + def test_parse_bool_true_values(self): + for val in ["true", "True", "TRUE", "1", "yes", "on"]: + assert _parse_bool(val) is True + + def test_parse_bool_false_values(self): + for val in ["false", "False", "FALSE", "0", "no", "off", ""]: + assert _parse_bool(val) is False + + def test_parse_list_basic(self): + result = _parse_list("a,b,c") + assert result == ["a", "b", "c"] + + def test_parse_list_with_spaces(self): + result = _parse_list("a, b , c") + assert result == ["a", "b", "c"] + + def test_parse_list_empty(self): + result = _parse_list("") + assert result == [] + + def test_parse_list_with_empty_items(self): + result = _parse_list("a,,b") + assert result == ["a", "b"] + + +class TestServerConfig: + """Tests for ServerConfig.""" + + def test_default_values(self): + config = ServerConfig() + assert config.host == "0.0.0.0" + assert config.port == 8000 + assert config.reload is False + assert config.workers == 1 + assert config.log_level == "info" + + +class TestSecurityConfig: + """Tests for SecurityConfig.""" + + def test_default_values(self): + config = SecurityConfig() + # Secret should be auto-generated + assert len(config.backend_secret) > 20 + assert config.auth_enabled is True + assert config.max_upload_size_mb == 10 + + +class TestQRConfig: + """Tests for QRConfig.""" + + def test_default_values(self): + config = QRConfig() + assert config.max_data_length == 4296 + assert config.max_qr_size == 4000 + assert config.min_qr_size == 21 + assert config.default_size == 500 + assert config.max_batch_size == 100 + + +class TestAppConfig: + """Tests for AppConfig.""" + + def test_from_env_development(self): + """Test loading development configuration.""" + reset_config() + env = { + "QR_BUILDER_ENV": "development", + "QR_BUILDER_AUTH_ENABLED": "false", + "QR_BUILDER_PORT": "9000", + } + with patch.dict(os.environ, env, clear=False): + reset_config() + config = AppConfig.from_env() + + assert config.environment == "development" + assert config.server.port == 9000 + assert config.security.auth_enabled is False + + def test_from_env_production_requires_secret(self): + """Test that production requires backend secret.""" + reset_config() + env = { + "QR_BUILDER_ENV": "production", + "QR_BUILDER_AUTH_ENABLED": "true", + "QR_BUILDER_BACKEND_SECRET": "", # Empty secret + } + with patch.dict(os.environ, env, clear=False): + reset_config() + with pytest.raises(ValueError, match="BACKEND_SECRET must be set"): + AppConfig.from_env() + + def test_validate_valid_config(self): + """Test validation passes for valid config.""" + config = AppConfig( + server=ServerConfig(port=8000), + security=SecurityConfig(backend_secret="test-secret"), + qr=QRConfig(), + environment="development", + ) + issues = config.validate() + assert len(issues) == 0 + + def test_validate_invalid_port(self): + """Test validation catches invalid port.""" + config = AppConfig( + server=ServerConfig(port=99999), + security=SecurityConfig(backend_secret="test"), + qr=QRConfig(), + environment="development", + ) + issues = config.validate() + assert any("Invalid port" in issue for issue in issues) + + def test_validate_invalid_qr_size(self): + """Test validation catches invalid QR size range.""" + config = AppConfig( + server=ServerConfig(), + security=SecurityConfig(backend_secret="test"), + qr=QRConfig(min_qr_size=100, max_qr_size=50), + environment="development", + ) + issues = config.validate() + assert any("min_qr_size" in issue for issue in issues) + + def test_validate_production_wildcard_cors(self): + """Test validation catches wildcard CORS in production.""" + config = AppConfig( + server=ServerConfig(), + security=SecurityConfig( + backend_secret="valid-secret", + allowed_origins=["*"], + ), + qr=QRConfig(), + environment="production", + ) + issues = config.validate() + assert any("Wildcard CORS" in issue for issue in issues) + + +class TestGetConfig: + """Tests for get_config singleton.""" + + def test_get_config_returns_same_instance(self): + """Test that get_config returns singleton.""" + reset_config() + os.environ["QR_BUILDER_ENV"] = "development" + os.environ["QR_BUILDER_AUTH_ENABLED"] = "false" + + config1 = get_config() + config2 = get_config() + assert config1 is config2 + + def test_reset_config(self): + """Test that reset_config clears singleton.""" + reset_config() + os.environ["QR_BUILDER_ENV"] = "development" + os.environ["QR_BUILDER_AUTH_ENABLED"] = "false" + + config1 = get_config() + reset_config() + + os.environ["QR_BUILDER_PORT"] = "9999" + config2 = get_config() + + assert config2.server.port == 9999 + # Clean up + os.environ["QR_BUILDER_PORT"] = "8000" + reset_config() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..775276a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,183 @@ +"""Tests for qr_builder.utils module.""" + +import os +import pytest +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +# Set test environment before imports +os.environ.setdefault("QR_BUILDER_ENV", "development") +os.environ.setdefault("QR_BUILDER_AUTH_ENABLED", "false") + +from qr_builder.utils import ( + detect_image_type, + validate_upload_file, + temp_file_context, + temp_output_context, + read_and_cleanup, + VALID_IMAGE_TYPES, +) + + +class TestDetectImageType: + """Tests for image type detection.""" + + def test_detect_png(self): + """Test PNG detection.""" + # PNG magic bytes + png_bytes = b'\x89PNG\r\n\x1a\n' + b'\x00' * 100 + assert detect_image_type(png_bytes) == 'image/png' + + def test_detect_jpeg(self): + """Test JPEG detection.""" + # JPEG magic bytes + jpeg_bytes = b'\xff\xd8\xff' + b'\x00' * 100 + assert detect_image_type(jpeg_bytes) == 'image/jpeg' + + def test_detect_gif87a(self): + """Test GIF87a detection.""" + gif_bytes = b'GIF87a' + b'\x00' * 100 + assert detect_image_type(gif_bytes) == 'image/gif' + + def test_detect_gif89a(self): + """Test GIF89a detection.""" + gif_bytes = b'GIF89a' + b'\x00' * 100 + assert detect_image_type(gif_bytes) == 'image/gif' + + def test_detect_webp(self): + """Test WebP detection.""" + # WebP has RIFF header with WEBP at offset 8 + webp_bytes = b'RIFF\x00\x00\x00\x00WEBP' + b'\x00' * 100 + assert detect_image_type(webp_bytes) == 'image/webp' + + def test_detect_bmp(self): + """Test BMP detection.""" + bmp_bytes = b'BM' + b'\x00' * 100 + assert detect_image_type(bmp_bytes) == 'image/bmp' + + def test_detect_unknown(self): + """Test unknown format returns None.""" + unknown_bytes = b'unknown format data' + assert detect_image_type(unknown_bytes) is None + + +class TestValidateUploadFile: + """Tests for file upload validation.""" + + @pytest.fixture + def mock_upload_file(self): + """Create a mock UploadFile.""" + def _create(content: bytes, content_type: str = "image/png", filename: str = "test.png"): + mock = MagicMock() + mock.read = AsyncMock(return_value=content) + mock.content_type = content_type + mock.filename = filename + return mock + return _create + + @pytest.mark.asyncio + async def test_valid_png(self, mock_upload_file): + """Test valid PNG file passes validation.""" + # Create a minimal valid PNG + png_bytes = b'\x89PNG\r\n\x1a\n' + b'\x00' * 100 + file = mock_upload_file(png_bytes, "image/png") + + content = await validate_upload_file(file) + assert content == png_bytes + + @pytest.mark.asyncio + async def test_file_too_large(self, mock_upload_file): + """Test file size limit is enforced.""" + # Create a file larger than 1MB limit + large_bytes = b'\x89PNG\r\n\x1a\n' + b'\x00' * (2 * 1024 * 1024) + file = mock_upload_file(large_bytes, "image/png") + + with pytest.raises(Exception) as exc_info: + await validate_upload_file(file, max_size_mb=1) + assert "too large" in str(exc_info.value.detail).lower() + + @pytest.mark.asyncio + async def test_empty_file(self, mock_upload_file): + """Test empty file is rejected.""" + file = mock_upload_file(b"", "image/png") + + with pytest.raises(Exception) as exc_info: + await validate_upload_file(file) + assert "empty" in str(exc_info.value.detail).lower() + + @pytest.mark.asyncio + async def test_invalid_mime_type(self, mock_upload_file): + """Test invalid MIME type is rejected.""" + # Not an image - just text data + file = mock_upload_file(b"not an image", "text/plain") + + with pytest.raises(Exception) as exc_info: + await validate_upload_file(file) + assert "Invalid" in str(exc_info.value.detail) + + +class TestTempFileContext: + """Tests for temporary file context managers.""" + + def test_temp_file_context_creates_file(self): + """Test temp file is created with content.""" + content = b"test content" + + with temp_file_context(content, suffix=".txt") as path: + assert path.exists() + assert path.read_bytes() == content + assert path.suffix == ".txt" + + # File should be cleaned up + assert not path.exists() + + def test_temp_file_context_cleanup_on_error(self): + """Test temp file is cleaned up even on error.""" + content = b"test content" + captured_path = None + + try: + with temp_file_context(content) as path: + captured_path = path + raise ValueError("Test error") + except ValueError: + pass + + # File should still be cleaned up + assert captured_path is not None + assert not captured_path.exists() + + def test_temp_output_context(self): + """Test temp output file context.""" + with temp_output_context(suffix=".png") as path: + # Write something to the file + path.write_bytes(b"test output") + assert path.exists() + + # File should be cleaned up + assert not path.exists() + + +class TestReadAndCleanup: + """Tests for read_and_cleanup function.""" + + def test_read_and_cleanup(self): + """Test file is read and deleted.""" + # Create a temp file + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + f.write(b"test content") + path = Path(f.name) + + # Read and cleanup + content = read_and_cleanup(path) + + assert content == b"test content" + assert not path.exists() + + def test_read_and_cleanup_nonexistent(self): + """Test reading nonexistent file raises error.""" + path = Path("/nonexistent/file.txt") + + with pytest.raises(FileNotFoundError): + read_and_cleanup(path)