Fix code review issues: security, race conditions, resource leaks

- Fix race condition: pass return_timestamps as parameter instead of mutating shared state
- Remove /local/transcriptions endpoint (path traversal vulnerability)
- Add timeout=30 and URL scheme validation to prevent SSRF
- Add temp file cleanup via try/finally in all route handlers
- AsyncTaskManager: add threading.Lock and TTL cleanup for completed tasks
- Change audio_rate to 16000 (matches Whisper's expected sample rate)
- Extract filename from URL via Content-Disposition/path
- Detect base64 audio format via python-magic
- Fix response_size_bytes to use json.dumps instead of str()
- Move scipy import to module level
- Clamp temperature to [0.0, 1.0]
- Deduplicate _load_model() code
- Fix docstrings, README structure, typo, log level

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serge Zaigraeff
2026-03-30 00:54:50 +03:00
co-authored by Claude Opus 4.6
parent ff4bd2ba3d
commit 2138651474
12 changed files with 153 additions and 189 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ class AudioProcessor:
output_path
]
logger.info(f"Конвертация в WAV: {' '.join(cmd)}")
logger.debug(f"Конвертация в WAV: {' '.join(cmd)}")
try:
subprocess.run(cmd, check=True, capture_output=True)
+33 -27
View File
@@ -1,12 +1,14 @@
"""
Модуль sources.py содержит функции для получения аудиофайлов
из различных источников (загруженные файлы, URL, base64, локальные файлы).
из различных источников (загруженные файлы, URL, base64).
"""
import os
import uuid
import tempfile
import base64
from urllib.parse import urlparse
import magic
import requests
from typing import Tuple, Optional
import logging
@@ -66,7 +68,11 @@ def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str],
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
"""
try:
response = requests.get(url, stream=True)
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return None, None, f"Unsupported URL scheme: {parsed.scheme}. Only http/https allowed"
response = requests.get(url, stream=True, timeout=30)
response.raise_for_status()
# Проверка размера по Content-Length
@@ -76,12 +82,22 @@ def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str],
if error:
return None, None, error
# Извлекаем имя файла из Content-Disposition или URL
original_name = None
cd = response.headers.get('Content-Disposition', '')
if 'filename=' in cd:
original_name = cd.split('filename=')[-1].strip('" ')
if not original_name:
url_path = parsed.path.rstrip('/')
if url_path:
original_name = os.path.basename(url_path)
temp_path = _make_temp_path()
with open(temp_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
return temp_path, os.path.basename(temp_path), None
return temp_path, original_name or os.path.basename(temp_path), None
except Exception as e:
logger.error(f"Ошибка при получении файла по URL {url}: {e}")
@@ -102,7 +118,20 @@ def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Opti
if error:
return None, None, error
temp_path = _make_temp_path()
# Определяем формат по содержимому
mime_to_ext = {
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/flac": ".flac",
"audio/mp4": ".m4a",
"audio/x-m4a": ".m4a",
"audio/aac": ".aac",
"audio/webm": ".webm",
}
detected_mime = magic.from_buffer(audio_data[:1024], mime=True)
suffix = mime_to_ext.get(detected_mime, ".wav")
temp_path = _make_temp_path(suffix)
with open(temp_path, 'wb') as f:
f.write(audio_data)
@@ -111,26 +140,3 @@ def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Opti
except Exception as e:
logger.error(f"Ошибка при декодировании base64 данных: {e}")
return None, None, f"Error decoding base64 data: {str(e)}"
def get_local_file(file_path: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""
Получает локальный аудиофайл.
Returns:
Кортеж (путь к файлу, имя файла, сообщение об ошибке).
Примечание: возвращает оригинальный путь, не копирует файл.
"""
if not os.path.exists(file_path):
return None, None, f"File not found: {file_path}"
try:
error = _check_size(os.path.getsize(file_path), max_file_size_mb)
if error:
return None, None, error
return file_path, os.path.basename(file_path), None
except Exception as e:
logger.error(f"Ошибка при открытии локального файла {file_path}: {e}")
return None, None, f"Error opening local file: {str(e)}"
+2 -2
View File
@@ -6,6 +6,7 @@ import os
import subprocess
import wave
import numpy as np
from scipy.signal import resample as scipy_resample
import logging
from typing import Tuple
@@ -33,9 +34,8 @@ def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
sampling_rate = wav_file.getframerate()
if sampling_rate != sr:
from scipy.signal import resample
num_samples = int(len(audio_array) * sr / sampling_rate)
audio_array = resample(audio_array, num_samples)
audio_array = scipy_resample(audio_array, num_samples)
sampling_rate = sr
return audio_array, sampling_rate
+31 -33
View File
@@ -117,32 +117,25 @@ class WhisperTranscriber:
"""
logger.info(f"Загрузка модели из {self.model_path}")
model_kwargs = dict(
torch_dtype=self.torch_dtype,
low_cpu_mem_usage=True,
use_safetensors=True,
)
try:
# Проверка возможности использования Flash Attention 2
if self.device.type == "cuda":
self.model = WhisperForConditionalGeneration.from_pretrained(
self.model_path,
torch_dtype=self.torch_dtype,
low_cpu_mem_usage=True,
use_safetensors=True,
attn_implementation="flash_attention_2"
).to(self.device)
model_kwargs["attn_implementation"] = "flash_attention_2"
self.model = WhisperForConditionalGeneration.from_pretrained(
self.model_path, **model_kwargs
).to(self.device)
if self.device.type == "cuda":
logger.info("Используется Flash Attention 2")
else:
self.model = WhisperForConditionalGeneration.from_pretrained(
self.model_path,
torch_dtype=self.torch_dtype,
low_cpu_mem_usage=True,
use_safetensors=True
).to(self.device)
except Exception as e:
logger.warning(f"Не удалось загрузить модель с Flash Attention: {e}")
# Fallback к обычной версии
model_kwargs.pop("attn_implementation", None)
self.model = WhisperForConditionalGeneration.from_pretrained(
self.model_path,
torch_dtype=self.torch_dtype,
low_cpu_mem_usage=True,
use_safetensors=True
self.model_path, **model_kwargs
).to(self.device)
self.processor = WhisperProcessor.from_pretrained(self.model_path)
@@ -161,37 +154,41 @@ class WhisperTranscriber:
logger.info("Модель успешно загружена и готова к использованию")
def transcribe(self, audio_path: str) -> Union[str, Dict]:
def transcribe(self, audio_path: str, return_timestamps: bool = None) -> Union[str, Dict]:
"""
Транскрибация аудиофайла.
Args:
audio_path: Путь к обработанному аудиофайлу.
return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига.
Returns:
В зависимости от параметра return_timestamps:
- Если return_timestamps=False: строка с распознанным текстом
- Если return_timestamps=True: словарь с ключами "segments" (список словарей с ключами start_time_ms, end_time_ms, text) и "text" (полный текст)
"""
if return_timestamps is None:
return_timestamps = self.return_timestamps
logger.info(f"Начало транскрибации файла: {audio_path}")
try:
# Загрузка аудио в формате numpy array
audio_array, sampling_rate = load_audio(audio_path, sr=16000)
# Транскрибация с корректным форматом данных
result = self.asr_pipeline(
{"raw": audio_array, "sampling_rate": sampling_rate},
{"raw": audio_array, "sampling_rate": sampling_rate},
generate_kwargs={
"language": self.language,
"max_new_tokens": self.max_new_tokens,
"language": self.language,
"max_new_tokens": self.max_new_tokens,
"temperature": self.temperature
},
return_timestamps=self.return_timestamps
return_timestamps=return_timestamps
)
# Если временные метки не запрошены, возвращаем только текст
if not self.return_timestamps:
if not return_timestamps:
transcribed_text = result.get("text", "")
logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста")
return transcribed_text
@@ -241,13 +238,14 @@ class WhisperTranscriber:
logger.error(f"Traceback: {traceback.format_exc()}")
raise
def process_file(self, input_path: str) -> Union[str, Dict]:
def process_file(self, input_path: str, return_timestamps: bool = None) -> Union[str, Dict]:
"""
Полный процесс обработки и транскрибации аудиофайла.
Args:
input_path: Путь к исходному аудиофайлу.
return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига.
Returns:
В зависимости от параметра return_timestamps:
- Если return_timestamps=False: строка с распознанным текстом
@@ -263,7 +261,7 @@ class WhisperTranscriber:
processed_path, temp_files = self.audio_processor.process_audio(input_path)
# Транскрибация
result = self.transcribe(processed_path)
result = self.transcribe(processed_path, return_timestamps=return_timestamps)
elapsed_time = time.time() - start_time
logger.info(f"Обработка и транскрибация завершены за {elapsed_time:.2f} секунд")
+5 -11
View File
@@ -3,6 +3,7 @@
который отвечает за транскрибацию аудиофайлов.
"""
import json
import os
import time
import traceback
@@ -36,7 +37,7 @@ class TranscriptionService:
"""
params = params or {}
language = params.get('language', self.config.get('language', 'en'))
temperature = float(params.get('temperature', 0.0))
temperature = max(0.0, min(1.0, float(params.get('temperature', 0.0))))
prompt = params.get('prompt', '')
# Проверяем, запрошены ли временные метки
@@ -44,10 +45,6 @@ class TranscriptionService:
if isinstance(return_timestamps, str):
return_timestamps = return_timestamps.lower() in ('true', 't', 'yes', 'y', '1')
# Временно изменяем настройку return_timestamps в транскрайбере
original_return_timestamps = self.transcriber.return_timestamps
self.transcriber.return_timestamps = return_timestamps
try:
# Определяем длительность аудиофайла
try:
@@ -57,7 +54,7 @@ class TranscriptionService:
return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500
start_time = time.time()
result = self.transcriber.process_file(file_path)
result = self.transcriber.process_file(file_path, return_timestamps=return_timestamps)
processing_time = time.time() - start_time
# Формируем ответ
@@ -66,7 +63,7 @@ class TranscriptionService:
"segments": result.get("segments", []),
"text": result.get("text", ""),
"processing_time": processing_time,
"response_size_bytes": len(str(result).encode('utf-8')),
"response_size_bytes": len(json.dumps(result, ensure_ascii=False).encode('utf-8')),
"duration_seconds": duration,
"model": os.path.basename(self.config["model_path"])
}
@@ -74,7 +71,7 @@ class TranscriptionService:
response = {
"text": result,
"processing_time": processing_time,
"response_size_bytes": len(str(result).encode('utf-8')),
"response_size_bytes": len(json.dumps(result, ensure_ascii=False).encode('utf-8')),
"duration_seconds": duration,
"model": os.path.basename(self.config["model_path"])
}
@@ -86,6 +83,3 @@ class TranscriptionService:
logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}")
logger.error(f"Traceback: {traceback.format_exc()}")
return {"error": str(e)}, 500
finally:
self.transcriber.return_timestamps = original_return_timestamps
+2 -2
View File
@@ -1,5 +1,5 @@
"""
Модуль history_logger.py — сохранение истории транскрибации в JSON-файлы.
Модуль history.py — сохранение истории транскрибации в JSON-файлы.
"""
import os
@@ -13,7 +13,7 @@ import logging
logger = logging.getLogger('app.history')
# Корневая директория истории (относительно корня проекта)
_history_root = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "history")
_history_root = os.path.join(os.path.dirname(os.path.dirname(__file__)), "history")
def save_history(result: Dict[str, Any], original_filename: str, config: Dict) -> Optional[str]:
+45 -31
View File
@@ -1,11 +1,11 @@
"""
Модуль manager.py содержит функции для асинхронной обработки задач.
Модуль async_tasks.py содержит функции для асинхронной обработки задач.
"""
import uuid
import time
from typing import Dict, Any, Callable, Optional
from threading import Thread
from threading import Thread, Lock
import logging
logger = logging.getLogger('app.async_tasks')
@@ -19,10 +19,13 @@ class AsyncTaskManager:
tasks (Dict): Словарь для хранения информации о задачах.
"""
TASK_TTL = 3600 # 1 час
def __init__(self):
"""
Инициализация менеджера асинхронных задач.
"""
self._lock = Lock()
self.tasks: Dict[str, Dict[str, Any]] = {}
def run_task(self, func: Callable, *args, **kwargs) -> str:
@@ -38,23 +41,33 @@ class AsyncTaskManager:
ID задачи.
"""
task_id = str(uuid.uuid4())
# Создание информации о задаче
self.tasks[task_id] = {
"status": "pending",
"result": None,
"error": None,
"created_at": time.time(),
"started_at": None,
"completed_at": None
}
# Создание и запуск потока
with self._lock:
self._cleanup_old_tasks()
self.tasks[task_id] = {
"status": "pending",
"result": None,
"error": None,
"created_at": time.time(),
"started_at": None,
"completed_at": None
}
thread = Thread(target=self._run_task_thread, args=(task_id, func, args, kwargs))
thread.daemon = True
thread.start()
return task_id
def _cleanup_old_tasks(self) -> None:
"""Удаляет завершённые задачи старше TASK_TTL. Вызывать под self._lock."""
now = time.time()
expired = [
tid for tid, info in self.tasks.items()
if info["completed_at"] and now - info["completed_at"] > self.TASK_TTL
]
for tid in expired:
del self.tasks[tid]
def _run_task_thread(self, task_id: str, func: Callable, args: tuple, kwargs: dict) -> None:
"""
@@ -66,26 +79,25 @@ class AsyncTaskManager:
args: Позиционные аргументы для функции.
kwargs: Именованные аргументы для функции.
"""
try:
# Обновление статуса задачи
with self._lock:
self.tasks[task_id]["status"] = "running"
self.tasks[task_id]["started_at"] = time.time()
# Выполнение функции
try:
result = func(*args, **kwargs)
# Сохранение результата
self.tasks[task_id]["status"] = "completed"
self.tasks[task_id]["result"] = result
self.tasks[task_id]["completed_at"] = time.time()
with self._lock:
self.tasks[task_id]["status"] = "completed"
self.tasks[task_id]["result"] = result
self.tasks[task_id]["completed_at"] = time.time()
logger.info(f"Задача {task_id} завершена успешно")
except Exception as e:
# Обработка ошибки
self.tasks[task_id]["status"] = "failed"
self.tasks[task_id]["error"] = str(e)
self.tasks[task_id]["completed_at"] = time.time()
with self._lock:
self.tasks[task_id]["status"] = "failed"
self.tasks[task_id]["error"] = str(e)
self.tasks[task_id]["completed_at"] = time.time()
logger.error(f"Задача {task_id} завершилась с ошибкой: {e}")
def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
@@ -98,7 +110,9 @@ class AsyncTaskManager:
Returns:
Информация о задаче или None, если задача не найдена.
"""
return self.tasks.get(task_id)
with self._lock:
task = self.tasks.get(task_id)
return dict(task) if task else None
# Глобальный экземпляр менеджера асинхронных задач
+2 -34
View File
@@ -1,10 +1,10 @@
"""
Модуль validators.py содержит классы и функции для валидации входных данных.
Модуль validation.py содержит классы и функции для валидации входных данных.
"""
import os
import magic
from typing import Dict, List, BinaryIO, Optional
from typing import Dict, List, BinaryIO
import logging
# Получаем логгер из централизованной настройки
@@ -186,35 +186,3 @@ class FileValidator:
return True
@staticmethod
def validate_local_file_path(file_path: str, allowed_directories: Optional[List[str]] = None) -> str:
"""
Валидирует путь к локальному файлу для предотвращения атак обхода пути.
Args:
file_path: Путь к файлу.
allowed_directories: Список разрешенных директорий.
Returns:
Нормализованный и проверенный путь к файлу.
Raises:
ValidationError: Если путь к файлу небезопасен.
"""
# Нормализация пути
normalized_path = os.path.normpath(file_path)
# Если указаны разрешенные директории, проверяем, что путь находится в одной из них
if allowed_directories:
for allowed_dir in allowed_directories:
full_allowed_path = os.path.abspath(allowed_dir)
full_file_path = os.path.abspath(os.path.join(full_allowed_path, normalized_path))
if full_file_path.startswith(full_allowed_path):
return full_file_path
logger.warning(f"Попытка доступа к файлу вне разрешенных директорий: {file_path}")
raise ValidationError("Путь к файлу не находится в разрешенных директориях")
# Если разрешенные директории не указаны, просто возвращаем нормализованный путь
return normalized_path
+16 -38
View File
@@ -9,8 +9,9 @@ from typing import Dict
import logging
from .core.transcription_service import TranscriptionService
from .audio.sources import get_uploaded_file, get_url_file, get_base64_file, get_local_file
from .audio.sources import get_uploaded_file, get_url_file, get_base64_file
from .infrastructure.validation import ValidationError
from .infrastructure.storage import cleanup_temp_files
from .infrastructure.async_tasks import transcribe_audio_async, task_manager
logger = logging.getLogger('app.routes')
@@ -43,33 +44,6 @@ class Routes:
"""Эндпоинт для получения конфигурации сервиса."""
return jsonify(self.config), 200
@self.app.route('/local/transcriptions', methods=['POST'])
def local_transcribe():
"""Эндпоинт для локальной транскрибации файла по пути на сервере."""
data = request.json
if not data or "file_path" not in data:
return jsonify({"error": "No file_path provided"}), 400
file_path = data["file_path"]
try:
validated_path = self.file_validator.validate_local_file_path(
file_path,
allowed_directories=self.config.get("allowed_directories", [])
)
except ValidationError as e:
client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', 'unknown'))
logger.warning(f"Невалидный путь '{file_path}' от {client_ip}: {e}")
return jsonify({"error": str(e)}), 400
temp_path, filename, error = get_local_file(validated_path, self._max_size)
if error:
return jsonify({"error": error}), 400
response, status_code = self.transcription_service.transcribe(temp_path, filename, data)
return jsonify(response), status_code
@self.app.route('/v1/models', methods=['GET'])
def list_models():
"""Эндпоинт для получения списка доступных моделей."""
@@ -105,15 +79,15 @@ class Routes:
if error:
return jsonify({"error": error}), 400
# Валидация файла
try:
self.file_validator.validate_file_by_path(temp_path, filename)
response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form))
return jsonify(response), status_code
except ValidationError as e:
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
return jsonify({"error": str(e)}), 400
response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form))
return jsonify(response), status_code
finally:
cleanup_temp_files([temp_path])
@self.app.route('/v1/audio/transcriptions/url', methods=['POST'])
def transcribe_from_url():
@@ -135,12 +109,13 @@ class Routes:
try:
self.file_validator.validate_file_by_path(temp_path, filename)
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
return jsonify(response), status_code
except ValidationError as e:
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
return jsonify({"error": str(e)}), 400
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
return jsonify(response), status_code
finally:
cleanup_temp_files([temp_path])
@self.app.route('/v1/audio/transcriptions/base64', methods=['POST'])
def transcribe_from_base64():
@@ -162,12 +137,13 @@ class Routes:
try:
self.file_validator.validate_file_by_path(temp_path, filename)
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
return jsonify(response), status_code
except ValidationError as e:
logger.warning(f"Ошибка валидации файла '{filename}': {e}")
return jsonify({"error": str(e)}), 400
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
return jsonify(response), status_code
finally:
cleanup_temp_files([temp_path])
@self.app.route('/v1/audio/transcriptions/async', methods=['POST'])
def transcribe_async():
@@ -179,8 +155,10 @@ class Routes:
try:
self.file_validator.validate_file_by_path(temp_path, filename)
except ValidationError as e:
cleanup_temp_files([temp_path])
return jsonify({"error": str(e)}), 400
# Не чистим temp_path здесь — async task отвечает за cleanup
task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber)
return jsonify({"task_id": task_id}), 202