added SSO login via MSAL and PKCE, ready for deployment (theoretically)

This commit is contained in:
michael 2025-11-03 08:41:27 -06:00
parent ee857e0358
commit 5de2003aff
18 changed files with 1163 additions and 31 deletions

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
# Development Mode (set to true to bypass authentication for local testing)
DEV_MODE=false
# Azure AD/MSAL Configuration
AZURE_CLIENT_ID=your_client_id_here
AZURE_AUTHORITY=https://login.microsoftonline.com/your_tenant_id_here
AZURE_REDIRECT_URI=https://yourdomain.com/voice2text/
# DeepL API Key
DEEPL_API_KEY=your_deepl_api_key_here
# Python API Configuration
PYTHON_API_URL=http://localhost:5010
# Session Configuration (in seconds, default: 8 hours)
SESSION_TIMEOUT=28800

7
.gitignore vendored
View file

@ -6,6 +6,13 @@ __pycache__/
*.so
.Python
# PHP Dependencies
vendor/
composer.lock
# Environment variables
.env
# Output files
outputs/*.txt
outputs/*.vtt

View file

@ -1,5 +1,7 @@
php_value upload_max_filesize 350M
php_value post_max_size 350M
php_value max_execution_time 1200
php_value max_input_time 1200
php_value memory_limit 512M
# PHP settings moved to .user.ini for PHP-FPM compatibility (MAMP)
# If using mod_php, uncomment these lines:
# php_value upload_max_filesize 350M
# php_value post_max_size 350M
# php_value max_execution_time 1200
# php_value max_input_time 1200
# php_value memory_limit 512M

8
.user.ini Normal file
View file

@ -0,0 +1,8 @@
; PHP Configuration for Voice to Text Application
; Works with PHP-FPM (MAMP)
upload_max_filesize = 350M
post_max_size = 350M
max_execution_time = 1200
max_input_time = 1200
memory_limit = 512M

423
CLAUDE.md Normal file
View file

@ -0,0 +1,423 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Voice to Text is a two-tier web application that transcribes audio files using OpenAI Whisper and optionally translates them using DeepL API. The application consists of:
- **Python Flask API** (backend): Handles transcription and translation
- **PHP web interface** (frontend): User interface, authentication, and request handling
- **Microsoft Azure AD SSO**: OAuth2 with PKCE flow for authentication
## Development Setup
### Initial Setup
```bash
# 1. Configure authentication
cp .env.example .env
# Edit .env with your Azure AD credentials
# 2. Install PHP dependencies
composer install
# 3. Install Python dependencies and create virtual environment
./setup.sh
# 4. Start the Python API server
./start_api.sh
# Or manually:
source venv/bin/activate
python api.py
```
The API runs on http://localhost:5010 by default. The PHP frontend should be served via MAMP, Apache, or any PHP-enabled web server with HTTPS enabled for production.
### Testing the Application
```bash
# Check if Python API is running
curl http://localhost:5010/health
# Or use the PHP diagnostic page
# Visit: check_api.php in browser
# Test downloads
# Visit: test_download.php in browser
```
### Python Version Compatibility
- Recommended: Python 3.10 or 3.11
- Supported: Python 3.8+
- Warning: Python 3.12+ may have compatibility issues with some dependencies
## Architecture
### Three-Layer Design
The application uses a separation of concerns:
1. **Authentication Layer**: Microsoft Azure AD SSO with OAuth2 PKCE flow
2. **Python API (api.py)**: Computation-heavy tasks (Whisper transcription, DeepL translation)
3. **PHP Frontend**: User interface, session management, file handling, and proxying requests to Python API
### Authentication Flow
```
User Browser
login.php (landing page)
↓ (clicks "Sign in with Microsoft")
auth.php
↓ (generates PKCE code_verifier & code_challenge)
Azure AD OAuth2 Authorization Endpoint
↓ (user authenticates)
auth.php (callback)
↓ (exchanges code + code_verifier for token)
Microsoft Graph API (/me)
↓ (retrieves user info)
Session initialized:
- $_SESSION['authenticated'] = true
- $_SESSION['user_id'], ['user_name'], ['user_email']
- $_SESSION['user_files'] = []
index.php (main app)
```
### Request Flow (After Authentication)
```
User Browser (index.php)
↓ (jQuery AJAX + FormData)
process.php
↓ (auth check via isAuthenticated())
↓ (cURL to Python API)
api.py (Flask)
↓ (Whisper transcription)
↓ (Optional: DeepL translation)
outputs/ directory
↓ (files tracked in $_SESSION['user_files'])
download.php
↓ (auth + ownership check)
User Browser (download)
```
### Key Components
**Authentication & Configuration Files**:
**auth_config.php** (Authentication & Environment Configuration):
- Loads environment variables from .env using vlucas/phpdotenv
- Defines Azure AD configuration constants (CLIENT_ID, AUTHORITY, REDIRECT_URI)
- Configures secure session settings (httponly, secure, samesite)
- Provides helper functions:
- `isAuthenticated()`: Check if user is logged in and session is valid
- `requireAuth()`: Redirect to login.php if not authenticated
- `getCurrentUser()`: Get current user info from session
**login.php** (Landing Page):
- First page users see when not authenticated
- Displays "Sign in with Microsoft" button with Microsoft logo
- Matches black/gold theme of main application
- Redirects to index.php if already authenticated
**auth.php** (OAuth2 PKCE Handler):
- Implements OAuth2 Authorization Code flow with PKCE
- Step 1: Generates code_verifier (64-char random string) and code_challenge (SHA256 hash)
- Step 2: Redirects to Azure AD with PKCE parameters
- Step 3: Handles callback, verifies state (CSRF protection)
- Step 4: Exchanges authorization code + code_verifier for access token
- Step 5: Calls Microsoft Graph API to get user info
- Step 6: Initializes session with user data and empty file list
- Step 7: Redirects to index.php
**logout.php** (Session Destruction):
- Clears all session variables
- Destroys session cookie
- Destroys session
- Redirects to login.php
**config.php** (Configuration Loader):
- Requires auth_config.php
- Starts session if not already started
- All configuration now loaded from .env via auth_config.php
**API & Core Files**:
**api.py** (Flask REST API - Port 5010):
- `/health`: Health check endpoint
- `/transcribe`: Main endpoint - accepts audio file, format (txt/vtt/srt), translation settings
- `/download/<filename>`: Serves transcribed files
- Whisper model loaded once at startup and kept in memory
- DeepL translator initialized at startup
- Generates both original and translated files when translation is enabled
**process.php** (PHP request handler):
- **Auth check**: Calls isAuthenticated() - returns error if not authenticated
- Receives multipart/form-data from frontend
- Validates file size (350MB limit)
- Forwards to Python API via cURL
- **File tracking**: Adds original and translated filenames to $_SESSION['user_files']
- Returns formatted HTML for display (truncated at 10,000 chars for preview)
- Provides download links for full files
**index.php** (Main UI):
- **Auth required**: Calls requireAuth() at top - redirects to login.php if not authenticated
- Displays user header with name, email, and logout button
- jQuery-based AJAX file upload
- Format selector (txt/vtt/srt)
- Translation toggle with language selector (30+ languages)
- Real-time progress bar during processing
- In-page preview of transcriptions
- Download buttons for original and translated files
**download.php** (File server):
- **Auth required**: Calls isAuthenticated() - returns 401 if not authenticated
- **Ownership check**: Verifies requested file is in $_SESSION['user_files']
- Returns 403 Forbidden if user doesn't own the file
- Logs unauthorized download attempts
- Serves files from outputs/ directory
- Security: Uses basename() to prevent directory traversal
- Sets proper Content-Type headers based on file extension
**.env** (Environment Variables):
- AZURE_CLIENT_ID: Azure AD application client ID
- AZURE_AUTHORITY: Azure AD authority URL with tenant ID
- AZURE_REDIRECT_URI: OAuth2 redirect URI (must match Azure AD config)
- DEEPL_API_KEY: DeepL API key for translation
- PYTHON_API_URL: Python Flask API endpoint (default: http://localhost:5010)
- SESSION_TIMEOUT: Session timeout in seconds (default: 28800 = 8 hours)
## Output Formats
### Text (.txt)
Plain text transcription - full text of audio
### VTT (.vtt)
WebVTT subtitle format with timestamps:
```
WEBVTT
00:00:00.000 --> 00:00:05.123
First segment text
00:00:05.123 --> 00:00:10.456
Second segment text
```
### SRT (.srt)
SubRip subtitle format with timestamps:
```
1
00:00:00,000 --> 00:00:05,123
First segment text
2
00:00:05,123 --> 00:00:10,456
Second segment text
```
**Key Difference**: VTT uses period (.) for milliseconds, SRT uses comma (,)
## Whisper Models
Available models (edit api.py line 26 to change):
- `tiny`: Fastest, least accurate
- `base`: Default - good balance
- `small`: Better accuracy, slower
- `medium`: High accuracy, much slower
- `large`: Best accuracy, very slow
Changing the model:
```python
# In api.py line 26:
model = whisper.load_model("small") # Change from "base" to desired model
```
## File Size and Timeout Limits
- **Maximum file size**: 350MB (configured in .htaccess and process.php)
- **Processing timeout**: 5 minutes (300 seconds in process.php)
- **PHP settings** (.htaccess):
- upload_max_filesize: 350M
- post_max_size: 350M
- max_execution_time: 1200 seconds
- memory_limit: 512M
## Translation
Translation is powered by DeepL API:
- Supports 30+ languages
- Translation happens after transcription
- Original language is auto-detected by Whisper
- Both original and translated files are saved with suffixes:
- `filename_original.{ext}`
- `filename_translated.{ext}`
## File Handling
### outputs/ Directory
All transcribed files are saved here. The directory:
- Created automatically by setup.sh or api.py
- Should have write permissions (777 in production)
- Files are named: `{original_filename}_original.{ext}` and `{original_filename}_translated.{ext}`
- Not tracked by git (see .gitignore)
### Temporary Files
- Audio files are saved temporarily during processing
- Cleaned up automatically after transcription (api.py line 186-187)
## Authentication & Security
### Microsoft Azure AD SSO
- **OAuth2 with PKCE**: Uses Proof Key for Code Exchange (RFC 7636)
- **No client secret needed**: PKCE allows public clients to authenticate securely
- **Code verifier**: 64-character random string generated for each auth request
- **Code challenge**: SHA256 hash of code_verifier, sent to Azure AD
- **Token exchange**: Authorization code + code_verifier exchanged for access token
### Session-Based File Access Control
- **Session tracking**: Files tracked in $_SESSION['user_files'] array
- **Upload tracking**: When user transcribes audio, both original and translated filenames added to their session
- **Download validation**: download.php checks if requested file is in user's session before serving
- **Session timeout**: Configurable (default: 8 hours) - after timeout, user loses access to their files
- **Trade-off**: Files remain in outputs/ directory but become inaccessible after session expires
### Session Security
- **httponly**: Session cookies not accessible via JavaScript (XSS protection)
- **secure**: Session cookies only transmitted over HTTPS (production)
- **samesite**: Set to 'Lax' to prevent CSRF attacks
- **strict_mode**: Rejects uninitialized session IDs
- **Session regeneration**: Session ID regenerated after login to prevent session fixation
- **CSRF protection**: OAuth2 state parameter validates callback authenticity
### File Security
- **basename()**: Prevents directory traversal attacks in download.php
- **File size validation**: 350MB limit enforced in both .htaccess and process.php
- **Ownership logging**: Unauthorized download attempts logged with user ID
- **No file type validation**: Relies on FFmpeg to handle/reject unsupported formats
### Environment Variables
- **.env file**: All sensitive credentials stored in .env (not in git)
- **API keys**: DeepL and Azure credentials loaded from environment
- **.gitignore**: .env explicitly excluded from version control
### Production Considerations
1. **HTTPS required**: Secure cookies require HTTPS in production
2. **File cleanup**: Old files in outputs/ should be cleaned via cron job
3. **Session storage**: Consider Redis/Memcached for multi-server deployments
4. **Rate limiting**: No rate limiting currently - consider adding for production
5. **Logging**: Unauthorized attempts logged - monitor for suspicious activity
## Session-Only File Tracking
### How It Works
Files are tracked in the PHP session (`$_SESSION['user_files']` array) rather than a database. This approach was chosen for simplicity.
### File Lifecycle
1. User uploads audio → process.php transcribes → adds filenames to $_SESSION['user_files']
2. User can download files as long as session is active
3. Session expires or user logs out → files become inaccessible
4. Files remain in outputs/ directory but cannot be downloaded
### Trade-offs
**Pros:**
- Simple implementation - no database needed
- Automatic "expiration" via session timeout
- Works well for temporary transcription tasks
**Cons:**
- Files inaccessible after session expires
- Can't access files across multiple devices/browsers
- Orphaned files accumulate in outputs/ directory
### Future Upgrades
To implement persistent file ownership:
1. Add SQLite/MySQL database with `users` and `files` tables
2. Store file ownership in database instead of session
3. Modify download.php to check database ownership
4. Consider filename-based ownership (encode user_id in filename)
## Common Development Tasks
### Changing Whisper Model
Edit api.py line 26 and restart the API:
```bash
# After editing
./start_api.sh
```
### Adjusting File Size Limits
Edit both:
1. `.htaccess` - PHP upload limits
2. `process.php` line 12 - PHP validation
3. If using production Apache: `/etc/php/.../php.ini`
### Testing Authentication Flow
1. Clear your browser cookies
2. Visit the application root
3. Should redirect to login.php
4. Click "Sign in with Microsoft"
5. Authenticate with Azure AD
6. Should redirect back to index.php with user header visible
### Testing Transcription
**Via Web UI:**
1. Log in via login.php
2. Upload a test audio file
3. Check that files appear in test_download.php
**Via API directly (bypasses auth):**
```bash
curl -X POST http://localhost:5010/transcribe \
-F "audio=@test.mp3" \
-F "format=txt" \
-F "translate=0"
```
### Testing File Access Control
1. Upload a file while logged in
2. Note the filename from the download link
3. Log out
4. Try to access download.php?file=filename directly
5. Should receive 401 Unauthorized
### Adding New Languages
Edit the language selector in index.php (lines 41-73) to add DeepL-supported languages.
## Production Deployment
See README.md sections:
- "Production Deployment (Apache)" for full Apache setup
- "Setup Python API as Systemd Service" for running API as a service
- "Monitoring and Maintenance" for logs and cleanup
Key production considerations:
1. Set up systemd service for Python API (voice2text-api.service)
2. Configure Apache virtual host
3. Set proper file permissions (www-data:www-data)
4. Set up log rotation
5. Configure cron job to clean old files in outputs/
6. Move API keys to environment variables
## Debugging
### API Not Responding
1. Check if API is running: `curl http://localhost:5010/health`
2. Check process: `ps aux | grep python`
3. Test Python directly: `source venv/bin/activate && python api.py`
4. Visit check_api.php in browser for diagnostic info
### Upload Fails
1. Check outputs/ directory exists and is writable
2. Verify file size is under 350MB
3. Check Apache/PHP error logs
4. Verify FFmpeg is installed: `which ffmpeg`
### Transcription Errors
1. Check Python API logs (stdout/stderr)
2. Verify audio file format is supported by FFmpeg
3. Test with a small sample file first
4. Check available disk space in /tmp
## Code Style Notes
- **Python**: Uses Flask conventions, logging via Python logging module
- **PHP**: Uses procedural style, cURL for HTTP requests
- **JavaScript**: jQuery-based, uses AJAX for async file upload
- **CSS**: BEM-like naming, black/gold theme with animations

View file

@ -22,10 +22,36 @@ A web application that converts audio files to text using OpenAI's Whisper model
- PHP 7.4 or higher
- MAMP or Apache server
- FFmpeg (for audio processing)
- Composer (for PHP dependencies)
- Microsoft Azure AD application (for SSO authentication)
## Installation
### 1. Install FFmpeg
### 1. Configure Authentication
This application uses Microsoft Azure AD for Single Sign-On (SSO) authentication with PKCE flow.
**Step 1: Copy and configure environment file**
```bash
cp .env.example .env
```
**Step 2: Edit `.env` file with your Azure AD credentials:**
```env
AZURE_CLIENT_ID=your_client_id_here
AZURE_AUTHORITY=https://login.microsoftonline.com/your_tenant_id_here
AZURE_REDIRECT_URI=https://yourdomain.com/voice2text/
DEEPL_API_KEY=your_deepl_api_key_here
PYTHON_API_URL=http://localhost:5010
SESSION_TIMEOUT=28800
```
**Step 3: Install PHP dependencies**
```bash
composer install
```
### 2. Install FFmpeg
**macOS:**
```bash
@ -41,7 +67,7 @@ sudo apt install ffmpeg
**Windows:**
Download from https://ffmpeg.org/download.html
### 2. Setup Python Environment
### 3. Setup Python Environment
Run the setup script:
```bash
@ -54,7 +80,7 @@ This will:
- Install all dependencies (Flask, Whisper, etc.)
- Create the outputs directory
### 3. Start the API Server
### 4. Start the API Server
```bash
chmod +x start_api.sh
@ -69,19 +95,22 @@ python api.py
The API will run on http://localhost:5010
### 4. Configure Web Server
### 5. Configure Web Server
Ensure your MAMP/Apache server points to this directory and PHP is enabled.
## Usage
1. Start the Python API server (see step 3 above)
2. Open the web application in your browser
3. Select output format (Text/VTT/SRT)
4. (Optional) Enable translation and select target language
5. Upload an audio file (max 350MB)
6. Wait for processing
7. Download original and/or translated transcription
1. Start the Python API server (see step 4 above)
2. Open the web application in your browser (you'll see a login page)
3. Click "Sign in with Microsoft" and authenticate with your Microsoft account
4. After authentication, you'll be redirected to the main application
5. Select output format (Text/VTT/SRT)
6. (Optional) Enable translation and select target language
7. Upload an audio file (max 350MB)
8. Wait for processing
9. Download original and/or translated transcription
10. Your files are associated with your session and only accessible to you
### Translation
@ -139,25 +168,58 @@ To change the model, edit `api.py` line 24:
model = whisper.load_model("base") # Change to desired model
```
## Authentication & Security
### Microsoft Azure AD SSO
- Uses OAuth2 with PKCE (Proof Key for Code Exchange) flow
- Secure authentication without client secrets
- Session-based file access control
- Users can only download files they've uploaded in their current session
### Session Management
- Secure session cookies (httponly, secure, samesite)
- Configurable session timeout (default: 8 hours)
- Session regeneration after login for security
### File Access Control
- Files are tracked per-user session in `$_SESSION['user_files']`
- Download attempts are validated against user's file list
- Unauthorized access attempts are logged and blocked
### Important Security Notes
- Ensure your `.env` file is never committed to git (it's in `.gitignore`)
- Use HTTPS in production for secure cookie transmission
- Files become inaccessible after session expires (files remain in `outputs/` but can't be downloaded)
- Consider setting up a cron job to clean old files from `outputs/` directory
## File Structure
```
.
├── api.py # Python Flask API with Whisper & DeepL
├── index.php # Frontend interface
├── process.php # PHP request handler
├── download.php # File download handler
├── check_api.php # API status checker
├── test_download.php # Download functionality tester
├── config.php # Configuration (API URLs, keys)
├── login.php # Landing page with Microsoft SSO
├── auth.php # OAuth2 PKCE authentication handler
├── logout.php # Session destruction handler
├── index.php # Main application interface (auth required)
├── process.php # PHP request handler (auth required)
├── download.php # File download handler (auth + ownership check)
├── check_api.php # API status checker (auth required)
├── test_download.php # Download functionality tester (auth required)
├── config.php # Configuration loader
├── auth_config.php # Authentication & environment config
├── style.css # Black/gold theme styles
├── .env # Environment variables (NOT in git)
├── .env.example # Environment variables template
├── .htaccess # PHP upload limits
├── .gitignore # Git ignore rules
├── composer.json # PHP dependencies
├── requirements.txt # Python dependencies
├── setup.sh # Setup script
├── start_api.sh # API start script
├── README.md # This file
├── CLAUDE.md # Claude Code guidance
├── outputs/ # Transcribed files directory
├── vendor/ # Composer dependencies (NOT in git)
└── venv/ # Python virtual environment
```

114
auth.php Normal file
View file

@ -0,0 +1,114 @@
<?php
/**
* OAuth2 PKCE Authentication Handler
* Handles Microsoft Azure AD authentication flow with PKCE
*/
require_once 'auth_config.php';
use League\OAuth2\Client\Provider\GenericProvider;
// Start session
session_start();
// Azure AD OAuth2 Provider configuration
$provider = new GenericProvider([
'clientId' => AZURE_CLIENT_ID,
'redirectUri' => AZURE_REDIRECT_URI,
'urlAuthorize' => AZURE_AUTHORITY . '/oauth2/v2.0/authorize',
'urlAccessToken' => AZURE_AUTHORITY . '/oauth2/v2.0/token',
'urlResourceOwnerDetails' => 'https://graph.microsoft.com/v1.0/me',
'scopes' => 'openid profile email User.Read'
]);
// Step 1: No authorization code - initiate OAuth flow
if (!isset($_GET['code'])) {
// Generate PKCE code verifier and challenge
$codeVerifier = bin2hex(random_bytes(32)); // 64-character random string
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
// Store code verifier in session for later use
$_SESSION['oauth2_code_verifier'] = $codeVerifier;
// Generate authorization URL with PKCE parameters
$authorizationUrl = $provider->getAuthorizationUrl([
'scope' => 'openid profile email User.Read',
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
'response_type' => 'code',
'response_mode' => 'query'
]);
// Store state for CSRF protection
$_SESSION['oauth2state'] = $provider->getState();
// Redirect to Azure AD
header('Location: ' . $authorizationUrl);
exit;
}
// Step 2: Authorization code received - exchange for access token
elseif (isset($_GET['code'])) {
// Verify state to prevent CSRF attacks
if (empty($_GET['state']) || (isset($_SESSION['oauth2state']) && $_GET['state'] !== $_SESSION['oauth2state'])) {
unset($_SESSION['oauth2state']);
unset($_SESSION['oauth2_code_verifier']);
die('Invalid state. Possible CSRF attack.');
}
try {
// Retrieve code verifier from session
if (!isset($_SESSION['oauth2_code_verifier'])) {
die('Code verifier not found in session.');
}
$codeVerifier = $_SESSION['oauth2_code_verifier'];
// Exchange authorization code for access token with PKCE
$accessToken = $provider->getAccessToken('authorization_code', [
'code' => $_GET['code'],
'code_verifier' => $codeVerifier
]);
// Get user information from Microsoft Graph API
$request = $provider->getAuthenticatedRequest(
'GET',
'https://graph.microsoft.com/v1.0/me',
$accessToken->getToken()
);
$client = new \GuzzleHttp\Client();
$response = $client->send($request);
$userData = json_decode($response->getBody(), true);
// Store user information in session
$_SESSION['authenticated'] = true;
$_SESSION['user_id'] = $userData['id'];
$_SESSION['user_name'] = $userData['displayName'] ?? $userData['userPrincipalName'];
$_SESSION['user_email'] = $userData['userPrincipalName'] ?? $userData['mail'];
$_SESSION['access_token'] = $accessToken->getToken();
$_SESSION['last_activity'] = time();
// Initialize user files array for tracking uploads
$_SESSION['user_files'] = [];
// Clean up temporary session variables
unset($_SESSION['oauth2state']);
unset($_SESSION['oauth2_code_verifier']);
// Regenerate session ID for security
session_regenerate_id(true);
// Redirect to main application
header('Location: index.php');
exit;
} catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) {
// Handle authentication errors
die('Authentication failed: ' . htmlspecialchars($e->getMessage()));
} catch (\Exception $e) {
// Handle other errors
die('An error occurred: ' . htmlspecialchars($e->getMessage()));
}
}

124
auth_config.php Normal file
View file

@ -0,0 +1,124 @@
<?php
/**
* Authentication Configuration
* Loads environment variables and configures Azure AD OAuth2
*/
// Load Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';
// Load environment variables
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
// Development Mode Configuration
define('DEV_MODE', filter_var($_ENV['DEV_MODE'] ?? false, FILTER_VALIDATE_BOOLEAN));
// Azure AD Configuration
define('AZURE_CLIENT_ID', $_ENV['AZURE_CLIENT_ID'] ?? '');
define('AZURE_AUTHORITY', $_ENV['AZURE_AUTHORITY'] ?? '');
define('AZURE_REDIRECT_URI', $_ENV['AZURE_REDIRECT_URI'] ?? '');
// Extract tenant ID from authority URL
if (AZURE_AUTHORITY) {
preg_match('/\/([^\/]+)$/', AZURE_AUTHORITY, $matches);
define('AZURE_TENANT_ID', $matches[1] ?? 'common');
} else {
define('AZURE_TENANT_ID', 'common');
}
// Python API Configuration
define('PYTHON_API_URL', $_ENV['PYTHON_API_URL'] ?? 'http://localhost:5010');
// DeepL API Configuration
define('DEEPL_API_KEY', $_ENV['DEEPL_API_KEY'] ?? '');
// Session Configuration
define('SESSION_TIMEOUT', (int)($_ENV['SESSION_TIMEOUT'] ?? 28800)); // Default: 8 hours
// Configure secure session settings (only if session hasn't started yet)
if (session_status() === PHP_SESSION_NONE) {
ini_set('session.cookie_httponly', '1');
// Only require secure cookies in production (not in dev mode on localhost)
ini_set('session.cookie_secure', DEV_MODE ? '0' : '1');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.use_strict_mode', '1');
ini_set('session.gc_maxlifetime', SESSION_TIMEOUT);
}
/**
* Check if user is authenticated
* @return bool
*/
function isAuthenticated() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// In dev mode, auto-authenticate with mock user
if (DEV_MODE) {
if (!isset($_SESSION['authenticated']) || !$_SESSION['authenticated']) {
// Initialize dev mode session with mock user
$_SESSION['authenticated'] = true;
$_SESSION['user_id'] = 'dev-user-' . uniqid();
$_SESSION['user_name'] = 'Dev User (Local)';
$_SESSION['user_email'] = 'dev@localhost';
$_SESSION['last_activity'] = time();
$_SESSION['user_files'] = [];
}
return true;
}
// Check if user is logged in
if (!isset($_SESSION['authenticated']) || !$_SESSION['authenticated']) {
return false;
}
// Check session timeout
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > SESSION_TIMEOUT)) {
session_unset();
session_destroy();
return false;
}
// Update last activity time
$_SESSION['last_activity'] = time();
return true;
}
/**
* Require authentication - redirect to login if not authenticated
*/
function requireAuth() {
// In dev mode, authentication is auto-handled by isAuthenticated()
if (DEV_MODE) {
isAuthenticated(); // This will auto-create the session
return;
}
if (!isAuthenticated()) {
header('Location: login.php');
exit;
}
}
/**
* Get current user information
* @return array|null
*/
function getCurrentUser() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isAuthenticated()) {
return null;
}
return [
'id' => $_SESSION['user_id'] ?? null,
'name' => $_SESSION['user_name'] ?? null,
'email' => $_SESSION['user_email'] ?? null
];
}

View file

@ -4,7 +4,13 @@
*/
require_once 'config.php';
// Require authentication
requireAuth();
$user = getCurrentUser();
echo "<h2>API Status Check</h2>";
echo "<p>Logged in as: <strong>" . htmlspecialchars($user['name']) . "</strong> (" . htmlspecialchars($user['email']) . ")</p>";
// Check if Python API is responding
$ch = curl_init(PYTHON_API_URL . '/health');

15
composer.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "voice2text/app",
"description": "Voice to Text application with Microsoft SSO",
"type": "project",
"require": {
"php": ">=7.4",
"vlucas/phpdotenv": "^5.5",
"league/oauth2-client": "^2.7"
},
"autoload": {
"psr-4": {
"Voice2Text\\": "src/"
}
}
}

View file

@ -1,13 +1,21 @@
<?php
/**
* Application Configuration
* Loads authentication config and environment variables
*/
// Load authentication configuration and environment variables
require_once __DIR__ . '/auth_config.php';
// Start session only if not already started
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Python API endpoint (adjust port if needed)
define('PYTHON_API_URL', 'http://localhost:5010');
// DeepL API Key
define('DEEPL_API_KEY', '28743b40-d23f-416d-8223-9b868c9531dc');
// Other configuration settings can be added here
// Configuration constants are now loaded from .env via auth_config.php:
// - PYTHON_API_URL
// - DEEPL_API_KEY
// - AZURE_CLIENT_ID
// - AZURE_AUTHORITY
// - AZURE_REDIRECT_URI
// - SESSION_TIMEOUT

View file

@ -10,12 +10,29 @@ ob_start();
error_reporting(E_ALL);
ini_set('display_errors', 0); // Don't display errors, log them
// Load configuration and authentication
require_once 'config.php';
// Check authentication
if (!isAuthenticated()) {
http_response_code(401);
die('Authentication required. Please log in.');
}
if (!isset($_GET['file'])) {
http_response_code(400);
die('No file specified');
}
$filename = basename($_GET['file']); // Security: prevent directory traversal
// Check if file belongs to current user
if (!isset($_SESSION['user_files']) || !in_array($filename, $_SESSION['user_files'])) {
http_response_code(403);
error_log("Unauthorized download attempt: " . $filename . " by user " . ($_SESSION['user_id'] ?? 'unknown'));
die('Access denied. You do not have permission to download this file.');
}
$filepath = __DIR__ . '/outputs/' . $filename;
// Debug logging

View file

@ -1,5 +1,11 @@
<?php
require_once 'config.php';
// Require authentication - redirect to login if not authenticated
requireAuth();
// Get current user info
$user = getCurrentUser();
?>
<!DOCTYPE html>
<html lang="en">
@ -13,6 +19,20 @@ require_once 'config.php';
</head>
<body>
<div class="app-container">
<?php if (DEV_MODE): ?>
<div class="dev-mode-banner">
🔧 DEV MODE ACTIVE - Authentication Bypassed
</div>
<?php endif; ?>
<div class="user-header">
<div class="user-info">
<span class="user-name"><?php echo htmlspecialchars($user['name']); ?></span>
<span class="user-email"><?php echo htmlspecialchars($user['email']); ?></span>
</div>
<a href="logout.php" class="logout-btn">Logout</a>
</div>
<img src="V2T.svg" alt="Voice to Text" class="logo">
<div id="initialInstruction" class="initial-instruction">

183
login.php Normal file
View file

@ -0,0 +1,183 @@
<?php
/**
* Login Landing Page
* Displays Microsoft sign-in button
*/
require_once 'auth_config.php';
// In dev mode, bypass login page entirely
if (DEV_MODE) {
header('Location: index.php');
exit;
}
// If already authenticated, redirect to main app
if (isAuthenticated()) {
header('Location: index.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In - Voice to Text</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Montserrat', sans-serif;
background: #000000;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.login-container {
background: #1a1a1a;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(255, 196, 7, 0.2);
border: 1px solid #333;
padding: 60px 50px;
max-width: 500px;
width: 100%;
text-align: center;
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.logo {
width: 350px;
height: auto;
display: block;
margin: 0 auto 40px;
filter: invert(1) brightness(2);
}
h1 {
color: #FFC407;
font-size: 28px;
font-weight: 700;
margin-bottom: 15px;
}
.subtitle {
color: #999;
font-size: 16px;
margin-bottom: 40px;
line-height: 1.6;
}
.microsoft-login-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px 40px;
background: #FFC407;
color: #000;
text-decoration: none;
border-radius: 50px;
font-weight: 700;
font-size: 16px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(255, 196, 7, 0.4);
border: none;
cursor: pointer;
}
.microsoft-login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 196, 7, 0.6);
background: #ffcd2e;
}
.microsoft-login-btn:active {
transform: translateY(0);
}
.microsoft-icon {
width: 20px;
height: 20px;
}
.info-box {
margin-top: 40px;
padding: 20px;
background: #0a0a0a;
border-radius: 12px;
border: 1px solid #333;
}
.info-box p {
color: #666;
font-size: 14px;
line-height: 1.6;
}
@media screen and (max-width: 600px) {
.login-container {
padding: 40px 30px;
}
.logo {
width: 280px;
}
h1 {
font-size: 24px;
}
.subtitle {
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="login-container">
<img src="V2T.svg" alt="Voice to Text" class="logo">
<h1>Welcome to Voice to Text</h1>
<p class="subtitle">
Transcribe audio files using OpenAI Whisper<br>
Translate with DeepL into 30+ languages
</p>
<a href="auth.php" class="microsoft-login-btn">
<svg class="microsoft-icon" viewBox="0 0 23 23" xmlns="http://www.w3.org/2000/svg">
<rect width="11" height="11" fill="#f25022"/>
<rect x="12" width="11" height="11" fill="#7fba00"/>
<rect y="12" width="11" height="11" fill="#00a4ef"/>
<rect x="12" y="12" width="11" height="11" fill="#ffb900"/>
</svg>
Sign in with Microsoft
</a>
<div class="info-box">
<p>
Sign in with your Microsoft account to access the application.<br>
Supported formats: Text, VTT, SRT
</p>
</div>
</div>
</body>
</html>

28
logout.php Normal file
View file

@ -0,0 +1,28 @@
<?php
/**
* Logout Handler
* Destroys session and redirects to login page
*/
// Load config first (sets session ini settings)
require_once 'config.php';
// Start session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Unset all session variables
$_SESSION = [];
// Delete session cookie
if (isset($_COOKIE[session_name()])) {
setcookie(session_name(), '', time() - 3600, '/');
}
// Destroy the session
session_destroy();
// Redirect to login page
header('Location: login.php');
exit;

View file

@ -1,7 +1,13 @@
<?php
session_start();
// Load config first (which sets session ini settings before starting session)
require_once 'config.php';
// Require authentication (this will start the session if needed)
if (!isAuthenticated()) {
echo json_encode(['success' => false, 'error' => 'Authentication required. Please log in.']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['voiceFile'])) {
$file = $_FILES['voiceFile'];
$outputFormat = isset($_POST['outputFormat']) ? $_POST['outputFormat'] : 'txt';
@ -40,6 +46,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['voiceFile'])) {
$data = json_decode($response, true);
if (isset($data['success']) && $data['success']) {
// Track file ownership in session
if (!isset($_SESSION['user_files'])) {
$_SESSION['user_files'] = [];
}
// Add original file to user's file list
$_SESSION['user_files'][] = $data['filename'];
// Return content for display and download links
$downloadUrl = 'download.php?file=' . urlencode($data['filename']);
@ -62,6 +76,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['voiceFile'])) {
// Add translated content if available
if (isset($data['translated_filename'])) {
// Add translated file to user's file list
$_SESSION['user_files'][] = $data['translated_filename'];
$response['translatedFileUrl'] = 'download.php?file=' . urlencode($data['translated_filename']);
$response['translatedFilename'] = $data['translated_filename'];

View file

@ -29,6 +29,64 @@ input, button, textarea, select, label {
animation: fadeIn 0.5s ease-in;
}
.dev-mode-banner {
background: #ff9800;
color: #000;
padding: 10px 20px;
border-radius: 8px;
text-align: center;
font-weight: 600;
font-size: 14px;
margin-bottom: 20px;
border: 2px solid #f57c00;
}
.user-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #0a0a0a;
border-radius: 12px;
border: 1px solid #333;
margin-bottom: 25px;
}
.user-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-name {
color: #FFC407;
font-weight: 600;
font-size: 15px;
}
.user-email {
color: #999;
font-size: 13px;
}
.logout-btn {
padding: 8px 20px;
background: transparent;
color: #FFC407;
border: 2px solid #FFC407;
border-radius: 20px;
text-decoration: none;
font-weight: 600;
font-size: 14px;
transition: all 0.3s ease;
}
.logout-btn:hover {
background: #FFC407;
color: #000;
transform: translateY(-1px);
}
@keyframes fadeIn {
from {
opacity: 0;

View file

@ -2,10 +2,34 @@
/**
* Test download functionality
*/
require_once 'config.php';
// Require authentication
requireAuth();
$user = getCurrentUser();
echo "<h2>Download Test</h2>";
echo "<p>Logged in as: <strong>" . htmlspecialchars($user['name']) . "</strong> (" . htmlspecialchars($user['email']) . ")</p>";
// Show user's accessible files
echo "<h3>Your Files (accessible for download):</h3>";
if (isset($_SESSION['user_files']) && count($_SESSION['user_files']) > 0) {
echo "<ul>";
foreach ($_SESSION['user_files'] as $file) {
echo "<li>";
echo "<strong>" . htmlspecialchars($file) . "</strong><br>";
echo "<a href='download.php?file=" . urlencode($file) . "' target='_blank'>Download</a>";
echo "</li><br>";
}
echo "</ul>";
} else {
echo "<p>No files uploaded yet in this session.</p>";
}
// List all files in outputs directory
$outputDir = __DIR__ . '/outputs/';
echo "<h2>Files in outputs directory:</h2>";
echo "<h2>All Files in outputs directory:</h2>";
echo "<ul>";
if (is_dir($outputDir)) {