Implement MVP: Anonymous feedback submission (User Story 1)
Complete implementation of Phase 1-3 (64 tasks): - Phase 1: Project setup with Flask, pytest, configuration - Phase 2: Core infrastructure (auth, models, services, testing) - Phase 3: Anonymous feedback submission with file uploads Features: - Anonymous feedback submission (text and/or up to 3 file attachments) - Multi-language support (any language accepted) - File validation (type, size) and virus scanning (ClamAV) - Product management with active/archived status - File-based storage with YAML metadata - User authentication system (Flask-Login) - CSRF protection and rate limiting - Test coverage: 10 passing tests (contract + integration) Security: - No IP address logging (FR-055 compliance) - File type whitelist and size limits (10MB max) - Virus scanning with graceful degradation - Filename sanitization and secure storage Test Results: - 8 contract tests passed - 2 integration tests passed - End-to-end workflow verified 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Utilities package"""
|
||||
# Utility functions for validation, security, etc.
|
||||
@@ -0,0 +1,144 @@
|
||||
"""File upload validation utilities"""
|
||||
import os
|
||||
from werkzeug.utils import secure_filename
|
||||
import clamd
|
||||
from flask import current_app
|
||||
|
||||
|
||||
# Allowed file extensions for attachments
|
||||
ALLOWED_EXTENSIONS = {
|
||||
'txt', 'log', 'pdf', 'png', 'jpg', 'jpeg', 'gif',
|
||||
'doc', 'docx', 'xls', 'xlsx', 'csv'
|
||||
}
|
||||
|
||||
# Maximum file size (10MB)
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
"""Check if file extension is allowed
|
||||
|
||||
Args:
|
||||
filename: Name of the uploaded file
|
||||
|
||||
Returns:
|
||||
bool: True if extension is allowed, False otherwise
|
||||
"""
|
||||
if not filename:
|
||||
return False
|
||||
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def validate_file_size(file_stream):
|
||||
"""Check if file size is within limits
|
||||
|
||||
Args:
|
||||
file_stream: File stream object
|
||||
|
||||
Returns:
|
||||
bool: True if size is acceptable, False otherwise
|
||||
"""
|
||||
# Seek to end to get file size
|
||||
file_stream.seek(0, os.SEEK_END)
|
||||
size = file_stream.tell()
|
||||
# Reset to beginning
|
||||
file_stream.seek(0)
|
||||
|
||||
return size <= MAX_FILE_SIZE
|
||||
|
||||
|
||||
def get_safe_filename(filename):
|
||||
"""Get secure version of filename
|
||||
|
||||
Args:
|
||||
filename: Original filename
|
||||
|
||||
Returns:
|
||||
str: Secure filename safe for filesystem storage
|
||||
"""
|
||||
return secure_filename(filename)
|
||||
|
||||
|
||||
def validate_file(file):
|
||||
"""Validate uploaded file
|
||||
|
||||
Args:
|
||||
file: Werkzeug FileStorage object
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, error_message)
|
||||
is_valid: bool indicating if file is valid
|
||||
error_message: str with error description or None
|
||||
"""
|
||||
if not file:
|
||||
return False, "No file provided"
|
||||
|
||||
if file.filename == '':
|
||||
return False, "No file selected"
|
||||
|
||||
if not allowed_file(file.filename):
|
||||
return False, f"File type not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}"
|
||||
|
||||
if not validate_file_size(file.stream):
|
||||
return False, f"File size exceeds maximum of {MAX_FILE_SIZE / (1024 * 1024):.0f}MB"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def scan_file_for_viruses(file):
|
||||
"""Scan file for viruses using ClamAV
|
||||
|
||||
Args:
|
||||
file: Werkzeug FileStorage object
|
||||
|
||||
Returns:
|
||||
tuple: (is_clean, error_message)
|
||||
is_clean: bool indicating if file is clean (True) or infected (False)
|
||||
error_message: str with error description or None
|
||||
"""
|
||||
try:
|
||||
# Connect to ClamAV daemon
|
||||
clamd_socket = current_app.config.get('CLAMD_SOCKET')
|
||||
|
||||
if not clamd_socket:
|
||||
# ClamAV not configured, skip scanning
|
||||
current_app.logger.warning("ClamAV socket not configured, skipping virus scan")
|
||||
return True, None
|
||||
|
||||
cd = clamd.ClamdUnixSocket(clamd_socket)
|
||||
|
||||
# Ping to check if ClamAV is available
|
||||
try:
|
||||
cd.ping()
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"ClamAV not available: {e}, skipping virus scan")
|
||||
return True, None
|
||||
|
||||
# Read file content
|
||||
file.stream.seek(0)
|
||||
file_data = file.stream.read()
|
||||
file.stream.seek(0) # Reset for later use
|
||||
|
||||
# Scan file
|
||||
scan_result = cd.instream(file_data)
|
||||
|
||||
# Check result
|
||||
if scan_result and 'stream' in scan_result:
|
||||
status, virus_name = scan_result['stream']
|
||||
|
||||
if status == 'OK':
|
||||
return True, None
|
||||
elif status == 'FOUND':
|
||||
return False, f"Virus detected: {virus_name}"
|
||||
else:
|
||||
return False, f"Scan error: {status}"
|
||||
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"ClamAV scanning error: {e}")
|
||||
# On error, we'll allow the file but log the error
|
||||
# In production, you might want to reject files if scanning fails
|
||||
return True, None
|
||||
Reference in New Issue
Block a user