Implemented complete Microsoft Authentication Library (MSAL) / Azure AD Single Sign-On (SSO) system following Ferrero app pattern. KEY FEATURE: Toggle authentication on/off via environment variable - SSO_ENABLED=false → Mock user, no login required (local dev) - SSO_ENABLED=true → Full Azure AD authentication (production) NEW FILES: - composer.json - Firebase JWT dependency - .env.example - Environment variable template - env_loader.php - Parse .env file - JWTValidator.php - Validate JWT tokens from Azure AD - AuthMiddleware.php - Core auth orchestrator with login UI - auth.php - Authentication API (login/logout/status) - auth-test.php - Debug authentication status - AUTH_README.md - Complete setup documentation UPDATED FILES: - config.php - Load env vars, add SSO constants - index.php - Require auth, add logout button, MSAL script - api.php - Add authentication check - enhance_prompt.php - Add authentication check - .gitignore - Exclude .env and vendor/ AUTHENTICATION FLOW: 1. User visits app → Auth check 2. If SSO disabled → Mock "Local Developer" user 3. If SSO enabled → Validate JWT from cookie 4. If no token → Show MSAL login page 5. User signs in → Token validated → Cookie set → App loads SECURITY FEATURES: ✅ httpOnly cookies (XSS prevention) ✅ SameSite=Lax (CSRF prevention) ✅ JWT signature validation ✅ Claims validation (exp, nbf, aud, iss) ✅ JWKS from Azure AD ✅ 24-hour token expiration ✅ Secure flag for HTTPS DEPENDENCIES INSTALLED: - firebase/php-jwt v6.11.1 TESTING: - Local: SSO disabled by default in .env - Server: Set SSO_ENABLED=true with Azure AD credentials - Cannot test MSAL locally (redirect URI bound to server) DEPLOYMENT: 1. Install composer dependencies 2. Configure .env with Azure AD credentials 3. Set SSO_ENABLED=true when ready 4. Visit auth-test.php to verify setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
311 lines
11 KiB
PHP
311 lines
11 KiB
PHP
<?php
|
|
header('Content-Type: application/json');
|
|
|
|
// Load configuration and authentication
|
|
require_once 'config.php';
|
|
require_once 'AuthMiddleware.php';
|
|
require_once 'session_manager.php';
|
|
|
|
// Check authentication
|
|
$auth = new AuthMiddleware();
|
|
$authStatus = $auth->isAuthenticated();
|
|
|
|
if (!$authStatus['authenticated']) {
|
|
http_response_code(401);
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Authentication required',
|
|
'requiresAuth' => true
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Initialize session manager for multi-user support
|
|
$sessionManager = new SessionManager();
|
|
|
|
class NanoBananaProAPI {
|
|
private $apiKey;
|
|
private $baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
private $model = 'gemini-3-pro-image-preview';
|
|
|
|
public function __construct($apiKey) {
|
|
$this->apiKey = $apiKey;
|
|
}
|
|
|
|
public function generateImage($prompt, $aspectRatio = '16:9', $imageSize = '2K', $inputImage = null) {
|
|
$parts = [];
|
|
|
|
// If there's an input image, add it for editing
|
|
if ($inputImage) {
|
|
error_log("Edit mode: Input image size = " . strlen($inputImage) . " chars");
|
|
|
|
// Clean any whitespace from base64 data
|
|
$inputImage = preg_replace('/\s+/', '', $inputImage);
|
|
|
|
// Basic validation - check if it looks like base64
|
|
if (!preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $inputImage)) {
|
|
error_log("Base64 validation failed for input image");
|
|
error_log("First 100 chars: " . substr($inputImage, 0, 100));
|
|
throw new Exception("Invalid image data format - not valid base64");
|
|
}
|
|
|
|
$parts[] = [
|
|
'inline_data' => [
|
|
'mime_type' => 'image/jpeg', // Use jpeg to match API output
|
|
'data' => $inputImage
|
|
]
|
|
];
|
|
|
|
error_log("Added input image to request (mime_type: image/jpeg)");
|
|
} else {
|
|
error_log("Generation mode: No input image");
|
|
}
|
|
|
|
// Add the text prompt
|
|
$parts[] = ['text' => $prompt];
|
|
|
|
$payload = [
|
|
'contents' => [
|
|
['parts' => $parts]
|
|
],
|
|
'generationConfig' => [
|
|
'responseModalities' => ['IMAGE'],
|
|
'imageConfig' => [
|
|
'aspectRatio' => $aspectRatio,
|
|
'imageSize' => $imageSize
|
|
]
|
|
]
|
|
];
|
|
|
|
return $this->makeRequest($payload);
|
|
}
|
|
|
|
private function makeRequest($payload, $retryCount = 0) {
|
|
$url = "{$this->baseUrl}/{$this->model}:generateContent";
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/json',
|
|
'x-goog-api-key: ' . $this->apiKey
|
|
],
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_TIMEOUT => 120 // 2 minute timeout for image generation
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
|
|
if (curl_errno($ch)) {
|
|
$error = curl_error($ch);
|
|
curl_close($ch);
|
|
throw new Exception('cURL error: ' . $error);
|
|
}
|
|
|
|
curl_close($ch);
|
|
|
|
if ($httpCode !== 200) {
|
|
$errorData = json_decode($response, true);
|
|
$errorMessage = $errorData['error']['message'] ?? "HTTP $httpCode";
|
|
$errorStatus = $errorData['error']['status'] ?? 'UNKNOWN';
|
|
|
|
// Log full error details for debugging
|
|
error_log("API Error - HTTP $httpCode (Status: $errorStatus)");
|
|
error_log("Error message: " . $errorMessage);
|
|
error_log("Full response: " . $response);
|
|
|
|
// Handle specific error types
|
|
if ($httpCode === 500 && stripos($errorMessage, 'internal') !== false && $retryCount < 2) {
|
|
// Retry on internal errors (up to 2 times)
|
|
error_log("Retrying request due to internal error (attempt " . ($retryCount + 1) . ")");
|
|
sleep(2); // Wait 2 seconds before retry
|
|
return $this->makeRequest($payload, $retryCount + 1);
|
|
}
|
|
|
|
if ($httpCode === 429 || $errorStatus === 'RESOURCE_EXHAUSTED') {
|
|
throw new Exception("API rate limit exceeded. Please wait a moment and try again.");
|
|
}
|
|
|
|
if ($errorStatus === 'INVALID_ARGUMENT') {
|
|
throw new Exception("Invalid request format. This might be due to corrupted image data. Try clicking 'Start New Image' and generating fresh.");
|
|
}
|
|
|
|
throw new Exception("API error: $errorMessage (HTTP $httpCode, Status: $errorStatus)");
|
|
}
|
|
|
|
return json_decode($response, true);
|
|
}
|
|
|
|
public function extractImageData($response) {
|
|
// Log the response for debugging
|
|
error_log("API Response: " . json_encode($response));
|
|
|
|
// Check for finish reasons that indicate content issues
|
|
if (isset($response['candidates'][0]['finishReason'])) {
|
|
$finishReason = $response['candidates'][0]['finishReason'];
|
|
$finishMessage = $response['candidates'][0]['finishMessage'] ?? '';
|
|
|
|
if ($finishReason === 'IMAGE_RECITATION') {
|
|
throw new Exception('Image generation blocked by content filter. Try a more creative and descriptive prompt. Avoid simple geometric shapes or common objects. Example: "A futuristic cityscape at sunset with flying cars" instead of "a red circle".');
|
|
}
|
|
|
|
if ($finishReason === 'SAFETY') {
|
|
throw new Exception('Image generation blocked by safety filters. Please try a different prompt.');
|
|
}
|
|
|
|
if ($finishReason !== 'STOP' && !empty($finishMessage)) {
|
|
throw new Exception('Image generation failed: ' . $finishMessage);
|
|
}
|
|
}
|
|
|
|
if (isset($response['candidates'][0]['content']['parts'])) {
|
|
foreach ($response['candidates'][0]['content']['parts'] as $part) {
|
|
if (isset($part['inline_data']['data'])) {
|
|
return [
|
|
'base64' => $part['inline_data']['data'],
|
|
'mime_type' => $part['inline_data']['mime_type'] ?? 'image/png'
|
|
];
|
|
}
|
|
// Check for inlineData (alternative format)
|
|
if (isset($part['inlineData']['data'])) {
|
|
return [
|
|
'base64' => $part['inlineData']['data'],
|
|
'mime_type' => $part['inlineData']['mimeType'] ?? 'image/png'
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Provide detailed error with response structure
|
|
$errorDetails = "Response structure: " . json_encode(array_keys($response));
|
|
if (isset($response['candidates'][0])) {
|
|
$errorDetails .= " | Candidate keys: " . json_encode(array_keys($response['candidates'][0]));
|
|
}
|
|
throw new Exception('No image data found in API response. ' . $errorDetails);
|
|
}
|
|
}
|
|
|
|
// Handle API requests
|
|
try {
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
throw new Exception('Invalid request method');
|
|
}
|
|
|
|
$action = $_POST['action'] ?? null;
|
|
|
|
if (!$action) {
|
|
throw new Exception('No action specified');
|
|
}
|
|
|
|
// Handle reset action
|
|
if ($action === 'reset') {
|
|
$sessionManager->reset();
|
|
|
|
echo json_encode(['success' => true]);
|
|
exit;
|
|
}
|
|
|
|
// Handle generate action
|
|
if ($action === 'generate') {
|
|
$prompt = $_POST['prompt'] ?? null;
|
|
$aspectRatio = $_POST['aspectRatio'] ?? '16:9';
|
|
$imageSize = $_POST['imageSize'] ?? '2K';
|
|
$uploadedImage = $_POST['uploadedImage'] ?? null;
|
|
$uploadedImageType = $_POST['uploadedImageType'] ?? null;
|
|
|
|
// Check if API key is configured
|
|
if (!defined('GEMINI_API_KEY') || empty(GEMINI_API_KEY)) {
|
|
throw new Exception('API key not configured. Please set GEMINI_API_KEY in config.php');
|
|
}
|
|
|
|
// Handle uploaded image
|
|
$currentImage = $sessionManager->getCurrentImage();
|
|
if ($uploadedImage && !$currentImage) {
|
|
error_log("Processing uploaded image (type: $uploadedImageType)");
|
|
|
|
// If there's a prompt, apply it to the uploaded image
|
|
if ($prompt) {
|
|
error_log("Applying prompt to uploaded image: $prompt");
|
|
|
|
// Initialize API
|
|
$api = new NanoBananaProAPI(GEMINI_API_KEY);
|
|
|
|
// Generate/edit image with the uploaded image as input
|
|
$response = $api->generateImage($prompt, $aspectRatio, $imageSize, $uploadedImage);
|
|
$imageData = $api->extractImageData($response);
|
|
|
|
// Save edited image to disk
|
|
$filename = $sessionManager->saveImage($imageData['base64'], $imageData['mime_type']);
|
|
$sessionManager->setCurrentImage($filename, $imageData['mime_type']);
|
|
|
|
// Add to conversation history
|
|
$sessionManager->addToHistory('Uploaded image + ' . $prompt, 'upload_edit');
|
|
} else {
|
|
// Just uploaded, no prompt yet - save to disk
|
|
$filename = $sessionManager->saveImage($uploadedImage, $uploadedImageType);
|
|
$sessionManager->setCurrentImage($filename, $uploadedImageType);
|
|
|
|
// Add to conversation history
|
|
$sessionManager->addToHistory('Image uploaded', 'upload');
|
|
}
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'message' => $prompt ? 'Image uploaded and edited successfully' : 'Image uploaded successfully'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Regular generation/editing flow
|
|
if (!$prompt) {
|
|
throw new Exception('Prompt is required');
|
|
}
|
|
|
|
// Initialize API
|
|
$api = new NanoBananaProAPI(GEMINI_API_KEY);
|
|
|
|
// Get current image if editing
|
|
$currentImage = $sessionManager->getCurrentImage();
|
|
$inputImage = $currentImage ? $currentImage['data'] : null;
|
|
|
|
// Generate or edit image
|
|
$response = $api->generateImage($prompt, $aspectRatio, $imageSize, $inputImage);
|
|
$imageData = $api->extractImageData($response);
|
|
|
|
// Save to disk
|
|
$filename = $sessionManager->saveImage($imageData['base64'], $imageData['mime_type']);
|
|
$sessionManager->setCurrentImage($filename, $imageData['mime_type']);
|
|
|
|
// Add to conversation history
|
|
$sessionManager->addToHistory($prompt, $inputImage ? 'edit' : 'generate');
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'message' => 'Image generated successfully'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
throw new Exception('Invalid action');
|
|
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
|
|
// Log detailed error info
|
|
error_log("Exception in api.php: " . $e->getMessage());
|
|
error_log("Stack trace: " . $e->getTraceAsString());
|
|
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => $e->getMessage(),
|
|
'debug' => [
|
|
'file' => basename($e->getFile()),
|
|
'line' => $e->getLine(),
|
|
'timestamp' => date('Y-m-d H:i:s')
|
|
]
|
|
]);
|
|
exit;
|
|
}
|