loreal-global-kickoff/BoxService.php
DJP 47b0615c3a Fix grandparent logic: properly get folder two levels up
Structure: Campaign Number → CAMPAIGN_ASSETS → SUPPLIED_ASSETS
- Changed to fetch parent, then parent's parent (proper two levels up)
- Previous logic was using path_collection which showed CAMPAIGN_ASSETS
- Now correctly retrieves the campaign number folder
- Uses two separate API calls to traverse up the hierarchy

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:22:06 -05:00

324 lines
9.8 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 grandparent folder (two levels up) from the given folder
* This gets the campaign number folder above CAMPAIGN_ASSETS
* Returns array with 'id' and 'name' or error
*/
public function getGrandparentFolder($folderId) {
$folderInfo = $this->getFolderInfo($folderId);
if (isset($folderInfo['error'])) {
return ['error' => $folderInfo['error']];
}
// First get the parent (CAMPAIGN_ASSETS)
$parent = $folderInfo['parent'] ?? null;
if (!$parent || !isset($parent['id'])) {
return ['error' => 'No parent folder found'];
}
// Now get the parent's parent (the campaign number)
$parentInfo = $this->getFolderInfo($parent['id']);
if (isset($parentInfo['error'])) {
return ['error' => $parentInfo['error']];
}
$grandparent = $parentInfo['parent'] ?? null;
if (!$grandparent) {
return ['error' => 'No grandparent folder found'];
}
return [
'id' => $grandparent['id'],
'name' => $grandparent['name']
];
}
/**
* Get folder contents recursively (nested folders)
*/
private function getFolderContentsRecursive($folderId, $depth = 0, $maxDepth = 3) {
// Limit recursion depth to prevent infinite loops
if ($depth >= $maxDepth) {
return [];
}
$contents = $this->getFolderContents($folderId);
if (isset($contents['error'])) {
return [];
}
$result = [
'files' => [],
'folders' => []
];
foreach ($contents['entries'] ?? [] as $item) {
if ($item['type'] === 'folder') {
// Recursively get contents of this folder
$subContents = $this->getFolderContentsRecursive($item['id'], $depth + 1, $maxDepth);
$folderData = [
'id' => $item['id'],
'name' => $item['name'],
'type' => 'folder',
'contents' => $subContents
];
$result['folders'][] = $folderData;
} else {
$result['files'][] = [
'id' => $item['id'],
'name' => $item['name'],
'type' => $item['type']
];
}
}
return $result;
}
/**
* 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']
];
}
// Check if folder name is SUPPLIED_ASSETS
if (strtoupper($folderInfo['name']) !== 'SUPPLIED_ASSETS') {
return [
'valid' => false,
'error' => 'Invalid folder. The folder must be named "SUPPLIED_ASSETS". Current folder name: "' . $folderInfo['name'] . '"'
];
}
// Get grandparent folder (two levels up) - this is the campaign number
$grandparent = $this->getGrandparentFolder($boxId);
// Get folder contents recursively
$contents = $this->getFolderContentsRecursive($boxId);
// Count total items recursively
$totalItems = $this->countItemsRecursive($contents);
return [
'valid' => true,
'folderInfo' => [
'id' => $folderInfo['id'],
'name' => $folderInfo['name']
],
'grandparent' => $grandparent,
'contents' => [
'total' => $totalItems,
'folders' => $contents['folders'],
'files' => $contents['files']
]
];
} catch (Exception $e) {
return [
'valid' => false,
'error' => 'Validation error: ' . $e->getMessage()
];
}
}
/**
* Count total items recursively
*/
private function countItemsRecursive($contents) {
$count = count($contents['files'] ?? []);
foreach ($contents['folders'] ?? [] as $folder) {
$count++; // Count the folder itself
$count += $this->countItemsRecursive($folder['contents'] ?? []);
}
return $count;
}
}