12 Commits

13 changed files with 13529 additions and 109 deletions

24
.env Normal file
View File

@@ -0,0 +1,24 @@
# ===================================================================
# ControlPatente - Environment Configuration
# ===================================================================
# Generated on: 2026-01-29
# ===================================================================
# --- Database Configuration ---
DB_USER=postgres
DB_PASSWORD=e0p0kcnMmG8kg2YylgQ0Mw
DB_NAME=controlpatente
# --- Security Configuration ---
# JWT Secret (auto-generated)
JWT_SECRET=d95810e29f700cb99d3ed6a891ace603522875069ec655887a01629891a38ce8
# Admin password (optional - if not set, a random password will be generated on first run)
# ADMIN_PASSWORD=HnXlU0BtE5-PtQ8n
# Allowed origins for CORS (comma-separated)
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,https://demo.v1ru5.cl
# Service-to-service API key (auto-generated)
SERVICE_API_KEY=a6b73ab722d2980cafb89836393266f96bf798209b6a4ce2

43
.env.example Normal file
View File

@@ -0,0 +1,43 @@
# ===================================================================
# ControlPatente - Environment Configuration
# ===================================================================
# Copy this file to .env and configure the values before starting
#
# IMPORTANT: Never commit .env to version control!
# ===================================================================
# --- Database Configuration ---
DB_USER=postgres
DB_PASSWORD=CHANGE_THIS_PASSWORD
DB_NAME=controlpatente
# --- Security Configuration (REQUIRED) ---
# JWT Secret - REQUIRED for authentication
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
JWT_SECRET=GENERATE_A_SECURE_64_CHARACTER_HEX_STRING_HERE
# Admin password (optional - if not set, a random password will be generated)
# ADMIN_PASSWORD=your_secure_admin_password
# Allowed origins for CORS (comma-separated)
# Default: http://localhost:5173
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Service-to-service API key (for ALPR -> Backend communication)
# Generate with: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
SERVICE_API_KEY=GENERATE_A_SECURE_48_CHARACTER_HEX_STRING_HERE
# --- Optional Configuration ---
# Backend port (default: 3000)
# PORT=3000
# ALPR processing interval in seconds (default: 1.5)
# PROCESS_INTERVAL=1.5
# Dataset capture cooldown in seconds (default: 60)
# DATASET_COOLDOWN=60
# Number of OCR worker threads (default: 2)
# OCR_WORKERS=2

View File

@@ -5,16 +5,22 @@ import os
import time
import threading
import re
import numpy as np
from datetime import datetime
from queue import Queue
from flask import Flask, Response
from flask import Flask, Response, request, send_from_directory
from flask_cors import CORS
from ultralytics import YOLO
# Configuration
# Configuration (puede ser sobrescrito por variables de entorno)
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000')
CAMERA_ID = 0
PROCESS_INTERVAL = 1.5 # Más reactivo
MODEL_PATH = 'best.pt'
CAMERA_ID = int(os.environ.get('CAMERA_ID', 0))
PROCESS_INTERVAL = float(os.environ.get('PROCESS_INTERVAL', 1.5))
MODEL_PATH = os.environ.get('MODEL_PATH', 'best.pt')
DATASET_DIR = os.environ.get('DATASET_DIR', '/app/dataset')
DATASET_COOLDOWN = int(os.environ.get('DATASET_COOLDOWN', 60))
OCR_WORKERS = int(os.environ.get('OCR_WORKERS', 2)) # Número de workers OCR
SERVICE_API_KEY = os.environ.get('SERVICE_API_KEY', '') # For backend auth
app = Flask(__name__)
CORS(app)
@@ -26,50 +32,157 @@ latest_detections = []
detection_lock = threading.Lock()
# Cola para procesamiento OCR asíncrono
ocr_queue = Queue(maxsize=5)
ocr_queue = Queue(maxsize=10)
# Cooldown para evitar múltiples capturas de la misma patente
recent_captures = {} # {plate_number: timestamp}
captures_lock = threading.Lock()
# Cache para lista de dataset
dataset_cache = {'data': None, 'timestamp': 0, 'ttl': 5} # 5 segundos de cache
# Métricas para health check
metrics = {
'fps': 0,
'ocr_queue_size': 0,
'total_detections': 0,
'total_captures': 0,
'last_detection': None,
'start_time': time.time()
}
metrics_lock = threading.Lock()
# Crear carpeta de dataset si no existe
os.makedirs(DATASET_DIR, exist_ok=True)
print(f"📁 Dataset directory: {DATASET_DIR}")
def cleanup_recent_captures():
"""Limpia capturas antiguas para evitar memory leak - ejecuta cada 5 minutos"""
while True:
time.sleep(300) # 5 minutos
current_time = time.time()
with captures_lock:
expired = [k for k, v in recent_captures.items() if current_time - v > DATASET_COOLDOWN * 2]
for k in expired:
del recent_captures[k]
if expired:
print(f"🧹 Cleaned {len(expired)} expired capture records")
def save_plate_capture(plate_number, full_frame):
"""Guarda la captura de la patente para el dataset con cooldown"""
current_time = time.time()
# Validar que el frame no esté vacío
if full_frame is None or full_frame.size == 0:
print(f"⚠️ Empty frame, skipping save for {plate_number}")
return False
# Verificar cooldown
with captures_lock:
if plate_number in recent_captures:
elapsed = current_time - recent_captures[plate_number]
if elapsed < DATASET_COOLDOWN:
return False
recent_captures[plate_number] = current_time
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
frame_to_save = np.copy(full_frame)
filename = f"{plate_number}_{timestamp}.jpg"
filepath = f"{DATASET_DIR}/{filename}"
success = cv2.imwrite(filepath, frame_to_save, [cv2.IMWRITE_JPEG_QUALITY, 95])
if not success or not os.path.exists(filepath) or os.path.getsize(filepath) == 0:
print(f"❌ Failed to save image for {plate_number}")
if os.path.exists(filepath):
os.remove(filepath)
return False
# Invalidar cache
dataset_cache['timestamp'] = 0
# Actualizar métricas
with metrics_lock:
metrics['total_captures'] += 1
# Contar total de capturas
total_count = len([f for f in os.listdir(DATASET_DIR) if f.endswith('.jpg')])
# Notificar al backend
try:
requests.post(f"{BACKEND_URL}/api/dataset/capture", json={
'plate_number': plate_number,
'filename': filename,
'count': total_count
}, timeout=2)
except:
pass
print(f"📸 Saved to dataset: {plate_number} (Total: {total_count})")
return True
except Exception as e:
print(f"❌ Error saving capture: {e}")
return False
def send_plate(plate_number):
"""Envía la patente detectada al backend"""
try:
url = f"{BACKEND_URL}/api/detect"
requests.post(url, json={'plate_number': plate_number}, timeout=3)
headers = {}
if SERVICE_API_KEY:
headers['X-Service-Key'] = SERVICE_API_KEY
requests.post(url, json={'plate_number': plate_number}, headers=headers, timeout=3)
print(f"✓ Plate sent: {plate_number}")
with metrics_lock:
metrics['total_detections'] += 1
metrics['last_detection'] = plate_number
except Exception as e:
print(f"✗ Error sending plate: {e}")
def validate_and_send(text):
"""Valida formato chileno y envía"""
# Formato nuevo: XXXX-00 | Formato antiguo: XX-0000
if re.match(r'^[A-Z]{4}\d{2}$', text) or re.match(r'^[A-Z]{2}\d{4}$', text):
send_plate(text)
return True
return False
def validate_plate(text):
"""Valida formatos de patentes de Chile, Argentina y Brasil"""
# Chile formato nuevo: XXXX00 (4 letras, 2 números)
# Chile formato antiguo: XX0000 (2 letras, 4 números)
# Argentina Mercosur: AA000AA (2 letras, 3 números, 2 letras)
# Brasil Mercosur: AAA0A00 (3 letras, 1 número, 1 letra, 2 números)
chile_new = re.match(r'^[A-Z]{4}\d{2}$', text)
chile_old = re.match(r'^[A-Z]{2}\d{4}$', text)
argentina = re.match(r'^[A-Z]{2}\d{3}[A-Z]{2}$', text)
brasil = re.match(r'^[A-Z]{3}\d[A-Z]\d{2}$', text)
return bool(chile_new or chile_old or argentina or brasil)
def ocr_worker(reader):
"""Hilo dedicado para OCR - no bloquea el stream"""
def ocr_worker(reader, worker_id):
"""Hilo dedicado para OCR - múltiples workers para mejor rendimiento"""
print(f"🔤 OCR Worker {worker_id} started")
while True:
try:
plate_img = ocr_queue.get(timeout=1)
if plate_img is None:
data = ocr_queue.get(timeout=1)
if data is None:
continue
# Preprocesamiento para mejor OCR
plate_img, full_frame = data
gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY)
ocr_results = reader.readtext(gray, detail=0, paragraph=False,
allowlist='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
for text in ocr_results:
clean_text = ''.join(e for e in text if e.isalnum()).upper()
if len(clean_text) >= 6:
validate_and_send(clean_text)
if len(clean_text) >= 6 and validate_plate(clean_text):
send_plate(clean_text)
save_plate_capture(clean_text, full_frame)
except:
pass
def camera_loop():
"""Hilo principal de captura - mantiene FPS alto"""
"""Hilo principal de captura"""
global outputFrame, latest_detections
print("🚀 Initializing ALPR System...")
print(f"⚙️ Config: PROCESS_INTERVAL={PROCESS_INTERVAL}s, OCR_WORKERS={OCR_WORKERS}")
print("📷 Loading camera...")
cap = cv2.VideoCapture(CAMERA_ID)
@@ -89,17 +202,22 @@ def camera_loop():
print("📝 Initializing EasyOCR...")
reader = easyocr.Reader(['en'], gpu=False)
# Iniciar worker de OCR
ocr_thread = threading.Thread(target=ocr_worker, args=(reader,), daemon=True)
ocr_thread.start()
# Iniciar múltiples workers de OCR
for i in range(OCR_WORKERS):
t = threading.Thread(target=ocr_worker, args=(reader, i+1), daemon=True)
t.start()
# Iniciar limpiador de cache
cleanup_thread = threading.Thread(target=cleanup_recent_captures, daemon=True)
cleanup_thread.start()
print("✅ System ready!")
last_process_time = 0
frame_count = 0
fps_start_time = time.time()
while True:
# Captura eficiente - solo 2 grabs
cap.grab()
cap.grab()
ret, frame = cap.retrieve()
@@ -111,11 +229,18 @@ def camera_loop():
frame_count += 1
current_time = time.time()
# Procesar ALPR cada PROCESS_INTERVAL segundos
# Calcular FPS cada segundo
if current_time - fps_start_time >= 1.0:
with metrics_lock:
metrics['fps'] = frame_count
metrics['ocr_queue_size'] = ocr_queue.qsize()
frame_count = 0
fps_start_time = current_time
# Procesar ALPR
if current_time - last_process_time > PROCESS_INTERVAL:
last_process_time = current_time
# YOLO detection - usar imgsz pequeño para velocidad
results = model(frame, verbose=False, imgsz=320, conf=0.5)
new_detections = []
@@ -125,15 +250,13 @@ def camera_loop():
conf = float(box.conf[0])
new_detections.append((x1, y1, x2, y2, conf))
# Extraer imagen de placa y enviar a cola OCR
plate_img = frame[y1:y2, x1:x2].copy()
if plate_img.size > 0 and not ocr_queue.full():
ocr_queue.put(plate_img)
ocr_queue.put((plate_img, frame.copy()))
with detection_lock:
latest_detections = new_detections
# Actualizar frame para streaming (sin bloquear)
display_frame = frame
with detection_lock:
for (x1, y1, x2, y2, conf) in latest_detections:
@@ -148,7 +271,7 @@ def generate():
"""Generador para streaming MJPEG"""
global outputFrame
while True:
time.sleep(0.033) # ~30 FPS para el stream
time.sleep(0.033)
with frame_lock:
if outputFrame is None:
continue
@@ -161,7 +284,114 @@ def video_feed():
@app.route("/health")
def health():
return {"status": "ok", "service": "alpr"}
"""Health check completo con métricas"""
with metrics_lock:
uptime = time.time() - metrics['start_time']
return {
"status": "ok",
"service": "alpr",
"uptime_seconds": int(uptime),
"fps": metrics['fps'],
"ocr_queue_size": metrics['ocr_queue_size'],
"ocr_workers": OCR_WORKERS,
"total_detections": metrics['total_detections'],
"total_captures": metrics['total_captures'],
"last_detection": metrics['last_detection'],
"dataset_size": len([f for f in os.listdir(DATASET_DIR) if f.endswith('.jpg')])
}
@app.route("/dataset/count")
def dataset_count():
try:
files = [f for f in os.listdir(DATASET_DIR) if f.endswith('.jpg')]
return {"plates_captured": len(files), "total_files": len(files)}
except:
return {"plates_captured": 0, "total_files": 0}
@app.route("/dataset/list")
def dataset_list():
"""Lista las imágenes del dataset con paginación y cache"""
current_time = time.time()
# Usar cache si está vigente
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 50))
cache_key = f"{page}_{per_page}"
try:
# Obtener lista de archivos (con cache básico)
if dataset_cache['timestamp'] == 0 or current_time - dataset_cache['timestamp'] > dataset_cache['ttl']:
files = [f for f in os.listdir(DATASET_DIR) if f.endswith('.jpg')]
files_with_time = [(f, os.path.getmtime(os.path.join(DATASET_DIR, f))) for f in files]
files_with_time.sort(key=lambda x: x[1], reverse=True)
dataset_cache['data'] = [f[0] for f in files_with_time]
dataset_cache['timestamp'] = current_time
sorted_files = dataset_cache['data']
# Paginación
total = len(sorted_files)
total_pages = (total + per_page - 1) // per_page
start = (page - 1) * per_page
end = start + per_page
page_files = sorted_files[start:end]
images = []
for f in page_files:
parts = f.replace('.jpg', '').split('_')
plate = parts[0] if parts else 'Unknown'
images.append({
'filename': f,
'plate': plate,
'url': f'/dataset/images/{f}'
})
return {
"images": images,
"total": total,
"page": page,
"per_page": per_page,
"total_pages": total_pages
}
except Exception as e:
return {"images": [], "total": 0, "error": str(e)}
@app.route("/dataset/images/<filename>")
def dataset_image(filename):
return send_from_directory(DATASET_DIR, filename)
# SECURITY: Auth decorator for destructive operations
from functools import wraps
def require_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
if not SERVICE_API_KEY:
# No key configured = dev mode, allow but warn
print("⚠️ SERVICE_API_KEY not set - dataset DELETE unprotected!")
return f(*args, **kwargs)
provided_key = request.headers.get('X-Service-Key', '')
if provided_key != SERVICE_API_KEY:
return {"error": "Unauthorized"}, 401
return f(*args, **kwargs)
return decorated
@app.route("/dataset/images/<filename>", methods=['DELETE'])
@require_auth
def delete_dataset_image(filename):
"""Elimina una imagen del dataset"""
try:
filepath = os.path.join(DATASET_DIR, filename)
if os.path.exists(filepath):
os.remove(filepath)
# Invalidar cache
dataset_cache['timestamp'] = 0
print(f"🗑️ Deleted from dataset: {filename}")
return {"success": True, "message": f"Deleted {filename}"}
else:
return {"success": False, "message": "File not found"}, 404
except Exception as e:
return {"success": False, "message": str(e)}, 500
if __name__ == "__main__":
t = threading.Thread(target=camera_loop, daemon=True)

View File

@@ -44,4 +44,8 @@ model AccessLog {
plateNumber String
accessStatus String // GRANTED, DENIED, UNKNOWN
timestamp DateTime @default(now())
@@index([plateNumber])
@@index([timestamp])
@@index([plateNumber, timestamp])
}

View File

@@ -1,5 +1,6 @@
const express = require('express');
const cors = require('cors');
const bcrypt = require('bcryptjs'); // Movido al inicio (E)
const { PrismaClient } = require('@prisma/client');
const http = require('http');
const { Server } = require('socket.io');
@@ -7,14 +8,55 @@ const { Server } = require('socket.io');
const app = express();
const prisma = new PrismaClient();
app.use(cors());
// SECURITY: Configure CORS with specific origins
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
: ['http://localhost:5173', 'http://127.0.0.1:5173'];
app.use(cors({
origin: ALLOWED_ORIGINS,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Service-Key']
}));
app.use(express.json());
// Rate limiting simple para /api/detect (G)
const detectRateLimit = new Map();
const RATE_LIMIT_WINDOW = 1000; // 1 segundo
const RATE_LIMIT_MAX = 10; // máximo 10 requests por segundo
function checkRateLimit(ip) {
const now = Date.now();
const record = detectRateLimit.get(ip) || { count: 0, resetTime: now + RATE_LIMIT_WINDOW };
if (now > record.resetTime) {
record.count = 1;
record.resetTime = now + RATE_LIMIT_WINDOW;
} else {
record.count++;
}
detectRateLimit.set(ip, record);
return record.count <= RATE_LIMIT_MAX;
}
// Limpiar rate limit map cada minuto
setInterval(() => {
const now = Date.now();
for (const [ip, record] of detectRateLimit.entries()) {
if (now > record.resetTime + 60000) {
detectRateLimit.delete(ip);
}
}
}, 60000);
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
origin: ALLOWED_ORIGINS,
methods: ["GET", "POST"],
credentials: true
}
});
@@ -31,9 +73,7 @@ app.use('/api/auth', authRoutes);
// Plates CRUD
app.get('/api/plates', authenticateToken, async (req, res) => {
try {
// Filter based on role
const where = req.user.role === 'ADMIN' ? {} : { addedById: req.user.id };
const plates = await prisma.plate.findMany({
where,
include: { addedBy: { select: { username: true } } }
@@ -47,7 +87,6 @@ app.get('/api/plates', authenticateToken, async (req, res) => {
app.post('/api/plates', authenticateToken, async (req, res) => {
const { number, owner } = req.body;
const isAdm = req.user.role === 'ADMIN';
// Admin -> ALLOWED, User -> PENDING
const status = isAdm ? 'ALLOWED' : 'PENDING';
try {
@@ -59,10 +98,7 @@ app.post('/api/plates', authenticateToken, async (req, res) => {
addedById: req.user.id
}
});
// Notify Admin via WebSocket
io.emit('new_plate_registered', plate);
res.json(plate);
} catch (err) {
res.status(500).json({ error: err.message });
@@ -72,7 +108,7 @@ app.post('/api/plates', authenticateToken, async (req, res) => {
// Admin: Approve/Reject Plate
app.put('/api/plates/:id/approve', authenticateToken, isAdmin, async (req, res) => {
const { id } = req.params;
const { status } = req.body; // ALLOWED or DENIED
const { status } = req.body;
if (!['ALLOWED', 'DENIED'].includes(status)) {
return res.status(400).json({ error: 'Invalid status' });
@@ -83,17 +119,13 @@ app.put('/api/plates/:id/approve', authenticateToken, isAdmin, async (req, res)
where: { id: parseInt(id) },
data: { status }
});
// Notify Users via WebSocket
io.emit('plate_status_updated', plate);
res.json(plate);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Admin: Delete Plate (Optional but good to have)
// Delete Plate (Admin or Owner)
app.delete('/api/plates/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
@@ -101,15 +133,12 @@ app.delete('/api/plates/:id', authenticateToken, async (req, res) => {
const plate = await prisma.plate.findUnique({ where: { id: parseInt(id) } });
if (!plate) return res.status(404).json({ error: 'Plate not found' });
// Check permissions
if (req.user.role !== 'ADMIN' && plate.addedById !== req.user.id) {
return res.status(403).json({ error: 'Unauthorized' });
}
await prisma.plate.delete({ where: { id: parseInt(id) } });
io.emit('plate_deleted', { id: parseInt(id) });
res.json({ message: 'Plate deleted' });
} catch (err) {
res.status(500).json({ error: err.message });
@@ -128,9 +157,7 @@ app.delete('/api/people/:id', authenticateToken, async (req, res) => {
}
await prisma.person.delete({ where: { id: parseInt(id) } });
io.emit('person_deleted', { id: parseInt(id) });
res.json({ message: 'Person deleted' });
} catch (err) {
res.status(500).json({ error: err.message });
@@ -139,7 +166,7 @@ app.delete('/api/people/:id', authenticateToken, async (req, res) => {
// History Endpoint
app.get('/api/history', async (req, res) => {
const { date } = req.query; // Format: YYYY-MM-DD
const { date } = req.query;
if (!date) {
return res.status(400).json({ error: 'Date is required' });
}
@@ -158,9 +185,7 @@ app.get('/api/history', async (req, res) => {
lte: endDate
}
},
orderBy: {
timestamp: 'desc'
}
orderBy: { timestamp: 'desc' }
});
res.json(logs);
} catch (err) {
@@ -174,13 +199,9 @@ app.get('/api/recent', async (req, res) => {
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000);
const logs = await prisma.accessLog.findMany({
where: {
timestamp: {
gte: fiveHoursAgo
}
timestamp: { gte: fiveHoursAgo }
},
orderBy: {
timestamp: 'desc'
}
orderBy: { timestamp: 'desc' }
});
res.json(logs);
} catch (err) {
@@ -238,10 +259,7 @@ app.post('/api/people', authenticateToken, async (req, res) => {
addedById: req.user.id
}
});
// Notify Admin via WebSocket
io.emit('new_person_registered', person);
res.json(person);
} catch (err) {
res.status(500).json({ error: err.message });
@@ -256,25 +274,46 @@ app.post('/api/people/bulk-approve', authenticateToken, isAdmin, async (req, res
where: { addedById: parseInt(userId), status: 'PENDING' },
data: { status: 'APPROVED' }
});
// Notify Users via WebSocket
io.emit('people_updated', { userId });
res.json({ message: 'Bulk approval successful' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Detection Endpoint (from Python)
app.post('/api/detect', async (req, res) => {
// Detection Endpoint (from Python) with Rate Limiting and Service Auth
// SECURITY: Requires X-Service-Key header for service-to-service auth
const validateServiceKey = (req, res, next) => {
const serviceKey = process.env.SERVICE_API_KEY;
// If no key configured, allow (development mode) but warn
if (!serviceKey) {
console.warn('⚠️ SERVICE_API_KEY not configured - /api/detect is unprotected!');
return next();
}
const providedKey = req.headers['x-service-key'];
if (providedKey !== serviceKey) {
console.warn(`🔒 Rejected /api/detect request - invalid service key from ${req.ip}`);
return res.status(401).json({ error: 'Invalid service key' });
}
next();
};
app.post('/api/detect', validateServiceKey, async (req, res) => {
const clientIp = req.ip || req.connection.remoteAddress;
// Check rate limit
if (!checkRateLimit(clientIp)) {
return res.status(429).json({ error: 'Too many requests' });
}
const { plate_number } = req.body;
console.log(`Detected: ${plate_number}`);
const DUPLICATE_COOLDOWN_MS = 30000; // 30 seconds
const DUPLICATE_COOLDOWN_MS = 30000;
try {
// Check for recent duplicate
const lastLog = await prisma.accessLog.findFirst({
where: { plateNumber: plate_number },
orderBy: { timestamp: 'desc' }
@@ -288,7 +327,6 @@ app.post('/api/detect', async (req, res) => {
}
}
// Check if plate exists
let plate = await prisma.plate.findUnique({
where: { number: plate_number }
});
@@ -300,12 +338,9 @@ app.post('/api/detect', async (req, res) => {
}
if (!plate) {
// Optional: Auto-create unknown plates?
// For now, treat as UNKNOWN (Denied)
accessStatus = 'UNKNOWN';
}
// Log the access attempt
const log = await prisma.accessLog.create({
data: {
plateNumber: plate_number,
@@ -314,7 +349,6 @@ app.post('/api/detect', async (req, res) => {
}
});
// Notify Frontend via WebSocket
io.emit('new_detection', {
plate: plate_number,
status: accessStatus,
@@ -329,7 +363,30 @@ app.post('/api/detect', async (req, res) => {
}
});
const bcrypt = require('bcryptjs');
// Dataset Capture Notification (from ALPR Python)
app.post('/api/dataset/capture', (req, res) => {
const { plate_number, filename, count } = req.body;
console.log(`📸 Dataset capture: ${plate_number} (Total: ${count})`);
io.emit('dataset_updated', {
plate: plate_number,
filename,
count,
timestamp: new Date()
});
res.json({ message: 'Notification sent' });
});
// Health check endpoint
app.get('/api/health', async (req, res) => {
try {
await prisma.$queryRaw`SELECT 1`;
res.json({ status: 'ok', database: 'connected' });
} catch (err) {
res.status(500).json({ status: 'error', database: 'disconnected' });
}
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, async () => {
@@ -340,7 +397,18 @@ server.listen(PORT, async () => {
const userCount = await prisma.user.count();
if (userCount === 0) {
console.log('No users found. Creating default admin user...');
const hashedPassword = await bcrypt.hash('admin123', 10);
// SECURITY: Use env var or generate random password
let adminPassword = process.env.ADMIN_PASSWORD;
let isGenerated = false;
if (!adminPassword) {
// Generate a secure random password
adminPassword = require('crypto').randomBytes(12).toString('base64url');
isGenerated = true;
}
const hashedPassword = await bcrypt.hash(adminPassword, 10);
await prisma.user.create({
data: {
username: 'admin',
@@ -348,7 +416,18 @@ server.listen(PORT, async () => {
role: 'ADMIN'
}
});
console.log('Default admin created: admin / admin123');
console.log('═'.repeat(50));
console.log('🔐 ADMIN USER CREATED');
console.log(' Username: admin');
if (isGenerated) {
console.log(` Password: ${adminPassword}`);
console.log(' ⚠️ SAVE THIS PASSWORD - it won\'t be shown again!');
console.log(' 💡 Set ADMIN_PASSWORD env var to use a custom password');
} else {
console.log(' Password: [from ADMIN_PASSWORD env var]');
}
console.log('═'.repeat(50));
}
} catch (err) {
console.error('Error seeding admin user:', err);

View File

@@ -1,6 +1,12 @@
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
// SECURITY: JWT_SECRET must be configured via environment variable
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
console.error('❌ FATAL: JWT_SECRET environment variable is required');
console.error(' Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
process.exit(1);
}
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];

View File

@@ -11,8 +11,10 @@ services:
POSTGRES_DB: ${DB_NAME:-controlpatente}
volumes:
- db_data:/var/lib/postgresql/data
ports:
- "5432:5432"
# SECURITY: Port not exposed externally - only accessible within Docker network
# Uncomment for local development debugging only
# ports:
# - "5432:5432"
networks:
- backend-net
restart: unless-stopped
@@ -21,6 +23,11 @@ services:
interval: 5s
timeout: 5s
retries: 5
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Backend Service (Node.js)
backend:
@@ -29,6 +36,10 @@ services:
environment:
- DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-controlpatente}
- PORT=3000
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:5173}
- SERVICE_API_KEY=${SERVICE_API_KEY:-}
ports:
- "3000:3000"
depends_on:
@@ -40,26 +51,57 @@ services:
volumes:
- ./backend:/app
- /app/node_modules
healthcheck:
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ALPR Component (Python + OpenCV)
alpr-service:
build: ./alpr-service
container_name: controlpatente-alpr
ports:
- "5001:5001" # Permite acceder al stream de video desde el nave
- "5001:5001"
environment:
- BACKEND_URL=http://backend:3000
# On Mac, you usually cannot pass /dev/video0 directly.
# We might need to use a stream or just test with a file for now if direct access fails.
# For Linux/Raspberry Pi, the device mapping below is correct.
- PROCESS_INTERVAL=1.5
- DATASET_COOLDOWN=60
- OCR_WORKERS=2
- SERVICE_API_KEY=${SERVICE_API_KEY:-}
devices:
- "/dev/video0:/dev/video0"
networks:
- backend-net
depends_on:
- backend
backend:
condition: service_healthy
restart: unless-stopped
privileged: true
# SECURITY: Use specific capabilities instead of privileged mode
# privileged: true # REMOVED - security risk
cap_add:
- SYS_RAWIO
security_opt:
- no-new-privileges:true
volumes:
- ./alpr-service/dataset:/app/dataset
healthcheck:
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:5001/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "20m"
max-file: "5"
# Frontend Service (React)
frontend:
@@ -76,6 +118,12 @@ services:
environment:
- VITE_API_URL=
- VITE_ALPR_STREAM_URL=
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
backend-net:
driver: bridge

View File

@@ -1,13 +1,193 @@
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>Control de Patentes - ALPR</title>
<meta name="description" content="Sistema de Control de Acceso mediante Reconocimiento Automático de Patentes" />
<!-- Loading Screen Styles (inline para carga inmediata) -->
<style>
/* Loading screen container */
#loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
transition: opacity 0.5s ease-out;
}
#loading-screen.fade-out {
opacity: 0;
pointer-events: none;
}
/* Logo container with glow effect */
.loading-logo {
width: 120px;
height: 120px;
margin-bottom: 30px;
animation: pulse 2s ease-in-out infinite;
}
.loading-logo img {
width: 100%;
height: 100%;
object-fit: contain;
filter: drop-shadow(0 0 20px rgba(59, 130, 246, 0.5));
}
/* Spinner ring */
.spinner-ring {
width: 60px;
height: 60px;
border: 3px solid rgba(59, 130, 246, 0.2);
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 24px;
}
/* Loading text */
.loading-text {
color: #94a3b8;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 8px;
}
.loading-subtext {
color: #64748b;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
}
/* Progress bar */
.progress-container {
width: 200px;
height: 4px;
background: rgba(59, 130, 246, 0.2);
border-radius: 2px;
overflow: hidden;
margin-top: 20px;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #3b82f6);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
width: 100%;
}
/* Animations */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.8;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Dots animation */
.loading-dots::after {
content: '';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%,
20% {
content: '';
}
40% {
content: '.';
}
60% {
content: '..';
}
80%,
100% {
content: '...';
}
}
</style>
</head>
<body>
<!-- Loading Screen (se remueve cuando React carga) -->
<div id="loading-screen">
<!-- Logo personalizable - coloca tu imagen en /public/logo.png -->
<div class="loading-logo">
<img src="/logo.png" alt="Logo" onerror="this.parentElement.style.display='none'" />
</div>
<!-- Spinner -->
<div class="spinner-ring"></div>
<!-- Loading text -->
<div class="loading-text">Control de Patentes</div>
<div class="loading-subtext">Cargando sistema<span class="loading-dots"></span></div>
<!-- Progress bar -->
<div class="progress-container">
<div class="progress-bar"></div>
</div>
</div>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<!-- Script para remover loading screen cuando React está listo -->
<script>
// Fallback: remover loading screen después de 10 segundos máximo
setTimeout(function () {
var loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
loadingScreen.classList.add('fade-out');
setTimeout(function () {
loadingScreen.remove();
}, 500);
}
}, 10000);
</script>
</body>
</html>

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 641 KiB

View File

@@ -1,10 +1,27 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import './i18n'
import App from './App.jsx'
// Función para remover la pantalla de carga
const removeLoadingScreen = () => {
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
loadingScreen.classList.add('fade-out');
setTimeout(() => {
loadingScreen.remove();
}, 500);
}
};
// Renderizar React y remover loading screen
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
// Remover loading screen cuando React esté montado
// Pequeño delay para asegurar que la UI esté lista
setTimeout(removeLoadingScreen, 100);

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import io from 'socket.io-client';
import { Users, CheckCircle, XCircle, Shield, Trash2, Camera, AlertCircle } from 'lucide-react';
import { Users, CheckCircle, XCircle, Shield, Trash2, Camera, AlertCircle, Database, X, Image, ChevronLeft, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import LanguageSelector from '../components/LanguageSelector';
@@ -26,23 +26,41 @@ function AdminDashboard({ token }) {
const [searchRut, setSearchRut] = useState('');
const [searchResult, setSearchResult] = useState(null); // null | { found: true, data: ... } | { found: false }
// Dataset State
const [datasetCount, setDatasetCount] = useState(0);
const [showDatasetModal, setShowDatasetModal] = useState(false);
const [datasetImages, setDatasetImages] = useState([]);
const [selectedImage, setSelectedImage] = useState(null);
const [selectedImageIndex, setSelectedImageIndex] = useState(-1);
const [datasetPage, setDatasetPage] = useState(1);
const [datasetTotalPages, setDatasetTotalPages] = useState(1);
const [datasetTotal, setDatasetTotal] = useState(0);
const [deletingImage, setDeletingImage] = useState(false);
useEffect(() => {
fetchData();
fetchDatasetCount();
// Live detection listener
socket.on('new_detection', (data) => {
setDetections(prev => [data, ...prev].slice(0, 10));
});
// Real-time dataset updates
socket.on('dataset_updated', (data) => {
setDatasetCount(data.count);
});
// Real-time updates for approvals
socket.on('new_plate_registered', () => fetchData());
socket.on('new_person_registered', () => fetchData());
socket.on('plate_status_updated', () => fetchData()); // Reused for consistency
socket.on('plate_status_updated', () => fetchData());
socket.on('plate_deleted', () => fetchData());
socket.on('person_deleted', () => fetchData());
return () => {
socket.off('new_detection');
socket.off('dataset_updated');
socket.off('new_plate_registered');
socket.off('new_person_registered');
socket.off('plate_status_updated');
@@ -51,6 +69,35 @@ function AdminDashboard({ token }) {
};
}, [token]);
// Keyboard navigation for dataset gallery
useEffect(() => {
const handleKeyDown = (e) => {
if (!showDatasetModal || !selectedImage) return;
if (e.key === 'ArrowLeft' && selectedImageIndex > 0) {
e.preventDefault();
setSelectedImageIndex(prev => {
const newIndex = prev - 1;
setSelectedImage(datasetImages[newIndex]);
return newIndex;
});
} else if (e.key === 'ArrowRight' && selectedImageIndex < datasetImages.length - 1) {
e.preventDefault();
setSelectedImageIndex(prev => {
const newIndex = prev + 1;
setSelectedImage(datasetImages[newIndex]);
return newIndex;
});
} else if (e.key === 'Escape') {
setSelectedImage(null);
setSelectedImageIndex(-1);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showDatasetModal, selectedImage, selectedImageIndex, datasetImages]);
useEffect(() => {
if (viewMode === 'history' && activeTab === 'monitor') {
fetchHistory(selectedDate);
@@ -88,6 +135,86 @@ function AdminDashboard({ token }) {
}
};
const fetchDatasetCount = async () => {
try {
const res = await axios.get('/dataset/count');
setDatasetCount(res.data.plates_captured || 0);
} catch (err) {
console.log('Dataset count not available');
}
};
const fetchDatasetImages = async (page = 1) => {
try {
const res = await axios.get(`/dataset/list?page=${page}&per_page=50`);
setDatasetImages(res.data.images || []);
setDatasetPage(res.data.page || 1);
setDatasetTotalPages(res.data.total_pages || 1);
setDatasetTotal(res.data.total || 0);
} catch (err) {
console.error('Error fetching dataset images');
}
};
const openDatasetModal = () => {
setDatasetPage(1);
fetchDatasetImages(1);
setShowDatasetModal(true);
};
const handleDatasetPageChange = (newPage) => {
if (newPage >= 1 && newPage <= datasetTotalPages) {
setDatasetPage(newPage);
fetchDatasetImages(newPage);
}
};
const selectImageByIndex = (index) => {
if (index >= 0 && index < datasetImages.length) {
setSelectedImage(datasetImages[index]);
setSelectedImageIndex(index);
}
};
const navigateImage = (direction) => {
const newIndex = selectedImageIndex + direction;
if (newIndex >= 0 && newIndex < datasetImages.length) {
selectImageByIndex(newIndex);
}
};
const deleteCurrentImage = async () => {
if (!selectedImage || deletingImage) return;
if (!confirm(`¿Eliminar imagen de ${selectedImage.plate}?`)) return;
setDeletingImage(true);
try {
await axios.delete(`/dataset/images/${selectedImage.filename}`);
// Remover de la lista local
const newImages = datasetImages.filter((_, idx) => idx !== selectedImageIndex);
setDatasetImages(newImages);
setDatasetTotal(prev => prev - 1);
setDatasetCount(prev => prev - 1);
// Navegar a la siguiente imagen o cerrar si no hay más
if (newImages.length === 0) {
setSelectedImage(null);
setSelectedImageIndex(-1);
} else if (selectedImageIndex >= newImages.length) {
selectImageByIndex(newImages.length - 1);
} else {
setSelectedImage(newImages[selectedImageIndex]);
}
} catch (err) {
console.error('Error deleting image:', err);
alert('Error al eliminar la imagen');
} finally {
setDeletingImage(false);
}
};
const handleSearchRut = (e) => {
e.preventDefault();
const normalizedRut = searchRut.replace(/\./g, '').toUpperCase();
@@ -201,11 +328,22 @@ function AdminDashboard({ token }) {
<h2 className="text-xl font-bold flex items-center gap-2">
<Camera /> {t('monitor_area')}
</h2>
<div className="flex items-center gap-4">
{/* Dataset Counter - Clickable */}
<button
onClick={openDatasetModal}
className="flex items-center gap-2 bg-gradient-to-r from-emerald-900/50 to-teal-900/50 px-4 py-2 rounded-lg border border-emerald-700/50 hover:from-emerald-800/50 hover:to-teal-800/50 transition-all cursor-pointer"
>
<Database size={18} className="text-emerald-400" />
<span className="text-emerald-300 font-mono font-bold">{datasetCount}</span>
<span className="text-emerald-400/70 text-sm">capturas</span>
</button>
<div className="flex bg-slate-800 rounded p-1">
<button onClick={() => setViewMode('live')} className={`px-3 py-1 rounded ${viewMode === 'live' ? 'bg-blue-600' : ''}`}>Live</button>
<button onClick={() => setViewMode('history')} className={`px-3 py-1 rounded ${viewMode === 'history' ? 'bg-blue-600' : ''}`}>History</button>
</div>
</div>
</div>
{/* Visitor Lookup Banner */}
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
@@ -455,10 +593,153 @@ function AdminDashboard({ token }) {
</div>
)}
</div>
{/* Dataset Gallery Modal */}
{showDatasetModal && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-slate-900 rounded-2xl border border-slate-700 w-full max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700">
<div className="flex items-center gap-3">
<Database className="text-emerald-400" size={24} />
<h2 className="text-xl font-bold text-white">Dataset de Capturas</h2>
<span className="bg-emerald-600 px-2 py-1 rounded text-sm font-mono">{datasetTotal} imágenes</span>
</div>
<button
onClick={() => { setShowDatasetModal(false); setSelectedImage(null); }}
className="text-slate-400 hover:text-white p-2 hover:bg-slate-800 rounded-lg transition-colors"
>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4">
{selectedImage ? (
/* Image Preview with Navigation */
<div className="flex flex-col items-center gap-4">
{/* Top Controls */}
<div className="w-full flex items-center justify-between">
<button
onClick={() => { setSelectedImage(null); setSelectedImageIndex(-1); }}
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors"
>
Volver a galería
</button>
<div className="text-slate-400 text-sm">
{selectedImageIndex + 1} de {datasetImages.length}
</div>
<button
onClick={deleteCurrentImage}
disabled={deletingImage}
className="flex items-center gap-2 px-3 py-2 bg-red-600 hover:bg-red-500 disabled:bg-red-800 disabled:cursor-not-allowed rounded-lg transition-colors"
>
<Trash2 size={16} />
{deletingImage ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
{/* Image with Navigation Arrows */}
<div className="relative flex items-center gap-4 w-full justify-center">
{/* Previous Button */}
<button
onClick={() => navigateImage(-1)}
disabled={selectedImageIndex <= 0}
className="p-3 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed rounded-full transition-colors"
>
<ChevronLeft size={24} />
</button>
{/* Image */}
<img
src={selectedImage.url}
alt={selectedImage.plate}
className="max-w-[70%] max-h-[55vh] rounded-lg border border-slate-700"
/>
{/* Next Button */}
<button
onClick={() => navigateImage(1)}
disabled={selectedImageIndex >= datasetImages.length - 1}
className="p-3 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed rounded-full transition-colors"
>
<ChevronRight size={24} />
</button>
</div>
{/* Plate Info */}
<div className="bg-slate-800 px-4 py-2 rounded-lg">
<span className="text-slate-400">Patente: </span>
<span className="font-mono font-bold text-emerald-400 text-lg">{selectedImage.plate}</span>
</div>
{/* Keyboard hint */}
<div className="text-slate-500 text-xs">
Usa para navegar
</div>
</div>
) : (
/* Image Grid */
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{datasetImages.map((img, idx) => (
<div
key={idx}
onClick={() => selectImageByIndex(idx)}
className="cursor-pointer group relative aspect-video bg-slate-800 rounded-lg overflow-hidden border border-slate-700 hover:border-emerald-500 transition-all"
>
<img
src={img.url}
alt={img.plate}
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
loading="lazy"
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-2">
<span className="font-mono text-sm text-emerald-300 font-bold">{img.plate}</span>
</div>
</div>
))}
{datasetImages.length === 0 && (
<div className="col-span-full text-center py-12 text-slate-500">
<Image size={48} className="mx-auto mb-4 opacity-50" />
<p>No hay capturas en el dataset</p>
</div>
)}
</div>
)}
</div>
{/* Pagination Footer */}
{!selectedImage && datasetTotalPages > 1 && (
<div className="flex items-center justify-center gap-4 p-4 border-t border-slate-700 bg-slate-800/50">
<button
onClick={() => handleDatasetPageChange(datasetPage - 1)}
disabled={datasetPage === 1}
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={18} />
Anterior
</button>
<div className="flex items-center gap-2 text-slate-300">
<span>Página</span>
<span className="bg-emerald-600 px-3 py-1 rounded font-mono font-bold">{datasetPage}</span>
<span>de</span>
<span className="font-mono font-bold">{datasetTotalPages}</span>
</div>
<button
onClick={() => handleDatasetPageChange(datasetPage + 1)}
disabled={datasetPage === datasetTotalPages}
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Siguiente
<ChevronRight size={18} />
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}
export default AdminDashboard;

View File

@@ -18,6 +18,10 @@ export default defineConfig({
'/video_feed': {
target: 'http://alpr-service:5001',
changeOrigin: true
},
'/dataset': {
target: 'http://alpr-service:5001',
changeOrigin: true
}
}
}