loreal-global-kickoff/BoxService.php
DJP dbf7090d09 Initial commit: L'Oréal Box Asset Submission Form
- Set up PHP application with Composer and JWT library
- Implemented SSO authentication with local dev mode
- Created Box API service for folder validation
- Built two-column form interface (form + preview)
- Added real-time Box ID validation with AJAX
- Integrated webhook submission with status response
- Auto-populate Master Campaign Number from Box folder hierarchy
- Responsive design with Montserrat font and black/yellow theme

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 14:43:36 -05:00

279 lines
8.5 KiB
PHP

<?php
/**
* Box API Service
* Handles Box API authentication and operations using JWT
*/
use Firebase\JWT\JWT;
class BoxService {
private $config;
private $boxConfig;
private $jwtConfig;
private $accessToken;
private $tokenExpiry;
public function __construct() {
$this->config = require __DIR__ . '/config.php';
$this->boxConfig = $this->config['box'];
// Load Box JWT configuration
if (!file_exists($this->boxConfig['jwt_config_path'])) {
throw new Exception('Box JWT configuration file not found');
}
$jwtConfigJson = file_get_contents($this->boxConfig['jwt_config_path']);
$this->jwtConfig = json_decode($jwtConfigJson, true);
if (!$this->jwtConfig) {
throw new Exception('Invalid Box JWT configuration');
}
}
/**
* Generate JWT assertion for Box authentication
*/
private function generateJWT() {
$authConfig = $this->jwtConfig['boxAppSettings']['appAuth'];
$keyId = $authConfig['publicKeyID'];
$privateKey = $authConfig['privateKey'];
$passphrase = $authConfig['passphrase'];
// Decrypt private key with passphrase
$privateKeyResource = openssl_pkey_get_private($privateKey, $passphrase);
if (!$privateKeyResource) {
throw new Exception('Failed to decrypt private key');
}
// Export private key in PEM format
openssl_pkey_export($privateKeyResource, $pemKey);
$claims = [
'iss' => $this->jwtConfig['boxAppSettings']['clientID'],
'sub' => $this->boxConfig['enterprise_id'],
'box_sub_type' => 'enterprise',
'aud' => 'https://api.box.com/oauth2/token',
'jti' => bin2hex(random_bytes(32)),
'exp' => time() + 45
];
$header = [
'alg' => 'RS256',
'typ' => 'JWT',
'kid' => $keyId
];
return JWT::encode($claims, $pemKey, 'RS256', null, $header);
}
/**
* Authenticate with Box and get access token
*/
public function authenticate() {
// Check if we have a valid cached token
if ($this->accessToken && $this->tokenExpiry && time() < $this->tokenExpiry) {
return $this->accessToken;
}
try {
$assertion = $this->generateJWT();
$postData = [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $assertion,
'client_id' => $this->jwtConfig['boxAppSettings']['clientID'],
'client_secret' => $this->jwtConfig['boxAppSettings']['clientSecret']
];
$ch = curl_init($this->boxConfig['oauth_url']);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($postData),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded'
]
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception('Box authentication failed: ' . $response);
}
$data = json_decode($response, true);
if (!isset($data['access_token'])) {
throw new Exception('No access token in response');
}
$this->accessToken = $data['access_token'];
$this->tokenExpiry = time() + ($data['expires_in'] ?? 3600) - 300; // 5 min buffer
return $this->accessToken;
} catch (Exception $e) {
throw new Exception('Box authentication error: ' . $e->getMessage());
}
}
/**
* Make an authenticated API request to Box
*/
private function apiRequest($endpoint, $method = 'GET', $data = null) {
$token = $this->authenticate();
$url = $this->boxConfig['api_base_url'] . $endpoint;
$ch = curl_init($url);
$headers = [
'Authorization: Bearer ' . $token,
'Content-Type: application/json'
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CUSTOMREQUEST => $method
]);
if ($data !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 404) {
return ['error' => 'Not found', 'httpCode' => 404];
}
if ($httpCode !== 200) {
return ['error' => 'API request failed', 'httpCode' => $httpCode, 'response' => $response];
}
return json_decode($response, true);
}
/**
* Get folder information including path collection
*/
public function getFolderInfo($folderId) {
return $this->apiRequest('/folders/' . $folderId);
}
/**
* Get folder contents (files and folders)
*/
public function getFolderContents($folderId, $limit = 1000) {
return $this->apiRequest('/folders/' . $folderId . '/items?limit=' . $limit);
}
/**
* Get the folder that is two levels up from the given folder
* Returns array with 'id' and 'name' or null if not found
*/
public function getGrandparentFolder($folderId) {
$folderInfo = $this->getFolderInfo($folderId);
if (isset($folderInfo['error'])) {
return ['error' => $folderInfo['error']];
}
$pathCollection = $folderInfo['path_collection']['entries'] ?? [];
$totalCount = count($pathCollection);
// Two levels up means we need at least 3 folders in the path
// (root folder is always index 0)
if ($totalCount < 3) {
return ['error' => 'Folder is not deep enough (needs at least 2 parent folders)'];
}
// Index for two levels up
// If path is [0: Root, 1: Level1, 2: Level2, 3: Level3, 4: Current]
// Two levels up from Current (index 4) is Level2 (index 2)
// That's totalCount - 3
$grandparentIndex = $totalCount - 3;
if ($grandparentIndex < 0) {
$grandparentIndex = 0; // Fallback to root
}
$grandparent = $pathCollection[$grandparentIndex];
return [
'id' => $grandparent['id'],
'name' => $grandparent['name']
];
}
/**
* Validate a Box ID and return comprehensive information
*/
public function validateBoxId($boxId) {
try {
// Get folder info
$folderInfo = $this->getFolderInfo($boxId);
if (isset($folderInfo['error'])) {
return [
'valid' => false,
'error' => $folderInfo['error']
];
}
// Get grandparent folder (two levels up)
$grandparent = $this->getGrandparentFolder($boxId);
// Get folder contents
$contents = $this->getFolderContents($boxId);
if (isset($contents['error'])) {
return [
'valid' => false,
'error' => $contents['error']
];
}
// Parse contents into files and folders
$files = [];
$folders = [];
foreach ($contents['entries'] ?? [] as $item) {
$itemData = [
'id' => $item['id'],
'name' => $item['name'],
'type' => $item['type']
];
if ($item['type'] === 'folder') {
$folders[] = $itemData;
} else {
$files[] = $itemData;
}
}
return [
'valid' => true,
'folderInfo' => [
'id' => $folderInfo['id'],
'name' => $folderInfo['name']
],
'grandparent' => $grandparent,
'contents' => [
'total' => $contents['total_count'] ?? 0,
'folders' => $folders,
'files' => $files
]
];
} catch (Exception $e) {
return [
'valid' => false,
'error' => 'Validation error: ' . $e->getMessage()
];
}
}
}