Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion application/single_app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
ARG UID=65532
ARG GID=65532
ARG INSTALL_PLAYWRIGHT_CHROMIUM=true
ARG INSTALL_AUDIO_FFMPEG=true

FROM mcr.microsoft.com/azurelinux/base/python:3.12 AS builder

ARG UID
ARG GID
ARG INSTALL_PLAYWRIGHT_CHROMIUM
ARG INSTALL_AUDIO_FFMPEG

# Copy pip.conf into the image for pip configuration
COPY docker-customization/pip.conf /etc/pip.conf
Expand Down Expand Up @@ -142,6 +144,17 @@
COPY --chown=${UID}:${GID} application/single_app/requirements.txt .
RUN python3 -m pip install --no-cache-dir -r requirements.txt

RUN set -eux; \
mkdir -p /audio-runtime/usr/bin; \
case "$(printf '%s' "${INSTALL_AUDIO_FFMPEG}" | tr '[:upper:]' '[:lower:]')" in \
true|1|yes|on) \
python3 -c "import pathlib, shutil, ffmpeg_binaries as ffmpeg_bin; ffmpeg_bin.init(); target_dir = pathlib.Path('/audio-runtime/usr/bin'); shutil.copy2(ffmpeg_bin.FFMPEG_PATH, target_dir / 'ffmpeg'); shutil.copy2(ffmpeg_bin.FFPROBE_PATH, target_dir / 'ffprobe'); (target_dir / 'ffmpeg').chmod(0o755); (target_dir / 'ffprobe').chmod(0o755)"; \

Check warning on line 151 in application/single_app/Dockerfile

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains dynamic execution, persistence, or system access marker. Recommendation%3A Do not execute changed lifecycle scripts or installers while this finding is unresolved.
;; \
*) \
echo "Skipping FFmpeg runtime package installation because INSTALL_AUDIO_FFMPEG=${INSTALL_AUDIO_FFMPEG}"; \

Check warning on line 154 in application/single_app/Dockerfile

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
;; \
esac

RUN set -eux; \
mkdir -p /playwright-runtime; \
case "$(printf '%s' "${INSTALL_PLAYWRIGHT_CHROMIUM}" | tr '[:upper:]' '[:lower:]')" in \
Expand Down Expand Up @@ -173,6 +186,7 @@
COPY --from=builder /usr/lib/python3.12 /usr/lib/python3.12
COPY --from=builder /odbc-runtime/ /
COPY --from=builder /playwright-runtime/ /
COPY --from=builder /audio-runtime/ /

USER ${UID}:${GID}

Expand All @@ -181,7 +195,7 @@

ENV HOME=/home/nonroot \
LD_LIBRARY_PATH="/usr/lib64:/opt/microsoft/msodbcsql18/lib64" \
PATH="/home/nonroot/.local/bin:$PATH" \
PATH="/usr/bin:/home/nonroot/.local/bin:$PATH" \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
Expand Down
52 changes: 50 additions & 2 deletions application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.250.008"
VERSION = "0.250.014"

SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() != 'false'
Expand Down Expand Up @@ -156,7 +156,12 @@ def _split_origin_list(raw_value):
'mpg', 'wmv', 'asf', 'm4v', 'isma', 'ismv', 'dvr-ms', 'webm', 'mpeg'
}

AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a'}
AUDIO_EXTENSIONS = {
'3ga', 'aac', 'ac3', 'aif', 'aifc', 'aiff', 'amr', 'ape', 'au', 'caf',
'dts', 'f4a', 'flac', 'm4a', 'm4b', 'm4r', 'mka', 'mp2', 'mp3', 'mpa',
'oga', 'ogg', 'opus', 'spx', 'wav', 'weba', 'wma', 'wv'
}
AUDIO_FAST_TRANSCRIPTION_SOURCE_EXTENSIONS = {'aac', 'flac', 'm4a', 'mp3', 'ogg', 'wav'}

def get_allowed_extensions(enable_video=False, enable_audio=False):
"""
Expand All @@ -183,6 +188,49 @@ def get_allowed_extensions(enable_video=False, enable_audio=False):

return extensions

def get_allowed_extension_categories(enable_video=False, enable_audio=False):
"""
Get allowed file extensions grouped for display in workspace upload dialogs.
"""
categories = [
{
'name': 'Documents and presentations',
'extensions': BASE_ALLOWED_EXTENSIONS | DOCUMENT_EXTENSIONS,
},
{
'name': 'Tables and data',
'extensions': TABULAR_EXTENSIONS,
},
{
'name': 'Images',
'extensions': IMAGE_EXTENSIONS,
},
{
'name': 'Email and diagrams',
'extensions': EMAIL_EXTENSIONS | VISIO_EXTENSIONS,
},
]

if enable_audio:
categories.append({
'name': 'Audio',
'extensions': AUDIO_EXTENSIONS,
})

if enable_video:
categories.append({
'name': 'Video',
'extensions': VIDEO_EXTENSIONS,
})

return [
{
'name': category['name'],
'extensions': sorted(category['extensions']),
}
for category in categories
]

ALLOWED_EXTENSIONS = get_allowed_extensions(enable_video=True, enable_audio=True)

# Admin UI specific extensions (for logo/favicon uploads)
Expand Down
206 changes: 166 additions & 40 deletions application/single_app/functions_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import re
import shutil
import subprocess

Check warning on line 5 in application/single_app/functions_documents.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains dynamic execution, persistence, or system access marker. Recommendation%3A Do not execute changed lifecycle scripts or installers while this finding is unresolved.
import traceback
import zipfile
from io import BytesIO
Expand All @@ -19,6 +20,8 @@
from functions_model_endpoint_runtime import MODEL_ENDPOINT_PROVIDER_ALLOWLIST, build_model_endpoint_sync_chat_client
import azure.cognitiveservices.speech as speechsdk

_AUDIO_RUNTIME_CAPABILITIES_CACHE = None

def allowed_file(filename, allowed_extensions=None):
if not allowed_extensions:
allowed_extensions = ALLOWED_EXTENSIONS
Expand Down Expand Up @@ -7345,13 +7348,90 @@
def _get_content_type(path: str) -> str:
ext = os.path.splitext(path)[1].lower()
mapping = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.3ga': 'audio/3gpp',
'.aac': 'audio/aac',
'.ac3': 'audio/ac3',
'.aif': 'audio/aiff',
'.aifc': 'audio/aiff',
'.aiff': 'audio/aiff',
'.amr': 'audio/amr',
'.ape': 'audio/x-ape',
'.au': 'audio/basic',
'.caf': 'audio/x-caf',
'.dts': 'audio/vnd.dts',
'.f4a': 'audio/mp4',
'.flac': 'audio/flac',
'.m4a': 'audio/mp4',
'.mp4': 'audio/mp4'
'.m4b': 'audio/mp4',
'.m4r': 'audio/mp4',
'.mka': 'audio/x-matroska',
'.mp2': 'audio/mpeg',
'.mp3': 'audio/mpeg',
'.mpa': 'audio/mpeg',
'.mp4': 'audio/mp4',
'.oga': 'audio/ogg',
'.ogg': 'audio/ogg',
'.opus': 'audio/opus',
'.spx': 'audio/ogg',
'.wav': 'audio/wav',
'.weba': 'audio/webm',
'.wma': 'audio/x-ms-wma',
'.wv': 'audio/x-wavpack'
}
return mapping.get(ext, 'application/octet-stream')


def get_audio_runtime_capabilities(force_refresh: bool = False):
"""Return cached runtime support details for audio upload transcoding."""
global _AUDIO_RUNTIME_CAPABILITIES_CACHE
if _AUDIO_RUNTIME_CAPABILITIES_CACHE is not None and not force_refresh:
return dict(_AUDIO_RUNTIME_CAPABILITIES_CACHE)

supported_extensions = sorted(f'.{extension}' for extension in AUDIO_EXTENSIONS)
source_extensions = sorted(
f'.{extension}'
for extension in AUDIO_FAST_TRANSCRIPTION_SOURCE_EXTENSIONS
if extension in AUDIO_EXTENSIONS
)
ffmpeg_path = shutil.which('ffmpeg') or ''
ffprobe_path = shutil.which('ffprobe') or ''

capabilities = {
'ffmpeg_available': False,
'ffprobe_available': bool(ffprobe_path),
'broad_transcoding_available': False,
'ffmpeg_path': ffmpeg_path,
'ffprobe_path': ffprobe_path,
'ffmpeg_version': '',
'supported_extensions': supported_extensions,
'direct_transcription_extensions': source_extensions,
'recommended_container_packages': ['ffmpeg', 'ffprobe'],
'message': 'FFmpeg was not found in this app runtime; audio uploads use Azure Speech source-file fallback only.',
}

if ffmpeg_path:
capabilities['ffmpeg_available'] = True
try:

Check warning on line 7414 in application/single_app/functions_documents.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
ffmpeg_result = subprocess.run(

Check warning on line 7415 in application/single_app/functions_documents.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains dynamic execution, persistence, or system access marker. Recommendation%3A Do not execute changed lifecycle scripts or installers while this finding is unresolved.
[ffmpeg_path, '-hide_banner', '-version'],
capture_output=True,
check=True,
text=True,
timeout=5,
)
version_line = (ffmpeg_result.stdout or '').splitlines()[0] if ffmpeg_result.stdout else ''
capabilities.update({
'broad_transcoding_available': True,
'ffmpeg_version': version_line,
'message': 'FFmpeg runtime detected; broad audio transcoding is available before Azure Speech transcription.',
})
except Exception as runtime_error:

Check warning on line 7428 in application/single_app/functions_documents.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
capabilities['message'] = f"FFmpeg was found, but runtime validation failed: {str(runtime_error)[:220]}"

_AUDIO_RUNTIME_CAPABILITIES_CACHE = capabilities
return dict(capabilities)


def _split_audio_file(input_path: str, chunk_seconds: int = 540) -> List[str]:
"""
Splits `input_path` into WAV segments of length `chunk_seconds` seconds,
Expand All @@ -7373,7 +7453,8 @@
f='segment',
segment_time=chunk_seconds,
reset_timestamps=1,
map='0'
map='0:a:0',
ac='1'
)
.run(quiet=True, overwrite_output=True)
)
Expand All @@ -7388,6 +7469,45 @@
print(f"Produced {len(chunks)} WAV chunks: {chunks}")
return chunks


def _is_missing_ffmpeg_error(error) -> bool:
error_text = str(error or '').lower()
missing_binary_markers = (
'no such file or directory',
'the system cannot find the file specified',
'cannot find the file specified',
'not recognized as an internal or external command',
)
return 'ffmpeg' in error_text and any(marker in error_text for marker in missing_binary_markers)


def _transcribe_audio_with_fast_api(audio_path, upload_filename, content_type, settings, endpoint, locale):
url = f"{endpoint}/speechtotext/transcriptions:transcribe?api-version=2024-11-15"
with open(audio_path, 'rb') as audio_f:
files = {
'audio': (upload_filename, audio_f, content_type),
'definition': (None, json.dumps({'locales': [locale]}), 'application/json')
}
if settings.get("speech_service_authentication_type") == "managed_identity":
credential = DefaultAzureCredential()

Check warning on line 7492 in application/single_app/functions_documents.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
token = credential.get_token(cognitive_services_scope)

Check warning on line 7493 in application/single_app/functions_documents.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
headers = {'Authorization': f'Bearer {token.token}'}

Check warning on line 7494 in application/single_app/functions_documents.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
else:
key = settings.get("speech_service_key", "")
headers = {'Ocp-Apim-Subscription-Key': key}

resp = requests.post(url, headers=headers, files=files)

Check warning on line 7499 in application/single_app/functions_documents.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains external connection or remote asset marker. Recommendation%3A Review whether changed code can send prompts, files, credentials, cookies, settings, logs, or user data to a new sink.
try:
resp.raise_for_status()
except Exception as e:
print(f"[Error] HTTP error for {audio_path}: {e}")
raise

result = resp.json()
phrases = result.get('combinedPhrases', [])
print(f"[Debug] Received {len(phrases)} phrases")
return [p.get('text', '').strip() for p in phrases if p.get('text')]

# Azure Speech SDK helper to get speech config with fresh token
def _get_speech_config(settings, endpoint: str, locale: str):
"""Get speech config with fresh token"""
Expand Down Expand Up @@ -7467,15 +7587,27 @@
if file_size > 300 * 1024 * 1024:
raise ValueError("Audio exceeds 300 MB limit.")

# 2) split to WAV chunks
update_callback(status="Preparing audio for transcription…")
chunk_paths = _split_audio_file(temp_file_path, chunk_seconds=540)

# 3) transcribe each WAV chunk
# 2) prepare speech configuration
settings = get_settings()
endpoint = settings.get("speech_service_endpoint", "").rstrip('/')
locale = settings.get("speech_service_locale", "en-US")

# 3) split to WAV chunks unless fast transcription can use the source file directly
update_callback(status="Preparing audio for transcription…")
chunk_paths = []
use_source_audio_for_fast_api = False
try:
chunk_paths = _split_audio_file(temp_file_path, chunk_seconds=540)
except RuntimeError as split_error:
if AZURE_ENVIRONMENT not in ("usgovernment", "custom") and _is_missing_ffmpeg_error(split_error):
use_source_audio_for_fast_api = True
print(
"[Warning] FFmpeg executable unavailable; using source audio with Azure Speech fast transcription API."
)
else:
raise

# 4) transcribe audio
all_phrases: List[str] = []

# Fast Transcription API not yet available in sovereign clouds, so use SDK
Expand Down Expand Up @@ -7619,45 +7751,39 @@

else:
# Use the fast-transcription API if not in sovereign or custom cloud
url = f"{endpoint}/speechtotext/transcriptions:transcribe?api-version=2024-11-15"
for idx, chunk_path in enumerate(chunk_paths, start=1):
update_callback(current_file_chunk=idx, status=f"Transcribing chunk {idx}/{len(chunk_paths)}…")
print(f"[Debug] Transcribing WAV chunk: {chunk_path}")

with open(chunk_path, 'rb') as audio_f:
files = {
'audio': (os.path.basename(chunk_path), audio_f, 'audio/wav'),
'definition': (None, json.dumps({'locales':[locale]}), 'application/json')
}
if settings.get("speech_service_authentication_type") == "managed_identity":
credential = DefaultAzureCredential()
token = credential.get_token(cognitive_services_scope)
headers = {'Authorization': f'Bearer {token.token}'}
else:
key = settings.get("speech_service_key", "")
headers = {'Ocp-Apim-Subscription-Key': key}

resp = requests.post(url, headers=headers, files=files)
try:
resp.raise_for_status()
except Exception as e:
print(f"[Error] HTTP error for {chunk_path}: {e}")
raise

result = resp.json()
phrases = result.get('combinedPhrases', [])
print(f"[Debug] Received {len(phrases)} phrases")
all_phrases += [p.get('text','').strip() for p in phrases if p.get('text')]
if use_source_audio_for_fast_api:
update_callback(current_file_chunk=1, status="Transcribing audio with Azure Speech…")
print(f"[Debug] Transcribing source audio: {temp_file_path}")
all_phrases += _transcribe_audio_with_fast_api(
temp_file_path,
original_filename,
_get_content_type(original_filename or temp_file_path),
settings,
endpoint,
locale
)
else:
for idx, chunk_path in enumerate(chunk_paths, start=1):
update_callback(current_file_chunk=idx, status=f"Transcribing chunk {idx}/{len(chunk_paths)}…")
print(f"[Debug] Transcribing WAV chunk: {chunk_path}")
all_phrases += _transcribe_audio_with_fast_api(
chunk_path,
os.path.basename(chunk_path),
'audio/wav',
settings,
endpoint,
locale
)

# 4) cleanup WAV chunks
# 5) cleanup WAV chunks
for p in chunk_paths:
try:
os.remove(p)
print(f"Removed chunk: {p}")
except Exception as e:
print(f"[Warning] Could not remove chunk {p}: {e}")

# 5) stitch and save transcript chunks
# 6) stitch and save transcript chunks
full_text = ' '.join(all_phrases).strip()
words = full_text.split()
chunk_settings = get_chunk_size_config(settings)
Expand Down
Loading
Loading