Includes backend API, public-v2 frontend, database migrations, Composer config, and deployment/implementation docs. Config files with credentials are excluded via .gitignore. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
483 lines
18 KiB
PHP
483 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* DAM Client - OpenText DAM API Integration with mTLS V2 Authentication
|
|
* Handles campaign queries and status updates
|
|
*/
|
|
|
|
class DamClient {
|
|
private $baseUrl; // Direct DAM URL
|
|
private $mtlsOAuthUrl; // mTLS OAuth endpoint
|
|
private $certPath; // Path to PFX certificate
|
|
private $certPassword; // Certificate password
|
|
private $clientId; // OAuth client ID
|
|
private $clientSecret; // OAuth client secret
|
|
private $accessToken; // OAuth token obtained via mTLS
|
|
private $tokenExpiry; // Token expiration timestamp
|
|
private $timeout; // Request timeout in seconds
|
|
|
|
/**
|
|
* Initialize DAM Client with mTLS V2 configuration
|
|
*/
|
|
public function __construct($config) {
|
|
$this->baseUrl = $config['dam']['base_url'];
|
|
$this->mtlsOAuthUrl = $config['dam']['mtls_oauth_url'];
|
|
$this->certPath = $config['dam']['mtls_cert_path'];
|
|
$this->certPassword = $config['dam']['mtls_cert_password'];
|
|
$this->clientId = $config['dam']['client_id'];
|
|
$this->clientSecret = $config['dam']['client_secret'];
|
|
$this->timeout = $config['dam']['timeout'] ?? 30;
|
|
$this->accessToken = null;
|
|
$this->tokenExpiry = 0;
|
|
|
|
// Validate configuration
|
|
if (empty($this->baseUrl)) {
|
|
throw new Exception("DAM base URL not configured");
|
|
}
|
|
if (empty($this->mtlsOAuthUrl)) {
|
|
throw new Exception("mTLS OAuth URL not configured");
|
|
}
|
|
if (empty($this->certPath) || !file_exists($this->certPath)) {
|
|
throw new Exception("mTLS certificate not found: " . $this->certPath);
|
|
}
|
|
if (empty($this->clientId) || empty($this->clientSecret)) {
|
|
throw new Exception("OAuth client credentials not configured");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get OAuth access token using client certificate (mTLS V2)
|
|
* Token is cached and automatically refreshed when expired
|
|
*/
|
|
private function getAccessToken() {
|
|
// Return cached token if still valid
|
|
if ($this->accessToken && time() < $this->tokenExpiry) {
|
|
return $this->accessToken;
|
|
}
|
|
|
|
// Request new token via mTLS
|
|
return $this->requestOAuthTokenViaMTLS();
|
|
}
|
|
|
|
/**
|
|
* Request OAuth token using client certificate
|
|
*/
|
|
private function requestOAuthTokenViaMTLS() {
|
|
error_log("DAM Client: Requesting OAuth token via mTLS from: " . $this->mtlsOAuthUrl);
|
|
|
|
// Convert PFX to PEM format
|
|
$pemCert = $this->convertPfxToPem($this->certPath, $this->certPassword);
|
|
|
|
// Prepare OAuth request body
|
|
$postData = http_build_query([
|
|
'grant_type' => 'client_credentials',
|
|
'client_id' => $this->clientId,
|
|
'client_secret' => $this->clientSecret
|
|
]);
|
|
|
|
// Initialize cURL with client certificate
|
|
$ch = curl_init($this->mtlsOAuthUrl);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $postData,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => $this->timeout,
|
|
CURLOPT_SSLCERT => $pemCert['cert'],
|
|
CURLOPT_SSLKEY => $pemCert['key'],
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
'Accept: application/json'
|
|
]
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$error = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
// Clean up temporary PEM files
|
|
@unlink($pemCert['cert']);
|
|
@unlink($pemCert['key']);
|
|
|
|
if ($error) {
|
|
throw new Exception("mTLS OAuth request failed: " . $error);
|
|
}
|
|
|
|
if ($httpCode !== 200) {
|
|
throw new Exception("mTLS OAuth failed: HTTP " . $httpCode . " - " . $response);
|
|
}
|
|
|
|
$data = json_decode($response, true);
|
|
if (!isset($data['access_token'])) {
|
|
throw new Exception("No access token in OAuth response");
|
|
}
|
|
|
|
// Cache token with 60 second buffer before expiry
|
|
$this->accessToken = $data['access_token'];
|
|
$expiresIn = $data['expires_in'] ?? 3600;
|
|
$this->tokenExpiry = time() + $expiresIn - 60;
|
|
|
|
error_log("DAM Client: OAuth token obtained (expires in " . $expiresIn . "s)");
|
|
|
|
return $this->accessToken;
|
|
}
|
|
|
|
/**
|
|
* Convert PFX certificate to PEM format
|
|
* Returns array with paths to temporary cert and key files
|
|
*/
|
|
private function convertPfxToPem($pfxPath, $password) {
|
|
// Read PFX file
|
|
$pfxContent = file_get_contents($pfxPath);
|
|
if ($pfxContent === false) {
|
|
throw new Exception("Failed to read PFX certificate: " . $pfxPath);
|
|
}
|
|
|
|
// Parse PFX
|
|
$certs = [];
|
|
if (!openssl_pkcs12_read($pfxContent, $certs, $password)) {
|
|
throw new Exception("Failed to parse PFX certificate - check password");
|
|
}
|
|
|
|
// Create temporary PEM files
|
|
$tempDir = sys_get_temp_dir();
|
|
$certFile = tempnam($tempDir, 'dam_cert_') . '.pem';
|
|
$keyFile = tempnam($tempDir, 'dam_key_') . '.pem';
|
|
|
|
// Write certificate
|
|
if (file_put_contents($certFile, $certs['cert']) === false) {
|
|
throw new Exception("Failed to write certificate to temp file");
|
|
}
|
|
|
|
// Write private key
|
|
if (file_put_contents($keyFile, $certs['pkey']) === false) {
|
|
@unlink($certFile);
|
|
throw new Exception("Failed to write private key to temp file");
|
|
}
|
|
|
|
return [
|
|
'cert' => $certFile,
|
|
'key' => $keyFile
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Make authenticated API request to DAM
|
|
*/
|
|
private function makeApiRequest($method, $url, $data = null) {
|
|
$token = $this->getAccessToken();
|
|
|
|
error_log("DAM API Request: " . $method . " " . $url);
|
|
|
|
$ch = curl_init($url);
|
|
$headers = [
|
|
'Authorization: Bearer ' . $token,
|
|
'Accept: application/json'
|
|
];
|
|
|
|
$curlOpts = [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => $this->timeout,
|
|
CURLOPT_SSL_VERIFYPEER => false, // Disable SSL verification for OAuth/Hybrid
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
];
|
|
|
|
if ($method === 'POST' || $method === 'PUT' || $method === 'PATCH') {
|
|
$curlOpts[CURLOPT_CUSTOMREQUEST] = $method;
|
|
if ($data !== null) {
|
|
$headers[] = 'Content-Type: application/json';
|
|
$curlOpts[CURLOPT_POSTFIELDS] = json_encode($data);
|
|
$curlOpts[CURLOPT_HTTPHEADER] = $headers;
|
|
}
|
|
} elseif ($method === 'GET') {
|
|
$curlOpts[CURLOPT_HTTPGET] = true;
|
|
}
|
|
|
|
curl_setopt_array($ch, $curlOpts);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$error = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
error_log("DAM API Response: HTTP " . $httpCode);
|
|
|
|
if ($error) {
|
|
throw new Exception("API request failed: " . $error);
|
|
}
|
|
|
|
return [
|
|
'status' => $httpCode,
|
|
'body' => $response,
|
|
'data' => json_decode($response, true)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Search for campaigns by status
|
|
*
|
|
* @param string $status Campaign status (A1, A2, A3, etc.)
|
|
* @param string $campaignType 'Local Adaptation' or 'Global comm'
|
|
* @return array List of campaigns
|
|
*/
|
|
public function searchCampaigns($status = 'A2', $campaignType = 'Local Adaptation') {
|
|
// Build search condition matching Python implementation
|
|
$searchCondition = [
|
|
'search_condition_list' => [
|
|
'search_condition' => [
|
|
[
|
|
'type' => 'com.artesia.search.SearchScalarCondition',
|
|
'metadata_field_id' => 'ARTESIA.FIELD.CONTAINER TYPE NAME',
|
|
'relational_operator_id' => 'ARTESIA.OPERATOR.CHAR.CONTAINS',
|
|
'value' => 'GLOBALCAMPAING',
|
|
'left_paren' => '(',
|
|
'right_paren' => ')'
|
|
],
|
|
[
|
|
'type' => 'com.artesia.search.SearchScalarCondition',
|
|
'metadata_field_id' => 'FERRERO.FIELD.CAMPAIGN TYPE',
|
|
'relational_operator_id' => 'ARTESIA.OPERATOR.CHAR.CONTAINS',
|
|
'value' => $campaignType,
|
|
'relational_operator' => 'and'
|
|
]
|
|
]
|
|
]
|
|
];
|
|
|
|
// URL encode search condition
|
|
$searchConditionEncoded = urlencode(json_encode($searchCondition));
|
|
|
|
// Build URL - include page_size to avoid DAM default limit (~10)
|
|
$url = $this->baseUrl . '/v6/search/text?load_type=metadata&search_config_id=18&page_size=200&search_condition_list=' . $searchConditionEncoded;
|
|
|
|
// Make request
|
|
$response = $this->makeApiRequest('GET', $url);
|
|
|
|
if ($response['status'] !== 200) {
|
|
throw new Exception("Campaign search failed: HTTP " . $response['status']);
|
|
}
|
|
|
|
$data = $response['data'];
|
|
$allCampaigns = [];
|
|
|
|
// Extract campaigns from response
|
|
if (isset($data['search_result_resource'])) {
|
|
$campaigns = $data['search_result_resource']['asset_list'] ?? [];
|
|
|
|
// Fallback to search_result_element_list
|
|
if (empty($campaigns)) {
|
|
$results = $data['search_result_resource']['search_result']['search_result_element_list'] ?? [];
|
|
$campaigns = array_map(function($r) {
|
|
return $r['resource'] ?? [];
|
|
}, $results);
|
|
}
|
|
|
|
// Extract campaign info and filter by status
|
|
foreach ($campaigns as $asset) {
|
|
$campaign = $this->extractCampaignInfo($asset);
|
|
|
|
// Filter by status if provided
|
|
if ($status === null || $campaign['status'] === $status) {
|
|
$allCampaigns[] = $campaign;
|
|
}
|
|
}
|
|
}
|
|
|
|
error_log("DAM Client: Found " . count($allCampaigns) . " campaigns with status " . $status);
|
|
|
|
return $allCampaigns;
|
|
}
|
|
|
|
/**
|
|
* Extract campaign information from asset data
|
|
*/
|
|
private function extractCampaignInfo($asset) {
|
|
$campaign = [
|
|
'asset_id' => $asset['asset_id'] ?? null,
|
|
'campaign_name' => $asset['name'] ?? null,
|
|
'campaign_id' => null,
|
|
'status' => null,
|
|
'brand' => null,
|
|
'market' => null
|
|
];
|
|
|
|
// Extract metadata fields
|
|
if (isset($asset['metadata']['metadata_element_list'])) {
|
|
foreach ($asset['metadata']['metadata_element_list'] as $category) {
|
|
if (isset($category['metadata_element_list'])) {
|
|
foreach ($category['metadata_element_list'] as $field) {
|
|
$fieldId = $field['id'] ?? '';
|
|
$value = $this->extractFieldValue($field);
|
|
|
|
switch ($fieldId) {
|
|
case 'ARTESIA.FIELD.NAME':
|
|
case 'INER_NAME_GENERIC':
|
|
$campaign['campaign_name'] = $value;
|
|
break;
|
|
case 'FERRERO.FIELD.CAMPAIGN ID':
|
|
case 'FERRERO.FIELD.CAMPAIGN_ID':
|
|
$campaign['campaign_id'] = $value;
|
|
break;
|
|
case 'CONTENT.SCALING.STATUS':
|
|
$campaign['status'] = $value;
|
|
break;
|
|
case 'FERRERO.FIELD.CAMPAIGN_BRAND':
|
|
$campaign['brand'] = $value;
|
|
break;
|
|
case 'FERRERO.FIELD.CAMPAIGN_MARKET':
|
|
$campaign['market'] = $value;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $campaign;
|
|
}
|
|
|
|
/**
|
|
* Extract value from metadata field structure
|
|
*/
|
|
private function extractFieldValue($field) {
|
|
if (!isset($field['value'])) {
|
|
return null;
|
|
}
|
|
|
|
$val = $field['value'];
|
|
|
|
if (is_array($val)) {
|
|
if (isset($val['value']) && is_array($val['value'])) {
|
|
if (isset($val['value']['value'])) {
|
|
return $val['value']['value'];
|
|
} elseif (isset($val['value']['field_value']['value'])) {
|
|
return $val['value']['field_value']['value'];
|
|
}
|
|
}
|
|
}
|
|
|
|
return is_string($val) ? $val : null;
|
|
}
|
|
|
|
/**
|
|
* Update campaign status (Two-Step Process)
|
|
*
|
|
* Step 1: Update campaign metadata
|
|
* Step 2: Trigger downstream notification
|
|
*
|
|
* @param string $campaignId Campaign asset ID (folder ID)
|
|
* @param string $newStatus New status (A3, A4, etc.)
|
|
* @return array Success status and message
|
|
*/
|
|
public function updateCampaignStatus($campaignId, $newStatus) {
|
|
error_log("DAM Client: Starting campaign CS update - Campaign: " . $campaignId . " to status: " . $newStatus);
|
|
|
|
// Single call to folders_scaling_update which handles both
|
|
// the metadata update and workflow trigger in one operation
|
|
$payload = [
|
|
'folderId' => $campaignId,
|
|
'contentScalingStatus' => $newStatus
|
|
];
|
|
|
|
$url = $this->baseUrl . '/v6/folders_scaling_update';
|
|
|
|
error_log("DAM Client: URL: " . $url);
|
|
error_log("DAM Client: Payload: " . json_encode($payload));
|
|
|
|
$response = $this->makeApiRequest('PATCH', $url, $payload);
|
|
|
|
// Success codes: 200, 201, or 202
|
|
$success = in_array($response['status'], [200, 201, 202]);
|
|
|
|
if (!$success) {
|
|
error_log("DAM Client: CS update FAILED - HTTP " . $response['status']);
|
|
error_log("DAM Client: Error response: " . $response['body']);
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Folders scaling update failed: ' . ($response['data']['error_description'] ?? 'HTTP ' . $response['status'])
|
|
];
|
|
}
|
|
|
|
error_log("DAM Client: CS update SUCCESS - Campaign status updated and workflow triggered");
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => 'Campaign status updated to ' . $newStatus . ' via content scaling'
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Update campaign status ONLY (Single-Step Process - No V6 Folder Scaling)
|
|
*
|
|
* This method only performs Step 1 (metadata update) without triggering
|
|
* the downstream V6 folder scaling notification.
|
|
*
|
|
* @param string $campaignId Campaign asset ID (folder ID)
|
|
* @param string $newStatus New status (A3, A4, etc.)
|
|
* @return array Success status and message
|
|
*/
|
|
public function updateCampaignStatusOnly($campaignId, $newStatus) {
|
|
error_log("DAM Client: Starting STATUS-ONLY campaign update - Campaign: " . $campaignId . " to status: " . $newStatus);
|
|
error_log("DAM Client: NOTE - V6 folder scaling notification will NOT be triggered");
|
|
|
|
// Build metadata update payload matching Python implementation
|
|
$payload = [
|
|
'edited_folder' => [
|
|
'data' => [
|
|
'metadata' => [
|
|
[
|
|
'id' => 'CONTENT.SCALING.STATUS',
|
|
'type' => 'com.artesia.metadata.MetadataField',
|
|
'value' => [
|
|
'cascading_domain_value' => false,
|
|
'domain_value' => true,
|
|
'value' => [
|
|
'type' => 'string',
|
|
'value' => $newStatus
|
|
]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
];
|
|
|
|
// Make request using PATCH to folders endpoint with lock_strategy
|
|
$url = $this->baseUrl . '/v6/folders/' . $campaignId . '?lock_strategy=optimistic';
|
|
|
|
error_log("DAM Client: Status-only URL: " . $url);
|
|
error_log("DAM Client: Status-only Payload: " . json_encode($payload));
|
|
|
|
$response = $this->makeApiRequest('PATCH', $url, $payload);
|
|
|
|
if ($response['status'] !== 200) {
|
|
error_log("DAM Client: STATUS-ONLY UPDATE FAILED - HTTP " . $response['status']);
|
|
error_log("DAM Client: Error response: " . $response['body']);
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Status update failed: ' . ($response['data']['error_description'] ?? 'HTTP ' . $response['status'])
|
|
];
|
|
}
|
|
|
|
error_log("DAM Client: STATUS-ONLY UPDATE SUCCESS - Campaign metadata updated (no V6 scaling notification)");
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => 'Campaign status updated to ' . $newStatus . ' (status only - no V6 scaling notification)'
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Test DAM connection
|
|
*/
|
|
public function testConnection() {
|
|
try {
|
|
$token = $this->getAccessToken();
|
|
return !empty($token);
|
|
} catch (Exception $e) {
|
|
error_log("DAM Client: Connection test failed - " . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
}
|