0) { ob_end_clean(); } header('Content-Type: application/json; charset=utf-8'); echo json_encode($payload); exit; } function h(string $value): string { return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } function friendly_distribution_label(string $distributionId, array $removedPublicFriendlyNameFallbacks = []): string { $id = trim($distributionId); $name = ''; if (isset($removedPublicFriendlyNameFallbacks[$id])) { $name = trim((string)$removedPublicFriendlyNameFallbacks[$id]); } if ($name === '') { return $id; } if (strcasecmp($name, $id) === 0) { return $id; } return $id . ' (' . $name . ')'; } function append_cloudfront_success_note(array &$out): void { $out[] = ''; $out[] = 'What happens next: CloudFront accepted these distribution updates.'; $out[] = 'CloudFront changes are not always visible immediately at every edge location.'; $out[] = 'Wait for the distribution status to finish deploying. In many cases this is minutes, but it can take longer.'; $out[] = 'Already-cached files may still be served until their cache TTL expires or you run an invalidation.'; $out[] = 'New requests for matching paths will use the new behaviors after the distribution deployment reaches the edge. In the AWS Console, verify on the exact distribution ID shown above, on the Behaviors tab.'; } function apply_friendly_distribution_names_to_output(array $lines, array $removedPublicFriendlyNameFallbacks): array { if (!$removedPublicFriendlyNameFallbacks) { return $lines; } foreach ($removedPublicFriendlyNameFallbacks as $id => $name) { $id = trim((string)$id); $name = trim((string)$name); if ($id === '' || $name === '' || strcasecmp($id, $name) === 0) { continue; } $label = $id . ' (' . $name . ')'; foreach ($lines as &$line) { $line = preg_replace('/\b' . preg_quote($id, '/') . '\b(?!\s*\()/', $label, (string)$line); } unset($line); } return $lines; } function lines_to_array(string $text): array { return array_values(array_filter(array_map('trim', preg_split('/\R+/', $text)))); } function require_extensions(): array { $missing = []; foreach (['curl', 'openssl', 'dom', 'simplexml'] as $ext) { if (!extension_loaded($ext)) { $missing[] = $ext; } } return $missing; } function hmac_sha256(string $key, string $data, bool $raw = true): string { return hash_hmac('sha256', $data, $key, $raw); } function signing_key(string $secret, string $date, string $region, string $service): string { $kDate = hmac_sha256('AWS4' . $secret, $date); $kRegion = hmac_sha256($kDate, $region); $kService = hmac_sha256($kRegion, $service); return hmac_sha256($kService, 'aws4_request'); } function aws_percent_encode(string $value): string { return str_replace('%7E', '~', rawurlencode($value)); } function canonical_query(array $query): string { ksort($query); $pairs = []; foreach ($query as $key => $value) { if (is_array($value)) { sort($value); foreach ($value as $v) { $pairs[] = aws_percent_encode((string)$key) . '=' . aws_percent_encode((string)$v); } } else { $pairs[] = aws_percent_encode((string)$key) . '=' . aws_percent_encode((string)$value); } } return implode('&', $pairs); } function is_cloudfront_throttle_response(int $status, string $body): bool { if (in_array($status, [429, 500, 502, 503, 504], true)) { return true; } return stripos($body, 'Throttling') !== false || stripos($body, 'TooManyRequests') !== false || stripos($body, 'Rate exceeded') !== false || stripos($body, 'Throttling') !== false; } function retry_sleep_seconds(int $attempt): int { $base = CF_BASE_RETRY_SLEEP_SECONDS * (2 ** max(0, $attempt - 1)); $jitter = random_int(0, 2); return min(60, $base + $jitter); } function cloudfront_request( string $method, string $path, array $query, string $body, array $extraHeaders, array $creds ): array { $lastError = null; for ($attempt = 1; $attempt <= CF_MAX_RETRIES; $attempt++) { [$access, $secret, $token] = $creds; $amzDate = gmdate('Ymd\THis\Z'); $trace[] = 'aws_signing_amz_date=' . $amzDate; $date = gmdate('Ymd'); $payloadHash = hash('sha256', $body); $headers = [ 'host' => CF_HOST, 'x-amz-content-sha256' => $payloadHash, 'x-amz-date' => $amzDate ]; if ($token) { $headers['x-amz-security-token'] = $token; } foreach ($extraHeaders as $k => $v) { $headers[strtolower($k)] = trim((string)$v); } ksort($headers); $canonicalHeaders = ''; $signedHeaderNames = []; foreach ($headers as $k => $v) { $canonicalHeaders .= strtolower($k) . ':' . preg_replace('/\s+/', ' ', trim((string)$v)) . "\n"; $signedHeaderNames[] = strtolower($k); } $signedHeaders = implode(';', $signedHeaderNames); $canonicalQuery = canonical_query($query); $canonicalRequest = implode("\n", [ strtoupper($method), $path, $canonicalQuery, $canonicalHeaders, $signedHeaders, $payloadHash ]); $credentialScope = "{$date}/" . AWS_REGION . "/" . AWS_SERVICE . "/aws4_request"; $stringToSign = implode("\n", [ 'AWS4-HMAC-SHA256', $amzDate, $credentialScope, hash('sha256', $canonicalRequest) ]); $signature = hash_hmac('sha256', $stringToSign, signing_key($secret, $date, AWS_REGION, AWS_SERVICE)); $authorization = 'AWS4-HMAC-SHA256 Credential=' . $access . '/' . $credentialScope . ', SignedHeaders=' . $signedHeaders . ', Signature=' . $signature; $url = 'https://' . CF_HOST . $path . ($canonicalQuery ? '?' . $canonicalQuery : ''); $curlHeaders = []; foreach ($headers as $k => $v) { $curlHeaders[] = $k . ': ' . $v; } $curlHeaders[] = 'Authorization: ' . $authorization; $ch = curl_init($url); $curlOptionsMain = [ CURLOPT_CUSTOMREQUEST => strtoupper($method), CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => $curlHeaders, CURLOPT_TIMEOUT => 12 ]; $curlTraceUnused = []; apply_cloudfront_tls_options($curlOptionsMain, $curlTraceUnused); curl_setopt_array($ch, $curlOptionsMain); if ($body !== '' || in_array(strtoupper($method), ['POST', 'PUT'], true)) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $raw = curl_exec($ch); if ($raw === false) { $lastError = 'cURL error: ' . curl_error($ch); curl_close($ch); if ($attempt < CF_MAX_RETRIES) { sleep(retry_sleep_seconds($attempt)); continue; } throw new RuntimeException($lastError); } $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $headerSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); $rawHeaders = substr($raw, 0, $headerSize); $responseBody = substr($raw, $headerSize); $responseHeaders = []; foreach (explode("\r\n", trim($rawHeaders)) as $line) { if (strpos($line, ':') !== false) { [$k, $v] = explode(':', $line, 2); $responseHeaders[strtolower(trim($k))] = trim($v); } } if ($status >= 200 && $status < 300) { return [$status, $responseHeaders, $responseBody]; } if (is_cloudfront_throttle_response($status, $responseBody) && $attempt < CF_MAX_RETRIES) { sleep(retry_sleep_seconds($attempt)); continue; } $lastError = "AWS {$method} {$path} failed with HTTP {$status}\n{$responseBody}"; break; } throw new RuntimeException($lastError ?: "AWS {$method} {$path} failed after retries."); } function ns_xpath(SimpleXMLElement $xml): SimpleXMLElement { $xml->registerXPathNamespace('cf', 'http://cloudfront.amazonaws.com/doc/2020-05-31/'); return $xml; } function text_xml(string $value): string { return htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8'); } function cache_policy_xml(string $name, string $comment, int $min, int $default, int $max, bool $compression = true): string { return '' . "\n" . '' . '' . text_xml($name) . '' . '' . text_xml($comment) . '' . '' . $default . '' . '' . $max . '' . '' . $min . '' . '' . '' . ($compression ? 'true' : 'false') . '' . '' . ($compression ? 'true' : 'false') . '' . 'none' . 'none' . 'none' . '' . ''; } function response_headers_policy_xml(): string { return '' . "\n" . '' . '' . MEDIA_CORS_POLICY_NAME . '' . 'Simple public CORS for cached image, audio, and video assets.' . '' . '1*' . '1
*
' . '3GETHEADOPTIONS' . 'false' . '6' . '
Content-Length
' . '
Content-Type
' . '
ETag
' . '
Last-Modified
' . '
Accept-Ranges
' . '
Content-Range
' . '
' . '31536000' . 'true' . '
' . '0' . '0' . '
'; } function find_cache_policy_id(string $name, array $creds): ?string { foreach (['custom', 'managed'] as $type) { [$status, $headers, $body] = cloudfront_request('GET', '/' . CF_VERSION . '/cache-policy', ['Type' => $type], '', [], $creds); $xml = ns_xpath(simplexml_load_string($body)); $items = $xml->xpath('//cf:CachePolicySummary'); foreach ($items ?: [] as $summary) { $summary = ns_xpath($summary); $names = $summary->xpath('.//cf:CachePolicyConfig/cf:Name'); $ids = $summary->xpath('.//cf:Id'); if ($names && $ids && (string)$names[0] === $name) { return (string)$ids[0]; } } } return null; } function find_response_headers_policy_id(string $name, array $creds): ?string { foreach (['custom', 'managed'] as $type) { [$status, $headers, $body] = cloudfront_request('GET', '/' . CF_VERSION . '/response-headers-policy', ['Type' => $type], '', [], $creds); $xml = ns_xpath(simplexml_load_string($body)); $items = $xml->xpath('//cf:ResponseHeadersPolicySummary'); foreach ($items ?: [] as $summary) { $summary = ns_xpath($summary); $names = $summary->xpath('.//cf:ResponseHeadersPolicyConfig/cf:Name'); $ids = $summary->xpath('.//cf:Id'); if ($names && $ids && (string)$names[0] === $name) { return (string)$ids[0]; } } } return null; } function ensure_cache_policy(string $name, string $xml, array $creds, bool $apply, array &$log): string { $existing = find_cache_policy_id($name, $creds); if ($existing) { $log[] = "Cache policy exists: {$name} → {$existing}"; return $existing; } if (!$apply) { $log[] = "DRY RUN: would create cache policy {$name}"; return 'DRY-RUN-' . $name; } [$status, $headers, $body] = cloudfront_request('POST', '/' . CF_VERSION . '/cache-policy', [], $xml, ['content-type' => 'application/xml'], $creds); $doc = ns_xpath(simplexml_load_string($body)); $ids = $doc->xpath('//cf:Id'); if (!$ids) { throw new RuntimeException("Created policy {$name}, but could not parse returned Id."); } $log[] = "Created cache policy: {$name} → {$ids[0]}"; return (string)$ids[0]; } function ensure_response_headers_policy(string $name, string $xml, array $creds, bool $apply, array &$log): string { $existing = find_response_headers_policy_id($name, $creds); if ($existing) { $log[] = "Response headers policy exists: {$name} → {$existing}"; return $existing; } if (!$apply) { $log[] = "DRY RUN: would create response headers policy {$name}"; return 'DRY-RUN-' . $name; } [$status, $headers, $body] = cloudfront_request('POST', '/' . CF_VERSION . '/response-headers-policy', [], $xml, ['content-type' => 'application/xml'], $creds); $doc = ns_xpath(simplexml_load_string($body)); $ids = $doc->xpath('//cf:Id'); if (!$ids) { throw new RuntimeException("Created response headers policy {$name}, but could not parse returned Id."); } $log[] = "Created response headers policy: {$name} → {$ids[0]}"; return (string)$ids[0]; } function dom_xpath(DOMDocument $doc): DOMXPath { $xp = new DOMXPath($doc); $xp->registerNamespace('cf', 'http://cloudfront.amazonaws.com/doc/2020-05-31/'); return $xp; } function set_child_text(DOMDocument $doc, DOMElement $parent, string $localName, string $value): void { $xp = dom_xpath($doc); $nodes = $xp->query('./cf:' . $localName, $parent); if ($nodes && $nodes->length) { $nodes->item(0)->nodeValue = $value; return; } $el = $doc->createElementNS('http://cloudfront.amazonaws.com/doc/2020-05-31/', $localName, $value); $parent->appendChild($el); } function ensure_empty_associations(DOMDocument $doc, DOMElement $behavior, string $name): void { $ns = 'http://cloudfront.amazonaws.com/doc/2020-05-31/'; $xp = dom_xpath($doc); $nodes = $xp->query('./cf:' . $name, $behavior); if ($nodes && $nodes->length) { $node = $nodes->item(0); while ($node->firstChild) { $node->removeChild($node->firstChild); } } else { $node = $doc->createElementNS($ns, $name); $behavior->appendChild($node); } $node->appendChild($doc->createElementNS($ns, 'Quantity', '0')); } function first_child_text(DOMDocument $doc, DOMElement $parent, string $localName, ?string $fallback = null): ?string { $xp = dom_xpath($doc); $nodes = $xp->query('./cf:' . $localName, $parent); if ($nodes && $nodes->length) { return $nodes->item(0)->textContent; } return $fallback; } function clone_child_if_exists(DOMDocument $doc, DOMElement $from, DOMElement $to, string $localName): void { $xp = dom_xpath($doc); $nodes = $xp->query('./cf:' . $localName, $from); if ($nodes && $nodes->length) { $to->appendChild($doc->importNode($nodes->item(0), true)); } } function restore_edge_associations_from_default(DOMDocument $doc, DOMElement $default, DOMElement $behavior): void { $xp = dom_xpath($doc); foreach (['LambdaFunctionAssociations', 'FunctionAssociations'] as $name) { $existing = $xp->query('./cf:' . $name, $behavior); if ($existing) { foreach (iterator_to_array($existing) as $node) { if ($node instanceof DOMNode) { $behavior->removeChild($node); } } } $source = $xp->query('./cf:' . $name, $default)->item(0); if ($source instanceof DOMElement) { $behavior->appendChild($doc->importNode($source, true)); } else { ensure_empty_associations($doc, $behavior, $name); } } } function append_get_head_options_allowed_methods(DOMDocument $doc, DOMElement $to): void { $ns = 'http://cloudfront.amazonaws.com/doc/2020-05-31/'; $allowed = $doc->createElementNS($ns, 'AllowedMethods'); $allowed->appendChild($doc->createElementNS($ns, 'Quantity', '3')); $items = $doc->createElementNS($ns, 'Items'); $items->appendChild($doc->createElementNS($ns, 'Method', 'GET')); $items->appendChild($doc->createElementNS($ns, 'Method', 'HEAD')); $items->appendChild($doc->createElementNS($ns, 'Method', 'OPTIONS')); $allowed->appendChild($items); $cached = $doc->createElementNS($ns, 'CachedMethods'); $cached->appendChild($doc->createElementNS($ns, 'Quantity', '2')); $cachedItems = $doc->createElementNS($ns, 'Items'); $cachedItems->appendChild($doc->createElementNS($ns, 'Method', 'GET')); $cachedItems->appendChild($doc->createElementNS($ns, 'Method', 'HEAD')); $cached->appendChild($cachedItems); $allowed->appendChild($cached); $to->appendChild($allowed); } function ensure_allowed_methods(DOMDocument $doc, DOMElement $behavior): void { $xp = dom_xpath($doc); $nodes = $xp->query('./cf:AllowedMethods', $behavior); if ($nodes && $nodes->length) { return; } append_get_head_options_allowed_methods($doc, $behavior); } function clone_default_as_behavior(DOMDocument $doc, DOMElement $default, string $pathPattern): DOMElement { $ns = 'http://cloudfront.amazonaws.com/doc/2020-05-31/'; $behavior = $doc->createElementNS($ns, 'CacheBehavior'); $targetOriginId = first_child_text($doc, $default, 'TargetOriginId'); $viewerProtocolPolicy = first_child_text($doc, $default, 'ViewerProtocolPolicy', 'redirect-to-https'); if (!$targetOriginId) { throw new RuntimeException('DefaultCacheBehavior is missing TargetOriginId; refusing to build derived behavior.'); } if (!$viewerProtocolPolicy) { throw new RuntimeException('DefaultCacheBehavior is missing ViewerProtocolPolicy; refusing to build derived behavior.'); } $behavior->appendChild($doc->createElementNS($ns, 'PathPattern', $pathPattern)); $behavior->appendChild($doc->createElementNS($ns, 'TargetOriginId', $targetOriginId)); clone_child_if_exists($doc, $default, $behavior, 'TrustedSigners'); clone_child_if_exists($doc, $default, $behavior, 'TrustedKeyGroups'); $behavior->appendChild($doc->createElementNS($ns, 'ViewerProtocolPolicy', $viewerProtocolPolicy)); append_get_head_options_allowed_methods($doc, $behavior); $smoothStreaming = first_child_text($doc, $default, 'SmoothStreaming', 'false'); $behavior->appendChild($doc->createElementNS($ns, 'SmoothStreaming', $smoothStreaming ?: 'false')); $behavior->appendChild($doc->createElementNS($ns, 'Compress', 'true')); clone_child_if_exists($doc, $default, $behavior, 'LambdaFunctionAssociations'); clone_child_if_exists($doc, $default, $behavior, 'FunctionAssociations'); // CloudFront requires these association containers to be present. if (!dom_xpath($doc)->query('./cf:LambdaFunctionAssociations', $behavior)->length) { ensure_empty_associations($doc, $behavior, 'LambdaFunctionAssociations'); } if (!dom_xpath($doc)->query('./cf:FunctionAssociations', $behavior)->length) { ensure_empty_associations($doc, $behavior, 'FunctionAssociations'); } $fieldLevelEncryptionId = first_child_text($doc, $default, 'FieldLevelEncryptionId', ''); $behavior->appendChild($doc->createElementNS($ns, 'FieldLevelEncryptionId', $fieldLevelEncryptionId ?? '')); clone_child_if_exists($doc, $default, $behavior, 'RealtimeLogConfigArn'); clone_child_if_exists($doc, $default, $behavior, 'OriginRequestPolicyId'); clone_child_if_exists($doc, $default, $behavior, 'ResponseHeadersPolicyId'); return $behavior; } function configure_behavior(DOMDocument $doc, DOMElement $behavior, string $cachePolicyId, ?string $responseHeadersPolicyId, bool $keepEdgeCode): void { set_child_text($doc, $behavior, 'CachePolicyId', $cachePolicyId); if ($responseHeadersPolicyId) { set_child_text($doc, $behavior, 'ResponseHeadersPolicyId', $responseHeadersPolicyId); } if (!$keepEdgeCode) { ensure_empty_associations($doc, $behavior, 'LambdaFunctionAssociations'); ensure_empty_associations($doc, $behavior, 'FunctionAssociations'); } set_child_text($doc, $behavior, 'Compress', 'true'); ensure_allowed_methods($doc, $behavior); } function behavior_priority(string $pattern, array $mediaPatterns): int { if ($pattern === '*debug*') return 0; if ($pattern === '*.json') return 1; if (in_array($pattern, $mediaPatterns, true)) return 2; return 9; } function rebuild_cache_behaviors(DOMDocument $doc, array $behaviors, array $mediaPatterns): void { $xp = dom_xpath($doc); $ns = 'http://cloudfront.amazonaws.com/doc/2020-05-31/'; $config = $xp->query('/cf:DistributionConfig')->item(0); $old = $xp->query('/cf:DistributionConfig/cf:CacheBehaviors')->item(0); if ($old) { $config->removeChild($old); } usort($behaviors, function (DOMElement $a, DOMElement $b) use ($doc, $mediaPatterns): int { $xp = dom_xpath($doc); $pa = $xp->query('./cf:PathPattern', $a)->item(0)->nodeValue; $pb = $xp->query('./cf:PathPattern', $b)->item(0)->nodeValue; $priorityA = behavior_priority($pa, $mediaPatterns); $priorityB = behavior_priority($pb, $mediaPatterns); return $priorityA === $priorityB ? strcmp($pa, $pb) : ($priorityA <=> $priorityB); }); $cacheBehaviors = $doc->createElementNS($ns, 'CacheBehaviors'); $cacheBehaviors->appendChild($doc->createElementNS($ns, 'Quantity', (string)count($behaviors))); if ($behaviors) { $items = $doc->createElementNS($ns, 'Items'); foreach ($behaviors as $behavior) { $items->appendChild($behavior); } $cacheBehaviors->appendChild($items); } $default = $xp->query('/cf:DistributionConfig/cf:DefaultCacheBehavior')->item(0); if ($default && $default->nextSibling) { $config->insertBefore($cacheBehaviors, $default->nextSibling); } else { $config->appendChild($cacheBehaviors); } } function element_to_stable_xml(DOMDocument $doc, DOMElement $element): string { $clone = new DOMDocument('1.0', 'UTF-8'); $clone->preserveWhiteSpace = false; $clone->formatOutput = false; $clone->appendChild($clone->importNode($element, true)); return trim($clone->saveXML($clone->documentElement)); } function restore_default_cache_behavior_from_snapshot(DOMDocument $doc, string $snapshotXml): void { $xp = dom_xpath($doc); $current = $xp->query('/cf:DistributionConfig/cf:DefaultCacheBehavior')->item(0); if (!$current instanceof DOMElement) { throw new RuntimeException('SAFETY STOP: DefaultCacheBehavior disappeared while generating candidate XML. Refusing to update CloudFront.'); } $snapshotDoc = new DOMDocument('1.0', 'UTF-8'); $snapshotDoc->preserveWhiteSpace = false; if (!$snapshotDoc->loadXML($snapshotXml) || !$snapshotDoc->documentElement) { throw new RuntimeException('SAFETY STOP: Could not reload DefaultCacheBehavior safety snapshot. Refusing to update CloudFront.'); } $imported = $doc->importNode($snapshotDoc->documentElement, true); $current->parentNode->replaceChild($imported, $current); } function assert_default_cache_behavior_unchanged(DOMDocument $doc, string $snapshotXml): void { $xp = dom_xpath($doc); $current = $xp->query('/cf:DistributionConfig/cf:DefaultCacheBehavior')->item(0); if (!$current instanceof DOMElement) { throw new RuntimeException('SAFETY STOP: DefaultCacheBehavior missing after candidate XML generation. Refusing to update CloudFront.'); } $currentXml = element_to_stable_xml($doc, $current); if ($currentXml !== trim($snapshotXml)) { throw new RuntimeException('SAFETY STOP: DefaultCacheBehavior changed during candidate XML generation. Refusing to update CloudFront.'); } } function summarize_default_edge_state(DOMDocument $doc, DOMElement $default): string { $xp = dom_xpath($doc); $rhs = first_child_text($doc, $default, 'ResponseHeadersPolicyId', 'none'); $lambdaQty = first_child_text($doc, $xp->query('./cf:LambdaFunctionAssociations', $default)->item(0) ?: $default, 'Quantity', '0'); $funcQty = first_child_text($doc, $xp->query('./cf:FunctionAssociations', $default)->item(0) ?: $default, 'Quantity', '0'); return 'Default lock captured: ResponseHeadersPolicyId=' . ($rhs ?: 'none') . ', LambdaFunctionAssociations=' . ($lambdaQty ?: '0') . ', FunctionAssociations=' . ($funcQty ?: '0'); } function normalize_required_association_blocks(DOMDocument $doc): void { $xp = dom_xpath($doc); // v159 safety rule: NEVER mutate DefaultCacheBehavior. The directory browser, // inline-text function, and default response headers live there and must survive // every run byte-for-byte. Only ordered CacheBehavior items are normalized. $items = $xp->query('/cf:DistributionConfig/cf:CacheBehaviors/cf:Items/cf:CacheBehavior'); if ($items) { foreach ($items as $behavior) { if ($behavior instanceof DOMElement) { ensure_empty_associations_if_missing($doc, $behavior, 'LambdaFunctionAssociations'); ensure_empty_associations_if_missing($doc, $behavior, 'FunctionAssociations'); } } } } function ensure_empty_associations_if_missing(DOMDocument $doc, DOMElement $behavior, string $name): void { $xp = dom_xpath($doc); $nodes = $xp->query('./cf:' . $name, $behavior); if ($nodes && $nodes->length) { return; } ensure_empty_associations($doc, $behavior, $name); } function validate_required_behavior_fields(DOMDocument $doc): array { $xp = dom_xpath($doc); $errors = []; $default = $xp->query('/cf:DistributionConfig/cf:DefaultCacheBehavior')->item(0); if ($default instanceof DOMElement) { foreach (['TargetOriginId', 'ViewerProtocolPolicy', 'LambdaFunctionAssociations', 'FunctionAssociations'] as $field) { $value = first_child_text($doc, $default, $field); if ($value === null || trim($value) === '') { $errors[] = "DefaultCacheBehavior missing {$field}"; } } } else { $errors[] = 'DistributionConfig missing DefaultCacheBehavior'; } $items = $xp->query('/cf:DistributionConfig/cf:CacheBehaviors/cf:Items/cf:CacheBehavior'); if ($items) { foreach ($items as $idx => $behavior) { if ($behavior instanceof DOMElement) { foreach (['PathPattern', 'TargetOriginId', 'ViewerProtocolPolicy', 'AllowedMethods', 'LambdaFunctionAssociations', 'FunctionAssociations'] as $field) { $value = first_child_text($doc, $behavior, $field); if ($value === null || trim($value) === '') { $errors[] = "CacheBehavior item {$idx} missing {$field}"; } } } } } return $errors; } function validate_association_quantities(DOMDocument $doc): array { $xp = dom_xpath($doc); $errors = []; $nodes = $xp->query('//cf:DefaultCacheBehavior | //cf:CacheBehavior'); if ($nodes) { foreach ($nodes as $idx => $behavior) { if ($behavior instanceof DOMElement) { foreach (['LambdaFunctionAssociations', 'FunctionAssociations'] as $name) { $assoc = $xp->query('./cf:' . $name, $behavior)->item(0); if (!$assoc instanceof DOMElement) { $errors[] = "Behavior {$idx} missing {$name}"; continue; } $qty = $xp->query('./cf:Quantity', $assoc)->item(0); if (!$qty || trim($qty->textContent) === '') { $errors[] = "Behavior {$idx} {$name} missing Quantity"; } } } } } return $errors; } function config_behavior_patterns_from_xml(string $xml): array { $patterns = []; $previous = libxml_use_internal_errors(true); try { $doc = new DOMDocument(); if ($doc->loadXML($xml)) { $xpath = new DOMXPath($doc); foreach ($xpath->query('//*[local-name()="CacheBehaviors"]/*[local-name()="Items"]/*[local-name()="CacheBehavior"]/*[local-name()="PathPattern"]') as $node) { $patterns[] = trim($node->textContent); } foreach ($xpath->query('//*[local-name()="DefaultCacheBehavior"]') as $node) { if (!in_array('Default (*)', $patterns, true)) { array_unshift($patterns, 'Default (*)'); } break; } } } catch (Throwable $e) { // Caller logs verification failure. } libxml_clear_errors(); libxml_use_internal_errors($previous); return array_values(array_unique(array_filter($patterns, static fn($v) => $v !== ''))); } // PUBLIC MANUAL FALLBACKS DISABLED in v110: only loaded grid checkbox selections are public. function append_known_target_warning(array &$log, string $id, string $label): void { $needle = strtolower($label . ' ' . $id); if (strpos($needle, 'media.define.com') !== false && $id !== 'E38Z4BOCM2WWK2') { $log[] = "TARGET WARNING: media.define.com is expected to be distribution E38Z4BOCM2WWK2 from the AWS console screenshot, but this run is targeting " . distribution_display_name($id, $label) . "."; } } function append_post_update_distribution_verification(array &$log, string $id, string $label, array $creds): void { $display = distribution_display_name($id, $label); try { [$status, $body] = cloudfront_request( 'GET', '/' . CF_VERSION . '/distribution/' . rawurlencode($id) . '/config', [], null, $creds ); if ($status < 200 || $status >= 300) { $log[] = "VERIFY PENDING for {$display}: CloudFront accepted the update, but the immediate verification read returned HTTP {$status}. This does not mean the apply failed."; $log[] = "VERIFY NOTE for {$display}: verification is advisory. Check the exact distribution ID in the AWS Console Behaviors tab after CloudFront finishes processing the update."; return; } $patterns = config_behavior_patterns_from_xml($body); $behaviorCount = max(0, count($patterns) - (in_array('Default (*)', $patterns, true) ? 1 : 0)); if ($patterns) { $log[] = "VERIFY READBACK for {$display}: CloudFront returned {$behaviorCount} ordered cache behavior(s) plus the default behavior."; $log[] = "VERIFY behavior patterns for {$display}: " . implode(', ', $patterns); } else { $log[] = "VERIFY PENDING for {$display}: CloudFront accepted the update, but the immediate verification read did not return readable behavior patterns."; $log[] = "VERIFY NOTE for {$display}: verification is advisory while CloudFront is still processing the distribution update."; } } catch (Throwable $e) { $log[] = "VERIFY PENDING for {$display}: CloudFront accepted the update, but immediate verification could not complete. " . $e->getMessage(); $log[] = "VERIFY NOTE for {$display}: verification is advisory. Check the exact distribution ID in the AWS Console Behaviors tab after the update finishes processing."; } } function update_distribution(string $id, string $label, array $config, array $policyIds, array $creds, bool $apply, array &$log, ?callable $progress = null): void { $log[] = "=== Distribution " . distribution_display_name($id, $label) . " ==="; $emit = function (string $message, string $kind = 'wait') use ($progress): void { if ($progress) { $progress($message, $kind); } }; $emit('Reading CloudFront config for ' . distribution_display_name($id, $label) . '.', 'wait'); append_known_target_warning($log, $id, $label); $addedCount = 0; $updatedCount = 0; [$status, $headers, $body] = cloudfront_request('GET', '/' . CF_VERSION . '/distribution/' . rawurlencode($id) . '/config', [], '', [], $creds); $etag = $headers['etag'] ?? null; if (!$etag) { throw new RuntimeException("No ETag returned for distribution {$id}."); } $doc = new DOMDocument('1.0', 'UTF-8'); $doc->preserveWhiteSpace = false; $doc->formatOutput = true; $doc->loadXML($body); $xp = dom_xpath($doc); $default = $xp->query('/cf:DistributionConfig/cf:DefaultCacheBehavior')->item(0); if (!$default instanceof DOMElement) { throw new RuntimeException("Could not find DefaultCacheBehavior in distribution {$id}."); } // v159 hard safety lock: capture DefaultCacheBehavior before any generated changes. // The app may update ordered media CacheBehaviors only. It must never change // the Default behavior's response headers policy, CloudFront Function, or Lambda@Edge. $defaultSnapshotXml = element_to_stable_xml($doc, $default); $log[] = summarize_default_edge_state($doc, $default); $log[] = "SAFETY LOCK: Default (*) will be restored from snapshot and verified unchanged before upload."; $emit('Default behavior safety snapshot captured for ' . distribution_display_name($id, $label) . '.', 'good'); $existingNodes = $xp->query('/cf:DistributionConfig/cf:CacheBehaviors/cf:Items/cf:CacheBehavior'); $behaviors = []; if ($existingNodes) { foreach ($existingNodes as $node) { if ($node instanceof DOMElement) { $behaviors[] = $node->cloneNode(true); } } } $upsert = function (string $pattern, string $cachePolicyId, ?string $responsePolicyId, bool $keepEdgeCode, bool $restoreDefaultEdgeCode = false) use ($doc, $default, &$behaviors, &$log, &$addedCount, &$updatedCount): void { $foundIndex = null; $xp = dom_xpath($doc); foreach ($behaviors as $idx => $behavior) { $pathNode = $xp->query('./cf:PathPattern', $behavior)->item(0); if ($pathNode && $pathNode->nodeValue === $pattern) { $foundIndex = $idx; break; } } if ($foundIndex === null) { $behavior = clone_default_as_behavior($doc, $default, $pattern); if ($restoreDefaultEdgeCode) { restore_edge_associations_from_default($doc, $default, $behavior); } configure_behavior($doc, $behavior, $cachePolicyId, $responsePolicyId, $keepEdgeCode); $behaviors[] = $behavior; $addedCount++; $log[] = "added {$pattern}"; if ($restoreDefaultEdgeCode) { $log[] = "preserved default edge code for {$pattern}"; } } else { if ($restoreDefaultEdgeCode) { restore_edge_associations_from_default($doc, $default, $behaviors[$foundIndex]); } configure_behavior($doc, $behaviors[$foundIndex], $cachePolicyId, $responsePolicyId, $keepEdgeCode); $updatedCount++; $log[] = "updated {$pattern}"; if ($restoreDefaultEdgeCode) { $log[] = "restored default edge code for {$pattern}"; } } }; if (!empty($config['addDebugBehavior'])) { $upsert('*debug*', $policyIds['debug'], null, true, true); } if (!empty($config['addJsonBehavior'])) { $log[] = "skipped *.json: JSON directory-index behavior is intentionally left untouched"; } if (!empty($config['addMediaBehaviors'])) { foreach ($config['mediaPatterns'] as $pattern) { // Media behaviors need the public CORS response headers policy and long cache. // They intentionally do NOT inherit the Default behavior edge code: // - no directory-browser Lambda@Edge for images/video/audio/fonts // - no force-UTF8 / inline-text CloudFront Function for binary media $upsert($pattern, $policyIds['media'], $policyIds['cors'], false, false); $log[] = "media CORS-only behavior for {$pattern}: no Default edge Lambda or CloudFront Function copied"; } } rebuild_cache_behaviors($doc, $behaviors, $config['mediaPatterns']); normalize_required_association_blocks($doc); // Restore and verify Default behavior after all generated behavior changes. restore_default_cache_behavior_from_snapshot($doc, $defaultSnapshotXml); assert_default_cache_behavior_unchanged($doc, $defaultSnapshotXml); $log[] = "SAFETY LOCK PASSED: Default (*) is byte-for-byte unchanged in the candidate XML."; $emit('SAFETY LOCK PASSED for ' . distribution_display_name($id, $label) . ': Default (*) unchanged.', 'good'); $doc->insertBefore($doc->createComment(' Generated by ' . TOOL_PACKAGE_NAME . ' ' . TOOL_VERSION . ' built ' . TOOL_BUILD_TIMESTAMP_UTC . ' at ' . gmdate('c') . ' '), $doc->documentElement); $candidateXml = $doc->saveXML(); $log[] = "summary for " . distribution_display_name($id, $label) . ": added {$addedCount}, updated {$updatedCount}"; $log[] = "candidate XML kept in memory for " . distribution_display_name($id, $label) . "; use Copy results or Download results."; $validationErrors = array_merge(validate_required_behavior_fields($doc), validate_association_quantities($doc)); if ($validationErrors) { throw new RuntimeException("Generated CloudFront XML failed local validation before upload:\n" . implode("\n", $validationErrors)); } if (!$apply) { $log[] = "DRY RUN ONLY for " . distribution_display_name($id, $label) . ": no update sent."; $emit('Dry run complete for ' . distribution_display_name($id, $label) . ': no update sent.', 'good'); $log[] = "----- BEGIN CANDIDATE XML " . distribution_display_name($id, $label) . " -----"; $log[] = $candidateXml; $log[] = "----- END CANDIDATE XML " . distribution_display_name($id, $label) . " -----"; return; } [$putStatus] = cloudfront_request( 'PUT', '/' . CF_VERSION . '/distribution/' . rawurlencode($id) . '/config', [], $candidateXml, [ 'content-type' => 'application/xml', 'if-match' => $etag ], $creds ); $log[] = "updated distribution " . distribution_display_name($id, $label) . "; HTTP {$putStatus}"; $emit('CloudFront accepted update for ' . distribution_display_name($id, $label) . '; HTTP ' . $putStatus . '.', 'good'); if ($putStatus >= 200 && $putStatus < 300) { $log[] = "UPDATE ACCEPTED for " . distribution_display_name($id, $label) . ": CloudFront accepted the new distribution config. Console visibility can lag while the distribution update deploys."; } append_post_update_distribution_verification($log, $id, $label, $creds); } function parse_distribution_pairs(string $idsText, string $labelsText): array { $ids = lines_to_array($idsText); $labels = lines_to_array($labelsText); $out = []; foreach ($ids as $i => $line) { // Backward compatibility: accept DISTRIBUTION_ID|friendly name in the ID box. $parts = array_map('trim', explode('|', $line, 2)); $id = $parts[0] ?? ''; $inlineLabel = $parts[1] ?? ''; $label = $inlineLabel !== '' ? $inlineLabel : ($labels[$i] ?? ''); if ($id !== '') { $out[] = [ 'id' => $id, 'label' => $label !== '' ? $label : $id ]; } } return $out; } function parse_distribution_lines(string $text): array { $out = []; foreach (lines_to_array($text) as $line) { $parts = array_map('trim', explode('|', $line, 2)); $id = $parts[0] ?? ''; $label = $parts[1] ?? ''; if ($id !== '') { $out[] = [ 'id' => $id, 'label' => $label !== '' ? $label : $id ]; } } return $out; } function distribution_display_name(string $id, string $label): string { return $label && $label !== $id ? "{$id} ({$label})" : $id; } function distribution_friendly_name(array $dist): string { $aliases = $dist['aliases'] ?? []; $comment = trim((string)($dist['comment'] ?? '')); $domain = trim((string)($dist['domain'] ?? '')); $id = trim((string)($dist['id'] ?? '')); if ($aliases) { return $aliases[0]; } if ($comment !== '') { return $comment; } if ($domain !== '') { return $domain; } return $id; } function parse_distribution_list_xml(string $body): array { $xml = ns_xpath(simplexml_load_string($body)); $items = $xml->xpath('//cf:DistributionSummary'); $out = []; foreach ($items ?: [] as $item) { $item = ns_xpath($item); $idNode = $item->xpath('./cf:Id'); $domainNode = $item->xpath('./cf:DomainName'); $commentNode = $item->xpath('./cf:Comment'); $statusNode = $item->xpath('./cf:Status'); $enabledNode = $item->xpath('./cf:Enabled'); $aliases = []; $aliasNodes = $item->xpath('./cf:Aliases/cf:Items/cf:CNAME'); foreach ($aliasNodes ?: [] as $alias) { $aliases[] = (string)$alias; } if ($idNode) { $dist = [ 'id' => (string)$idNode[0], 'domain' => $domainNode ? (string)$domainNode[0] : '', 'comment' => $commentNode ? (string)$commentNode[0] : '', 'status' => $statusNode ? (string)$statusNode[0] : '', 'enabled' => $enabledNode ? (string)$enabledNode[0] : '', 'aliases' => $aliases ]; $dist['friendly'] = distribution_friendly_name($dist); $out[] = $dist; } } usort($out, function ($a, $b) { return strcasecmp($a['friendly'], $b['friendly']); }); return $out; } function discover_distributions(array $creds): array { $all = []; $marker = ''; do { $query = $marker !== '' ? ['Marker' => $marker] : []; [$status, $headers, $body] = cloudfront_request('GET', '/' . CF_VERSION . '/distribution', $query, '', [], $creds); $batch = parse_distribution_list_xml($body); $all = array_merge($all, $batch); $xml = ns_xpath(simplexml_load_string($body)); $isTruncated = $xml->xpath('//cf:IsTruncated'); $nextMarker = $xml->xpath('//cf:NextMarker'); $marker = ($isTruncated && (string)$isTruncated[0] === 'true' && $nextMarker) ? (string)$nextMarker[0] : ''; } while ($marker !== ''); return $all; } function encode_selected_distribution(array $dist): string { return base64_encode(json_encode([ 'id' => $dist['id'], 'label' => $dist['friendly'] ], JSON_UNESCAPED_SLASHES)); } function decode_selected_distributions(array $encoded): array { $out = []; foreach ($encoded as $value) { $json = base64_decode((string)$value, true); if ($json === false) { continue; } $row = json_decode($json, true); if (is_array($row) && !empty($row['id'])) { $out[] = [ 'id' => (string)$row['id'], 'label' => !empty($row['label']) ? (string)$row['label'] : (string)$row['id'] ]; } } return $out; } function load_local_config_file(string $path): array { $path = trim($path); if ($path === '') { $path = LOCAL_CONFIG_DEFAULT_PATH; } if (!is_file($path)) { throw new RuntimeException('LOCAL_CONFIG_NOT_FOUND'); } $config = require $path; if (!is_array($config)) { throw new RuntimeException('LOCAL_CONFIG_NOT_ARRAY'); } return $config; } function local_config_value(array $config, string $key, string $default = ''): string { $value = $config[$key] ?? $default; return is_scalar($value) ? (string)$value : $default; } function looks_like_placeholder_key(string $value): bool { $value = trim($value); if ($value === '') { return true; } return stripos($value, 'PASTE_') !== false || stripos($value, 'YOUR_') !== false || stripos($value, 'HERE') !== false || stripos($value, 'EXAMPLE') !== false; } function masked_key_hint(string $value): string { $value = trim($value); if ($value === '') { return ''; } if (strlen($value) <= 8) { return 'configured'; } return substr($value, 0, 4) . '…' . substr($value, -4); } function local_config_session_token(array $config): string { $token = local_config_value($config, 'awsSessionToken'); if ($token === '') { $token = local_config_value($config, 'sessionToken'); } if ($token === '') { $token = local_config_value($config, 'aws_session_token'); } return $token; } function credentials_from_local_config_file_if_available(string $path): array { $path = trim($path); if ($path === '') { $path = LOCAL_CONFIG_DEFAULT_PATH; } if (!is_file($path)) { return [ 'found' => false, 'path' => $path, 'config' => [], 'message' => 'Local config file not found. Manual AWS key entry is available.' ]; } try { $config = load_local_config_file($path); } catch (Throwable $e) { $reason = $e->getMessage() === 'LOCAL_CONFIG_NOT_ARRAY' ? 'Local config file found, but it did not return a PHP array.' : 'Local config file found, but it could not be read.'; return [ 'found' => true, 'path' => $path, 'config' => [], 'message' => $reason, 'error' => true ]; } $access = local_config_value($config, 'awsAccessKeyId'); $secret = local_config_value($config, 'awsSecretAccessKey'); $token = local_config_session_token($config); $issues = []; if ($access === '') { $issues[] = 'awsAccessKeyId is missing'; } elseif (looks_like_placeholder_key($access)) { $issues[] = 'awsAccessKeyId is still a placeholder'; } if ($secret === '') { $issues[] = 'awsSecretAccessKey is missing'; } elseif (looks_like_placeholder_key($secret)) { $issues[] = 'awsSecretAccessKey is still a placeholder'; } if ($issues) { return [ 'found' => true, 'path' => $path, 'config' => $config, 'message' => 'Local config file found, but ' . implode(' and ', $issues) . '.', 'configured' => false ]; } return [ 'found' => true, 'path' => $path, 'config' => $config, 'access' => $access, 'secret' => $secret, 'token' => $token, 'configured' => true, 'message' => 'Local config file found. Credentials loaded.' ]; } function aws_sigv4_headers(string $method, string $host, string $path, array $query, string $body, string $accessKey, string $secretKey, ?string $sessionToken = null): array { ksort($query); $canonicalQuery = http_build_query($query, '', '&', PHP_QUERY_RFC3986); $amzDate = gmdate('Ymd\THis\Z'); $dateStamp = gmdate('Ymd'); $trace[] = 'aws_signing_date_stamp=' . $dateStamp; $payloadHash = hash('sha256', $body); $canonicalHeaders = 'host:' . $host . "\n" . 'x-amz-content-sha256:' . $payloadHash . "\n" . 'x-amz-date:' . $amzDate . "\n"; $signedHeaders = 'host;x-amz-content-sha256;x-amz-date'; if ($sessionToken !== null && $sessionToken !== '') { $canonicalHeaders .= 'x-amz-security-token:' . trim($sessionToken) . "\n"; $signedHeaders .= ';x-amz-security-token'; } $canonicalRequest = implode("\n", [ strtoupper($method), $path, $canonicalQuery, $canonicalHeaders, $signedHeaders, $payloadHash ]); $credentialScope = $dateStamp . '/' . AWS_REGION . '/' . AWS_SERVICE . '/aws4_request'; $stringToSign = implode("\n", [ 'AWS4-HMAC-SHA256', $amzDate, $credentialScope, hash('sha256', $canonicalRequest) ]); $kDate = hash_hmac('sha256', $dateStamp, 'AWS4' . $secretKey, true); $kRegion = hash_hmac('sha256', AWS_REGION, $kDate, true); $kService = hash_hmac('sha256', AWS_SERVICE, $kRegion, true); $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true); $signature = hash_hmac('sha256', $stringToSign, $kSigning); $headers = [ 'Host' => $host, 'X-Amz-Date' => $amzDate, 'X-Amz-Content-Sha256' => $payloadHash, 'Authorization' => 'AWS4-HMAC-SHA256 Credential=' . $accessKey . '/' . $credentialScope . ', SignedHeaders=' . $signedHeaders . ', Signature=' . $signature, ]; if ($sessionToken !== null && $sessionToken !== '') { $headers['X-Amz-Security-Token'] = trim($sessionToken); } return $headers; } function apply_cloudfront_tls_options(array &$curlOptions, array &$trace = []): void { $curlOptions[CURLOPT_SSL_VERIFYPEER] = true; $curlOptions[CURLOPT_SSL_VERIFYHOST] = 2; $curlCaInfo = ini_get('curl.cainfo'); if (is_string($curlCaInfo) && trim($curlCaInfo) !== '') { $trace[] = 'tls_ca=php curl.cainfo configured'; return; } $opensslCaFile = ini_get('openssl.cafile'); if (is_string($opensslCaFile) && trim($opensslCaFile) !== '') { $trace[] = 'tls_ca=php openssl.cafile configured'; return; } if (defined('CURLOPT_SSL_OPTIONS') && defined('CURLSSLOPT_NATIVE_CA')) { $curlOptions[CURLOPT_SSL_OPTIONS] = CURLSSLOPT_NATIVE_CA; $trace[] = 'tls_ca=windows native CA requested'; return; } $trace[] = 'tls_ca=no native CA support detected'; } function explain_curl_ssl_error(int $errno, string $error): string { if ($errno === 60 || stripos($error, 'issuer certificate') !== false || stripos($error, 'certificate') !== false) { return 'Local PHP/cURL SSL trust is broken. Run this tool from a normal HTTPS host, or install/use a Windows PHP/cURL build that supports the Windows certificate store.'; } return 'CloudFront network/TLS test failed: ' . ($error ?: ('cURL errno ' . $errno)); } function aws_error_details_from_body(string $responseBody): array { $body = trim($responseBody); if ($body === '') { return ['', '', '']; } $code = ''; $message = ''; $requestId = ''; $previous = libxml_use_internal_errors(true); try { $xml = simplexml_load_string($body); if ($xml !== false) { $code = trim((string)($xml->Code ?? $xml->Error->Code ?? '')); $message = trim((string)($xml->Message ?? $xml->Error->Message ?? '')); $requestId = trim((string)($xml->RequestId ?? $xml->RequestID ?? '')); } } catch (Throwable $e) { // Keep fallback parsing below. } libxml_clear_errors(); libxml_use_internal_errors($previous); if ($code === '' && preg_match('/\s*([^<]+)\s*<\/Code>/i', $body, $m)) { $code = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_XML1, 'UTF-8')); } if ($message === '' && preg_match('/\s*([^<]+)\s*<\/Message>/i', $body, $m)) { $message = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_XML1, 'UTF-8')); } if ($requestId === '' && preg_match('/\s*([^<]+)\s*<\/RequestId>/i', $body, $m)) { $requestId = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_XML1, 'UTF-8')); } return [$code, $message, $requestId]; } function one_line_snippet(string $value, int $limit = 500): string { $value = trim(preg_replace('/\s+/', ' ', strip_tags($value))); if (strlen($value) > $limit) { return substr($value, 0, $limit) . '...'; } return $value; } function safe_credential_fingerprint(string $value, bool $isSecret = false): array { $raw = $value; $trimmed = trim($value); $len = strlen($raw); $trimmedLen = strlen($trimmed); $changedByTrim = ($raw !== $trimmed); if ($isSecret) { return [ 'length' => $len, 'trimmed_length' => $trimmedLen, 'trim_changed' => $changedByTrim ? 'yes' : 'no', 'sha256_8' => $trimmed === '' ? '' : substr(hash('sha256', $trimmed), 0, 8), ]; } $prefix = $trimmed === '' ? '' : substr($trimmed, 0, min(4, strlen($trimmed))); $suffix = strlen($trimmed) <= 4 ? '' : substr($trimmed, -4); return [ 'length' => $len, 'trimmed_length' => $trimmedLen, 'trim_changed' => $changedByTrim ? 'yes' : 'no', 'prefix' => $prefix, 'suffix' => $suffix, 'sha256_8' => $trimmed === '' ? '' : substr(hash('sha256', $trimmed), 0, 8), ]; } function append_safe_credential_trace(array &$trace, array $creds): void { $accessValue = (string)($creds['accessKeyId'] ?? $creds['access'] ?? $creds[0] ?? ''); $secretValue = (string)($creds['secretAccessKey'] ?? $creds['secret'] ?? $creds[1] ?? ''); $tokenValue = (string)($creds['sessionToken'] ?? $creds['token'] ?? $creds[2] ?? ''); $sourceValue = (string)($creds['source'] ?? 'runtime signer credentials'); $access = safe_credential_fingerprint($accessValue, false); $secret = safe_credential_fingerprint($secretValue, true); $token = safe_credential_fingerprint($tokenValue, true); $trace[] = 'credential_source=' . $sourceValue; $trace[] = 'credential_config_path=' . LOCAL_CONFIG_DEFAULT_PATH; $trace[] = 'credential_array_shape=' . (array_key_exists(0, $creds) ? 'numeric' : 'named'); $trace[] = 'access_key_length=' . $access['length']; $trace[] = 'access_key_trimmed_length=' . $access['trimmed_length']; $trace[] = 'access_key_trim_changed=' . $access['trim_changed']; $trace[] = 'access_key_prefix=' . $access['prefix']; $trace[] = 'access_key_suffix=' . $access['suffix']; $trace[] = 'access_key_sha256_8=' . $access['sha256_8']; $trace[] = 'secret_key_length=' . $secret['length']; $trace[] = 'secret_key_trimmed_length=' . $secret['trimmed_length']; $trace[] = 'secret_key_trim_changed=' . $secret['trim_changed']; $trace[] = 'secret_key_sha256_8=' . $secret['sha256_8']; $trace[] = 'session_token_present=' . ($tokenValue !== '' ? 'yes' : 'no'); if ($tokenValue !== '') { $trace[] = 'session_token_length=' . $token['length']; $trace[] = 'session_token_trimmed_length=' . $token['trimmed_length']; $trace[] = 'session_token_trim_changed=' . $token['trim_changed']; $trace[] = 'session_token_sha256_8=' . $token['sha256_8']; } $trace[] = 'server_time_utc=' . gmdate('Y-m-d\TH:i:s\Z'); $trace[] = 'server_unix_time=' . time(); } function load_local_credentials(): array { // Compatibility wrapper used by the AWS test resolver. // This file's real local-config loader is credentials_from_local_config_file_if_available(). // Read only AWS keys from local config. Never use the tool username/password fields. if (function_exists('credentials_from_local_config_file_if_available')) { $loaded = credentials_from_local_config_file_if_available(LOCAL_CONFIG_DEFAULT_PATH); if (is_array($loaded) && !empty($loaded['configured'])) { return [ (string)($loaded['access'] ?? ''), (string)($loaded['secret'] ?? ''), (string)($loaded['token'] ?? ''), ]; } if (is_array($loaded) && isset($loaded['config']) && is_array($loaded['config'])) { $cfg = $loaded['config']; return [ (string)($cfg['awsAccessKeyId'] ?? $cfg['accessKeyId'] ?? $cfg['AWS_ACCESS_KEY_ID'] ?? ''), (string)($cfg['awsSecretAccessKey'] ?? $cfg['secretAccessKey'] ?? $cfg['AWS_SECRET_ACCESS_KEY'] ?? ''), (string)($cfg['awsSessionToken'] ?? $cfg['sessionToken'] ?? $cfg['aws_session_token'] ?? $cfg['AWS_SESSION_TOKEN'] ?? ''), ]; } } if (function_exists('load_local_config_file')) { try { $cfg = load_local_config_file(LOCAL_CONFIG_DEFAULT_PATH); if (is_array($cfg)) { return [ (string)($cfg['awsAccessKeyId'] ?? $cfg['accessKeyId'] ?? $cfg['AWS_ACCESS_KEY_ID'] ?? ''), (string)($cfg['awsSecretAccessKey'] ?? $cfg['secretAccessKey'] ?? $cfg['AWS_SECRET_ACCESS_KEY'] ?? ''), (string)($cfg['awsSessionToken'] ?? $cfg['sessionToken'] ?? $cfg['aws_session_token'] ?? $cfg['AWS_SESSION_TOKEN'] ?? ''), ]; } } catch (Throwable $e) { return ['', '', '']; } } return ['', '', '']; } function is_fixed_tool_username_value(string $value): bool { return strcasecmp(trim($value), 'cloudfront-media-tool') === 0; } function looks_like_aws_access_key_id(string $value): bool { $v = trim($value); return (bool)preg_match('/^(AKIA|ASIA)[A-Z0-9]{16}$/', $v); } function local_config_has_aws_credentials(): bool { [$access, $secret, $token] = load_local_credentials(); return trim((string)$access) !== '' && trim((string)$secret) !== ''; } function resolve_aws_credentials_for_test(): array { [$configAccess, $configSecret, $configToken] = load_local_credentials(); $manualAccessRaw = (string)current_post('awsAccessKeyId', ''); $manualSecretRaw = (string)current_post('awsSecretAccessKey', ''); $manualTokenRaw = (string)current_post('awsSessionToken', ''); $manualAccess = trim($manualAccessRaw); $manualSecret = trim($manualSecretRaw); $manualToken = trim($manualTokenRaw); $sourceDetail = 'local config'; if (trim((string)$configAccess) === '' || trim((string)$configSecret) === '') { $sourceDetail = 'local config missing AWS access key or secret key; checked real local config loader'; } // Browser autocomplete can accidentally place the fixed tool username into the AWS access key field. // Never treat the tool username as an AWS access key. if ($manualAccess !== '' && (is_fixed_tool_username_value($manualAccess))) { $manualAccess = ''; $sourceDetail = 'local config; ignored manual AWS access field because it contained the tool username'; } // Obfuscated placeholders and non-AWS strings are visual only; they are not AWS credentials. if ($manualAccess !== '' && !looks_like_aws_access_key_id($manualAccess)) { $manualAccess = ''; $sourceDetail = 'local config; ignored manual AWS access field because it was not AKIA/ASIA format'; } $hasManualPair = ($manualAccess !== '' && $manualSecret !== ''); if ($hasManualPair) { return [ 0 => $manualAccess, 1 => $manualSecret, 2 => ($manualToken !== '' ? $manualToken : null), 'accessKeyId' => $manualAccess, 'secretAccessKey' => $manualSecret, 'sessionToken' => $manualToken, 'source' => 'manual AWS credential fields', ]; } return [ 0 => trim((string)$configAccess), 1 => trim((string)$configSecret), 2 => (trim((string)$configToken) !== '' ? trim((string)$configToken) : null), 'accessKeyId' => trim((string)$configAccess), 'secretAccessKey' => trim((string)$configSecret), 'sessionToken' => trim((string)$configToken), 'source' => $sourceDetail, ]; } function append_runtime_credential_source_log(array &$log, array $creds): void { $trace = []; append_safe_credential_trace($trace, $creds); foreach ($trace as $line) { if ( str_starts_with($line, 'credential_source=') || str_starts_with($line, 'access_key_prefix=') || str_starts_with($line, 'access_key_suffix=') || str_starts_with($line, 'access_key_sha256_8=') || str_starts_with($line, 'secret_key_sha256_8=') || str_starts_with($line, 'session_token_present=') ) { $log[] = $line; } } } function cloudfront_diagnostic_credentials_test(array $creds): array { $trace = []; $started = microtime(true); $trace[] = 'diagnostic_start'; append_safe_credential_trace($trace, $creds); $trace[] = 'php_version=' . PHP_VERSION; $trace[] = 'curl_loaded=' . (extension_loaded('curl') ? 'yes' : 'no'); $trace[] = 'openssl_loaded=' . (extension_loaded('openssl') ? 'yes' : 'no'); $trace[] = 'php_sapi=' . PHP_SAPI; $trace[] = 'server_software=' . (string)($_SERVER['SERVER_SOFTWARE'] ?? ''); $trace[] = 'os=' . PHP_OS_FAMILY . ' ' . PHP_OS; if (function_exists('curl_version')) { $cv = curl_version(); $trace[] = 'curl_version=' . (string)($cv['version'] ?? ''); $trace[] = 'curl_ssl_version=' . (string)($cv['ssl_version'] ?? ''); $trace[] = 'curl_libz_version=' . (string)($cv['libz_version'] ?? ''); } $trace[] = 'openssl_cafile=' . (string)ini_get('openssl.cafile'); $trace[] = 'curl_cainfo=' . (string)ini_get('curl.cainfo'); if (!extension_loaded('curl')) { return [ 'ok' => false, 'message' => 'PHP cURL extension is not loaded.', 'trace' => $trace ]; } [$access, $secret, $token] = $creds; $method = 'GET'; $host = CF_HOST; $path = '/' . CF_VERSION . '/distribution'; $query = ['MaxItems' => '1']; $body = ''; try { $trace[] = 'signing_request'; $headers = aws_sigv4_headers($method, $host, $path, $query, $body, $access, $secret, $token); $url = 'https://' . $host . $path . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); $trace[] = 'url=' . $url; $headerLines = []; foreach ($headers as $k => $v) { $headerLines[] = $k . ': ' . $v; } $ch = curl_init($url); if (!$ch) { return [ 'ok' => false, 'message' => 'Could not initialize cURL.', 'trace' => $trace ]; } $curlOptions = [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headerLines, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 4, CURLOPT_NOSIGNAL => true, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, ]; apply_cloudfront_tls_options($curlOptions, $trace); curl_setopt_array($ch, $curlOptions); $trace[] = 'curl_exec_start timeout=8 connecttimeout=4'; $raw = curl_exec($ch); $errno = curl_errno($ch); $error = curl_error($ch); $info = curl_getinfo($ch); curl_close($ch); $elapsedMs = (int)round((microtime(true) - $started) * 1000); $trace[] = 'curl_exec_end elapsed_ms=' . $elapsedMs; $trace[] = 'curl_errno=' . $errno; if ($error !== '') { $trace[] = 'curl_error=' . $error; } $trace[] = 'http_code=' . (string)($info['http_code'] ?? ''); $trace[] = 'namelookup_time=' . (string)($info['namelookup_time'] ?? ''); $trace[] = 'connect_time=' . (string)($info['connect_time'] ?? ''); $trace[] = 'appconnect_time=' . (string)($info['appconnect_time'] ?? ''); $trace[] = 'total_time=' . (string)($info['total_time'] ?? ''); if ($raw === false) { return [ 'ok' => false, 'message' => explain_curl_ssl_error($errno, $error), 'trace' => $trace ]; } $headerSize = (int)($info['header_size'] ?? 0); $responseBody = substr($raw, $headerSize); $status = (int)($info['http_code'] ?? 0); if ($status >= 200 && $status < 300) { return [ 'ok' => true, 'message' => 'AWS credentials OK. CloudFront answered in ' . $elapsedMs . ' ms.', 'trace' => $trace ]; } [$awsErrorCode, $awsErrorMessage, $awsRequestId] = aws_error_details_from_body($responseBody); $cleanBody = one_line_snippet($responseBody, 500); if ($cleanBody === '') { $cleanBody = 'HTTP ' . $status; } if ($awsErrorCode !== '') { $trace[] = 'aws_error_code=' . $awsErrorCode; } if ($awsErrorMessage !== '') { $trace[] = 'aws_error_message=' . one_line_snippet($awsErrorMessage, 500); } if ($awsRequestId !== '') { $trace[] = 'aws_request_id=' . $awsRequestId; } $trace[] = 'aws_response_body_snippet=' . $cleanBody; $shortMessage = 'CloudFront answered HTTP ' . $status; if ($awsErrorCode !== '') { $shortMessage .= ' ' . $awsErrorCode; } if ($awsErrorMessage !== '') { $shortMessage .= ': ' . substr(one_line_snippet($awsErrorMessage, 240), 0, 240); } else { $shortMessage .= ': ' . substr($cleanBody, 0, 240); } return [ 'ok' => false, 'message' => $shortMessage, 'trace' => $trace ]; } catch (Throwable $e) { $trace[] = 'exception=' . $e->getMessage(); return [ 'ok' => false, 'message' => 'Credential diagnostic failed before CloudFront response: ' . $e->getMessage(), 'trace' => $trace ]; } } function test_cloudfront_credentials(array $creds): array { try { cloudfront_request('GET', '/' . CF_VERSION . '/distribution', ['MaxItems' => '1'], '', [], $creds); return [ 'ok' => true, 'message' => 'Credentials tested successfully with CloudFront.' ]; } catch (Throwable $e) { return [ 'ok' => false, 'message' => 'Credentials failed CloudFront test: ' . $e->getMessage() ]; } } function current_post(string $key, string $default = ''): string { return isset($_POST[$key]) ? (string)$_POST[$key] : $default; } $missingExtensions = require_extensions(); $log = []; $error = ''; $ran = false; $actionLabel = ''; $discoveredDistributions = []; $selectedDistributionRows = []; $defaultPassword = getenv('CF_TOOL_PASSWORD') ?: TOOL_PASSWORD_DEFAULT; $currentActionToken = current_tool_action_token_for_render($defaultPassword); function tool_action_cookie_name(): string { return 'cf_media_action_token_' . substr(hash('sha256', __FILE__), 0, 12); } function new_tool_action_token(string $password): string { $issued = (string)time(); $nonce = bin2hex(random_bytes(16)); $sig = hash_hmac('sha256', $issued . '|' . $nonce, hash('sha256', $password)); return $issued . '|' . $nonce . '|' . $sig; } function set_tool_action_token_cookie(string $password): string { $token = new_tool_action_token($password); setcookie(tool_action_cookie_name(), $token, [ 'expires' => time() + TOOL_AUTH_COOKIE_SECONDS, 'path' => '/', 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), 'httponly' => false, 'samesite' => 'Strict', ]); $_COOKIE[tool_action_cookie_name()] = $token; return $token; } function current_tool_action_token_for_render(string $password): string { $token = (string)($_COOKIE[tool_action_cookie_name()] ?? ''); if ($token === '') { return set_tool_action_token_cookie($password); } return $token; } function valid_tool_action_token(string $password, string $postedToken): bool { $cookieToken = (string)($_COOKIE[tool_action_cookie_name()] ?? ''); if ($postedToken === '' || $cookieToken === '' || !hash_equals($cookieToken, $postedToken)) { return false; } $parts = explode('|', $postedToken); if (count($parts) !== 3) { return false; } [$issued, $nonce, $sig] = $parts; if (!ctype_digit($issued) || strlen($nonce) < 16) { return false; } $age = time() - (int)$issued; if ($age < 0 || $age > TOOL_AUTH_COOKIE_SECONDS) { return false; } $expected = hash_hmac('sha256', $issued . '|' . $nonce, hash('sha256', $password)); return hash_equals($expected, $sig); } function rotate_tool_action_token_cookie(string $password): string { return set_tool_action_token_cookie($password); } function stream_json_line(array $row): void { echo json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . " "; // Push a harmless whitespace line so IIS/FastCGI/proxies/browsers are much // less likely to buffer tiny progress events until the whole run completes. // The browser parser trims and ignores this blank line. echo str_repeat(' ', 4096) . " "; @ob_flush(); @flush(); } function start_ndjson_stream(): void { while (ob_get_level() > 0) { @ob_end_flush(); } @ini_set('zlib.output_compression', '0'); @ini_set('output_buffering', '0'); @ini_set('implicit_flush', '1'); @ini_set('max_execution_time', '0'); @set_time_limit(0); @ignore_user_abort(true); if (function_exists('apache_setenv')) { @apache_setenv('no-gzip', '1'); } header('Content-Type: application/x-ndjson; charset=utf-8'); header('Cache-Control: no-cache, no-store, must-revalidate, no-transform'); header('Pragma: no-cache'); header('Expires: 0'); header('X-Accel-Buffering: no'); // Initial padding opens the stream immediately on buffering servers. echo str_repeat(' ', 4096) . " "; @flush(); } function runlog_job_dir(): string { $dir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'cf-media-tool-runlogs'; if (!is_dir($dir)) { @mkdir($dir, 0700, true); } return $dir; } function sanitize_runlog_job_id(string $jobId): string { $jobId = preg_replace('/[^a-zA-Z0-9_-]/', '', $jobId) ?: ''; return substr($jobId, 0, 80); } function runlog_job_path(string $jobId): string { $jobId = sanitize_runlog_job_id($jobId); if ($jobId === '') { $jobId = 'missing-job-id'; } return runlog_job_dir() . DIRECTORY_SEPARATOR . $jobId . '.json'; } function write_runlog_job(string $jobId, array $payload): void { $jobId = sanitize_runlog_job_id($jobId); if ($jobId === '') { return; } $payload['jobId'] = $jobId; $payload['updatedAt'] = gmdate('c'); @file_put_contents(runlog_job_path($jobId), json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX); } function read_runlog_job(string $jobId): ?array { $jobId = sanitize_runlog_job_id($jobId); if ($jobId === '') { return null; } $path = runlog_job_path($jobId); if (!is_file($path)) { return null; } $json = @file_get_contents($path); $data = is_string($json) ? json_decode($json, true) : null; return is_array($data) ? $data : null; } function tool_auth_cookie_name(): string { return 'cf_media_tool_auth'; } function tool_auth_cookie_signature(string $password, int $expires): string { return hash_hmac('sha256', $expires . '|' . TOOL_LOGIN_USERNAME, hash('sha256', $password, true)); } function set_tool_auth_cookie(string $password): void { $expires = time() + TOOL_AUTH_COOKIE_SECONDS; $sig = tool_auth_cookie_signature($password, $expires); $value = $expires . ':' . $sig; setcookie(tool_auth_cookie_name(), $value, [ 'expires' => $expires, 'path' => '/', 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), 'httponly' => true, 'samesite' => 'Strict', ]); } function has_valid_tool_auth_cookie(string $password): bool { $raw = $_COOKIE[tool_auth_cookie_name()] ?? ''; if (!is_string($raw) || strpos($raw, ':') === false) { return false; } [$expires, $sig] = explode(':', $raw, 2); if (!ctype_digit($expires) || (int)$expires < time()) { return false; } $expected = tool_auth_cookie_signature($password, (int)$expires); return hash_equals($expected, $sig); } function clear_tool_auth_cookie(): void { unset($_COOKIE[tool_auth_cookie_name()]); setcookie(tool_auth_cookie_name(), '', [ 'expires' => time() - 3600, 'path' => '/', 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), 'httponly' => true, 'samesite' => 'Strict', ]); } function is_tool_access_authorized(string $password): bool { $username = current_post('toolUsername', TOOL_LOGIN_USERNAME); $postedPassword = current_post('toolPassword'); if (hash_equals(TOOL_LOGIN_USERNAME, $username) && hash_equals($password, $postedPassword)) { return true; } return has_valid_tool_auth_cookie($password); } $loadedLocalConfig = []; $configHasKeys = false; $configFileFound = false; $configHasSessionToken = false; $localConfigNotice = ''; $localConfigStatus = null; $localConfigCredentialTest = null; $localConfigPathForUi = current_post('localConfigPath', LOCAL_CONFIG_DEFAULT_PATH); try { $localConfigStatus = credentials_from_local_config_file_if_available($localConfigPathForUi); if (!empty($localConfigStatus['found'])) { $loadedLocalConfig = $localConfigStatus['config']; $localConfigCredentialTest = [ 'ok' => !empty($localConfigStatus['configured']) ? null : false, 'message' => $localConfigStatus['message'] ?? '' ]; } } catch (Throwable $e) { $localConfigStatus = [ 'found' => true, 'path' => $localConfigPathForUi, 'config' => [], 'message' => 'Local config file found, but it could not be read.' ]; $localConfigCredentialTest = [ 'ok' => false, 'message' => 'Local config file found, but it could not be read.' ]; } // Credential display state used by the page layout. $configHasKeys = !empty($localConfigStatus['configured']); $configFileFound = !empty($localConfigStatus['found']); $configHasSessionToken = $configHasKeys && local_config_session_token($loadedLocalConfig) !== ''; $configAccessMask = $configHasKeys ? mask_for_value(local_config_value($loadedLocalConfig, 'awsAccessKeyId')) : ''; $configSecretMask = $configHasKeys ? mask_for_value(local_config_value($loadedLocalConfig, 'awsSecretAccessKey')) : ''; $configTokenMask = $configHasSessionToken ? mask_for_value(local_config_session_token($loadedLocalConfig)) : ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $isAjaxMode = in_array(current_post('mode'), ['ajax-test-tool-password', 'ajax-test-credentials', 'ajax-discover-distributions', 'ajax-prefetch-distributions', 'ajax-check-tool-auth', 'ajax-logout-tool-auth', 'ajax-run-action'], true); if ($isAjaxMode) { ob_start(); ini_set('display_errors', '0'); error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE & ~E_WARNING); } if (current_post('mode') === 'ajax-logout-tool-auth') { // JSON response handled by send_json_response(). clear_tool_auth_cookie(); send_json_response([ 'ok' => true, 'target' => 'toolPassword', 'message' => 'Logged out.' ]); } if (current_post('mode') === 'ajax-check-tool-auth') { // JSON response handled by send_json_response(). if ($defaultPassword === 'change-this-password-now') { send_json_response([ 'ok' => false, 'target' => 'toolPassword', 'message' => 'Change TOOL_PASSWORD_DEFAULT first.' ]); } if (has_valid_tool_auth_cookie($defaultPassword)) { send_json_response([ 'ok' => true, 'target' => 'toolPassword', 'message' => 'Access already authorized.' ]); } send_json_response([ 'ok' => false, 'target' => 'toolPassword', 'message' => 'Access not yet authorized.' ]); } if (current_post('mode') === 'ajax-test-tool-password') { // JSON response handled by send_json_response(). $password = current_post('toolPassword'); $username = current_post('toolUsername', TOOL_LOGIN_USERNAME); if (!hash_equals(TOOL_LOGIN_USERNAME, $username)) { send_json_response([ 'ok' => false, 'target' => 'toolPassword', 'message' => 'Tool username is incorrect.' ]); } if ($defaultPassword === 'change-this-password-now') { send_json_response([ 'ok' => false, 'target' => 'toolPassword', 'message' => 'Change TOOL_PASSWORD_DEFAULT first.' ]); } if (!hash_equals($defaultPassword, $password)) { send_json_response([ 'ok' => false, 'target' => 'toolPassword', 'message' => 'Tool password is incorrect.' ]); } set_tool_auth_cookie($defaultPassword); send_json_response([ 'ok' => true, 'target' => 'toolPassword', 'message' => 'Password OK.' ]); } if (current_post('mode') === 'ajax-test-credentials') { // JSON response handled by send_json_response(). try { if ($missingExtensions) { throw new RuntimeException('Missing PHP extensions: ' . implode(', ', $missingExtensions)); } $password = current_post('toolPassword'); $username = current_post('toolUsername', TOOL_LOGIN_USERNAME); if (!hash_equals(TOOL_LOGIN_USERNAME, $username)) { send_json_response([ 'ok' => false, 'target' => 'toolPassword', 'message' => 'Tool username is incorrect.' ]); } if (!(hash_equals($defaultPassword, $password) || has_valid_tool_auth_cookie($defaultPassword))) { send_json_response([ 'ok' => false, 'target' => 'toolPassword', 'message' => 'Tool password is incorrect.' ]); } if ($defaultPassword === 'change-this-password-now') { send_json_response([ 'ok' => false, 'target' => 'toolPassword', 'message' => 'Change TOOL_PASSWORD_DEFAULT first.' ]); } $test = cloudfront_diagnostic_credentials_test(resolve_aws_credentials_for_test()); if (!empty($test['ok'])) { send_json_response([ 'ok' => true, 'target' => 'awsCredentials', 'message' => $test['message'] ?? 'Credentials OK.', 'serverTrace' => $test['trace'] ?? [] ]); } send_json_response([ 'ok' => false, 'target' => 'awsCredentials', 'message' => $test['message'] ?? 'Credential test failed.', 'serverTrace' => $test['trace'] ?? [] ]); } catch (Throwable $e) { send_json_response([ 'ok' => false, 'target' => 'awsCredentials', 'message' => $e->getMessage() ]); } } if (current_post('mode') === 'ajax-discover-distributions') { // JSON response handled by send_json_response(). try { if ($missingExtensions) { throw new RuntimeException('Missing PHP extensions: ' . implode(', ', $missingExtensions)); } $password = current_post('toolPassword'); $username = current_post('toolUsername', TOOL_LOGIN_USERNAME); if (!hash_equals(TOOL_LOGIN_USERNAME, $username)) { send_json_response([ 'ok' => false, 'target' => 'toolPassword', 'message' => 'Tool username is incorrect.' ]); } if (!(hash_equals($defaultPassword, $password) || has_valid_tool_auth_cookie($defaultPassword))) { send_json_response([ 'ok' => false, 'target' => 'toolPassword', 'message' => 'Tool password is incorrect.' ]); } if ($defaultPassword === 'change-this-password-now') { send_json_response([ 'ok' => false, 'target' => 'toolPassword', 'message' => 'Change TOOL_PASSWORD_DEFAULT first.' ]); } $access = current_post('awsAccessKeyId'); $secret = current_post('awsSecretAccessKey'); $token = current_post('awsSessionToken'); if ($access === '') { $access = local_config_value($loadedLocalConfig, 'awsAccessKeyId'); } if ($secret === '') { $secret = local_config_value($loadedLocalConfig, 'awsSecretAccessKey'); } if ($token === '') { $token = local_config_session_token($loadedLocalConfig); } if (!$access || !$secret || looks_like_placeholder_key($access) || looks_like_placeholder_key($secret)) { send_json_response([ 'ok' => false, 'target' => 'awsCredentials', 'message' => 'AWS keys are missing or still placeholders.' ]); } $rows = discover_distributions([$access, $secret, $token ?: null]); send_json_response([ 'ok' => true, 'target' => 'distributions', 'message' => 'Loaded ' . count($rows) . ' CloudFront distributions.', 'distributions' => $rows ]); } catch (Throwable $e) { send_json_response([ 'ok' => false, 'target' => 'distributions', 'message' => $e->getMessage() ]); } } if (current_post('mode') === 'ajax-prefetch-distributions') { // Background local-config prefetch. Results are displayed only after Access is verified. try { if ($missingExtensions) { throw new RuntimeException('Missing PHP extensions: ' . implode(', ', $missingExtensions)); } $access = local_config_value($loadedLocalConfig, 'awsAccessKeyId'); $secret = local_config_value($loadedLocalConfig, 'awsSecretAccessKey'); $token = local_config_session_token($loadedLocalConfig); if (!$access || !$secret || looks_like_placeholder_key($access) || looks_like_placeholder_key($secret)) { send_json_response([ 'ok' => false, 'target' => 'awsCredentials', 'message' => 'Local AWS keys are missing or still placeholders.' ]); } $credentialTest = cloudfront_diagnostic_credentials_test([$access, $secret, $token ?: null]); if (empty($credentialTest['ok'])) { send_json_response([ 'ok' => false, 'target' => 'awsCredentials', 'message' => $credentialTest['message'] ?? 'Credential test failed.', 'serverTrace' => $credentialTest['trace'] ?? [] ]); } $rows = discover_distributions([$access, $secret, $token ?: null]); send_json_response([ 'ok' => true, 'target' => 'distributions', 'message' => 'AWS credentials OK. Loaded ' . count($rows) . ' CloudFront distributions.', 'serverTrace' => $credentialTest['trace'] ?? [], 'distributions' => $rows ]); } catch (Throwable $e) { send_json_response([ 'ok' => false, 'target' => 'distributions', 'message' => $e->getMessage() ]); } } if (current_post('mode') === 'ajax-run-status') { $jobId = sanitize_runlog_job_id(current_post('jobId')); $payload = read_runlog_job($jobId); if (!$payload) { send_json_response([ 'ok' => true, 'type' => 'progress', 'kind' => 'wait', 'message' => 'Waiting for run to start…', 'log' => ['Waiting for run to start…'], 'jobId' => $jobId, ]); } $payload['ok'] = $payload['ok'] ?? true; send_json_response($payload); } if (current_post('mode') === 'ajax-run-action') { start_ndjson_stream(); $streamLog = []; $streamError = ''; $streamApply = current_post('runAction') === 'apply'; $streamJobId = sanitize_runlog_job_id(current_post('jobId')); if ($streamJobId === '') { $streamJobId = bin2hex(random_bytes(12)); } write_runlog_job($streamJobId, [ 'type' => 'progress', 'kind' => 'wait', 'message' => 'Run accepted by server…', 'log' => ['Run accepted by server…'], 'done' => false, ]); $progress = function (string $text, string $kind = 'wait', array $extra = []) use (&$streamLog, $streamJobId): void { $streamLog[] = $text; $row = array_merge([ 'type' => 'progress', 'kind' => $kind, 'message' => $text, 'log' => $streamLog, 'done' => false, ], $extra); write_runlog_job($streamJobId, $row); stream_json_line($row); }; try { $progress(TOOL_PACKAGE_NAME . ' | ' . TOOL_VERSION . ' | built ' . TOOL_BUILD_TIMESTAMP_UTC, 'wait'); $progress($streamApply ? 'APPLY MODE: CloudFront will be updated.' : 'DRY RUN MODE: no CloudFront changes will be made.', $streamApply ? 'warn' : 'wait'); if (!valid_tool_action_token($defaultPassword, current_post('toolActionToken'))) { throw new RuntimeException('Apply/Dry run was blocked because this was not an AJAX action from the current page. Reload the page and try again.'); } $currentActionToken = current_tool_action_token_for_render($defaultPassword); stream_json_line([ 'type' => 'token', 'token' => $currentActionToken, ]); $progress('Fresh AJAX action token accepted.', 'good'); if ($missingExtensions) { throw new RuntimeException('Missing PHP extensions: ' . implode(', ', $missingExtensions)); } $password = current_post('toolPassword'); $username = current_post('toolUsername', TOOL_LOGIN_USERNAME); if (!hash_equals(TOOL_LOGIN_USERNAME, $username)) { throw new RuntimeException('Tool username is incorrect.'); } if (!(hash_equals($defaultPassword, $password) || has_valid_tool_auth_cookie($defaultPassword))) { throw new RuntimeException('Tool password is incorrect. Log in again before applying changes.'); } if ($defaultPassword === 'change-this-password-now') { throw new RuntimeException('Change TOOL_PASSWORD_DEFAULT or set CF_TOOL_PASSWORD before using this tool.'); } $progress('Access authorization OK.', 'good'); $resolvedCreds = resolve_aws_credentials_for_test(); $access = (string)($resolvedCreds['accessKeyId'] ?? $resolvedCreds[0] ?? ''); $secret = (string)($resolvedCreds['secretAccessKey'] ?? $resolvedCreds[1] ?? ''); $token = (string)($resolvedCreds['sessionToken'] ?? $resolvedCreds[2] ?? ''); if (!$access || !$secret || looks_like_placeholder_key($access) || looks_like_placeholder_key($secret)) { throw new RuntimeException('AWS access key and secret key are required. Enter real keys manually, or create a local config file with active awsAccessKeyId and awsSecretAccessKey entries. Make sure the key lines are not commented out and not left as placeholders.'); } $creds = [ 0 => $access, 1 => $secret, 2 => ($token !== '' ? $token : null), 'accessKeyId' => $access, 'secretAccessKey' => $secret, 'sessionToken' => $token, 'source' => (string)($resolvedCreds['source'] ?? 'credential resolver'), ]; $credentialLog = []; append_runtime_credential_source_log($credentialLog, $creds); foreach ($credentialLog as $line) { $progress($line, 'wait'); } $progress('AWS credential source resolved. Creating/reusing CloudFront policies.', 'wait'); $mediaPatterns = lines_to_array(current_post('mediaPatterns', DEFAULT_MEDIA_PATTERNS_TEXT)); if (!$mediaPatterns) { throw new RuntimeException('At least one media pattern is required.'); } $selectedDistributionRows = decode_selected_distributions($_POST['selectedDistributions'] ?? []); if ($selectedDistributionRows) { $distributionRows = $selectedDistributionRows; } else { $distributionRows = parse_distribution_pairs('', current_post('distributionLabels')); } if (!$distributionRows) { throw new RuntimeException('Select at least one distribution before applying changes.'); } $removedPublicDistributionIdFallbacks = array_column($distributionRows, 'id'); $config = [ 'removedPublicDistributionIdFallbacks' => $removedPublicDistributionIdFallbacks, 'mediaPatterns' => $mediaPatterns, 'addDebugBehavior' => current_post('addDebugBehavior') === '1', 'addJsonBehavior' => false, 'addMediaBehaviors' => current_post('addMediaBehaviors') === '1' ]; if (!$streamApply) { $progress('Dry run note: candidate XML stays in this browser response. No CloudFront updates will be sent.', 'wait'); } $progress('Ensuring media cache policy.', 'wait'); $mediaPolicy = ensure_cache_policy( MEDIA_CACHE_POLICY_NAME, cache_policy_xml(MEDIA_CACHE_POLICY_NAME, 'Long-lived cache for immutable public media assets.', 0, 31536000, 31536000), $creds, $streamApply, $streamLog ); $progress('Media cache policy ready: ' . $mediaPolicy, 'good'); $progress('Ensuring JSON cache policy object only. JSON behaviors remain untouched.', 'wait'); $jsonPolicy = ensure_cache_policy( JSON_CACHE_POLICY_NAME, cache_policy_xml(JSON_CACHE_POLICY_NAME, 'Near-fresh cache for JSON files, including .directory-index.json.', 0, 1, 60), $creds, $streamApply, $streamLog ); $progress('JSON cache policy ready: ' . $jsonPolicy . ' ; *.json behavior remains untouched.', 'good'); $progress('Ensuring debug cache policy.', 'wait'); $debugPolicy = ensure_cache_policy( DEBUG_CACHE_POLICY_NAME, cache_policy_xml(DEBUG_CACHE_POLICY_NAME, 'No-cache policy for URLs containing debug.', 0, 0, 0, false), $creds, $streamApply, $streamLog ); $progress('Debug cache policy ready: ' . $debugPolicy, 'good'); $progress('Ensuring public CORS response headers policy.', 'wait'); $corsPolicy = ensure_response_headers_policy( MEDIA_CORS_POLICY_NAME, response_headers_policy_xml(), $creds, $streamApply, $streamLog ); $progress('Public CORS response headers policy ready: ' . $corsPolicy, 'good'); $policyIds = [ 'media' => $mediaPolicy, 'json' => $jsonPolicy, 'debug' => $debugPolicy, 'cors' => $corsPolicy ]; $total = count($distributionRows); $index = 0; foreach ($distributionRows as $row) { $index++; $label = (string)($row['label'] ?? ''); $id = (string)($row['id'] ?? ''); $display = trim($label) !== '' ? $label . ' (' . $id . ')' : $id; $progress('Distribution ' . $index . ' of ' . $total . ': starting ' . $display, 'wait', [ 'current' => $index, 'total' => $total, 'distributionId' => $id, 'distributionLabel' => $label, ]); update_distribution($id, $label, $config, $policyIds, $creds, $streamApply, $streamLog, $progress); $progress('Distribution ' . $index . ' of ' . $total . ': finished ' . $display, 'good', [ 'current' => $index, 'total' => $total, 'distributionId' => $id, 'distributionLabel' => $label, ]); } $progress('Done.', 'good'); $doneRow = [ 'type' => 'done', 'ok' => true, 'kind' => 'good', 'message' => 'Done.', 'log' => $streamLog, 'token' => $currentActionToken, 'done' => true, ]; write_runlog_job($streamJobId, $doneRow); stream_json_line($doneRow); } catch (Throwable $e) { $streamError = $e->getMessage(); $streamLog[] = 'ERROR: ' . $streamError; $errorRow = [ 'type' => 'error', 'ok' => false, 'kind' => 'bad', 'message' => $streamError, 'log' => $streamLog, 'done' => true, ]; write_runlog_job($streamJobId ?? '', $errorRow); stream_json_line($errorRow); } exit; } $ran = true; $actionLabel = current_post('mode') ?: 'run'; try { if (in_array(current_post('mode'), ['dry-run', 'apply'], true)) { throw new RuntimeException('Dry run and Apply are AJAX-only in this version. Reload the page and use the buttons normally.'); } if ($missingExtensions) { throw new RuntimeException('Missing PHP extensions: ' . implode(', ', $missingExtensions)); } $password = current_post('toolPassword'); $username = current_post('toolUsername', TOOL_LOGIN_USERNAME); if (!hash_equals(TOOL_LOGIN_USERNAME, $username)) { throw new RuntimeException('Tool username is incorrect.'); } if (!(hash_equals($defaultPassword, $password) || has_valid_tool_auth_cookie($defaultPassword))) { throw new RuntimeException('Tool password is incorrect. Set CF_TOOL_PASSWORD in IIS/PHP environment or edit TOOL_PASSWORD_DEFAULT.'); } if ($defaultPassword === 'change-this-password-now') { throw new RuntimeException('Change TOOL_PASSWORD_DEFAULT or set CF_TOOL_PASSWORD before using this tool.'); } $resolvedCreds = resolve_aws_credentials_for_test(); $access = (string)($resolvedCreds['accessKeyId'] ?? $resolvedCreds[0] ?? ''); $secret = (string)($resolvedCreds['secretAccessKey'] ?? $resolvedCreds[1] ?? ''); $token = (string)($resolvedCreds['sessionToken'] ?? $resolvedCreds[2] ?? ''); if (!$access || !$secret || looks_like_placeholder_key($access) || looks_like_placeholder_key($secret)) { throw new RuntimeException('AWS access key and secret key are required. Enter real keys manually, or create a local config file with active awsAccessKeyId and awsSecretAccessKey entries. Make sure the key lines are not commented out and not left as placeholders.'); } $localConfigNotice = 'Using AWS credentials from ' . (string)($resolvedCreds['source'] ?? 'credential resolver') . '.'; if (current_post('mode') === 'test-credentials') { $log[] = TOOL_PACKAGE_NAME . ' | ' . TOOL_VERSION . ' | built ' . TOOL_BUILD_TIMESTAMP_UTC; if ($localConfigNotice !== '') { $log[] = $localConfigNotice; } $test = cloudfront_diagnostic_credentials_test(resolve_aws_credentials_for_test()); if (!empty($test['ok'])) { $log[] = 'Credentials ready. CloudFront test succeeded.'; } else { throw new RuntimeException($test['message'] ?? 'Credential test failed.'); } throw new RuntimeException('__TEST_COMPLETE__'); } $creds = [$access, $secret, $token ?: null]; if (current_post('mode') === 'discover') { if (!hash_equals(TOOL_LOGIN_USERNAME, current_post('toolUsername', TOOL_LOGIN_USERNAME)) || !is_tool_access_authorized($defaultPassword)) { $messages[] = ['type' => 'bad', 'text' => 'Tool password must be correct before distributions can be displayed.']; } else $discoveredDistributions = discover_distributions($creds); $log[] = TOOL_PACKAGE_NAME . ' | ' . TOOL_VERSION . ' | built ' . TOOL_BUILD_TIMESTAMP_UTC; $log[] = 'Loaded ' . count($discoveredDistributions) . ' CloudFront distributions.'; throw new RuntimeException('__DISCOVERY_COMPLETE__'); } $selectedDistributionRows = decode_selected_distributions($_POST['selectedDistributions'] ?? []); if ($selectedDistributionRows) { $distributionRows = $selectedDistributionRows; } else { $distributionRows = parse_distribution_pairs('', current_post('distributionLabels')); } if (!$distributionRows) { throw new RuntimeException('Select at least one distribution, or enter at least one distribution ID.'); } $removedGridOnlyTarget = trim(''); if ($removedGridOnlyTarget !== '') { $distributionRows = array_values(array_filter($distributionRows, function ($row) use ($removedGridOnlyTarget) { return strcasecmp($row['id'], $removedGridOnlyTarget) === 0 || strcasecmp($row['label'], $removedGridOnlyTarget) === 0 || stripos($row['label'], $removedGridOnlyTarget) !== false; })); if (!$distributionRows) { throw new RuntimeException('Run-only filter did not match any distribution ID or friendly name.'); } } $removedPublicDistributionIdFallbacks = array_column($distributionRows, 'id'); $friendlyDistributionNames = []; $rawDistributionLinesForNames = preg_split('/\R+/', (string)''); foreach ($rawDistributionLinesForNames as $rawDistributionLineForName) { $lineForName = trim((string)$rawDistributionLineForName); if ($lineForName === '') { continue; } $idForName = $lineForName; $nameForId = ''; if (strpos($lineForName, '|') !== false) { [$idForName, $nameForId] = array_map('trim', explode('|', $lineForName, 2)); } elseif (preg_match('/^(E[A-Z0-9]+)\s+(.+)$/', $lineForName, $mName)) { $idForName = trim($mName[1]); $nameForId = trim($mName[2]); } elseif (!preg_match('/^E[A-Z0-9]+$/', $lineForName)) { // A manually typed friendly name/domain can label itself until discovery maps it to an ID. $nameForId = $lineForName; } if ($idForName !== '' && $nameForId !== '') { $friendlyDistributionNames[$idForName] = $nameForId; } } $mediaPatterns = lines_to_array(current_post('mediaPatterns', DEFAULT_MEDIA_PATTERNS_TEXT)); if (!$mediaPatterns) { throw new RuntimeException('At least one media pattern is required.'); } $apply = current_post('mode') === 'apply'; $config = [ 'removedPublicDistributionIdFallbacks' => $removedPublicDistributionIdFallbacks, 'mediaPatterns' => $mediaPatterns, 'addDebugBehavior' => current_post('addDebugBehavior') === '1', 'addJsonBehavior' => false, 'addMediaBehaviors' => current_post('addMediaBehaviors') === '1' ]; $creds = [ 0 => $access, 1 => $secret, 2 => ($token !== '' ? $token : null), 'accessKeyId' => $access, 'secretAccessKey' => $secret, 'sessionToken' => $token, 'source' => (string)($resolvedCreds['source'] ?? 'credential resolver'), ]; $log[] = TOOL_PACKAGE_NAME . ' | ' . TOOL_VERSION . ' | built ' . TOOL_BUILD_TIMESTAMP_UTC; if ($localConfigNotice !== '') { $log[] = $localConfigNotice; } $log[] = $apply ? 'APPLY MODE: CloudFront will be updated.' : 'DRY RUN MODE: no CloudFront changes will be made.'; append_runtime_credential_source_log($log, $creds); if (!$apply) { $log[] = 'Dry run note: no files are written to this server. Candidate XML stays in this page for Copy results or Download results.'; } $mediaPolicy = ensure_cache_policy( MEDIA_CACHE_POLICY_NAME, cache_policy_xml(MEDIA_CACHE_POLICY_NAME, 'Long-lived cache for immutable public media assets.', 0, 31536000, 31536000), $creds, $apply, $log ); $jsonPolicy = ensure_cache_policy( JSON_CACHE_POLICY_NAME, cache_policy_xml(JSON_CACHE_POLICY_NAME, 'Near-fresh cache for JSON files, including .directory-index.json.', 0, 1, 60), $creds, $apply, $log ); $debugPolicy = ensure_cache_policy( DEBUG_CACHE_POLICY_NAME, cache_policy_xml(DEBUG_CACHE_POLICY_NAME, 'No-cache policy for URLs containing debug.', 0, 0, 0, false), $creds, $apply, $log ); $corsPolicy = ensure_response_headers_policy( MEDIA_CORS_POLICY_NAME, response_headers_policy_xml(), $creds, $apply, $log ); $policyIds = [ 'media' => $mediaPolicy, 'json' => $jsonPolicy, 'debug' => $debugPolicy, 'cors' => $corsPolicy ]; foreach ($distributionRows as $row) { update_distribution($row['id'], $row['label'], $config, $policyIds, $creds, $apply, $log); } $log[] = 'Done.'; } catch (Throwable $e) { if ($e->getMessage() === '__DISCOVERY_COMPLETE__' || $e->getMessage() === '__TEST_COMPLETE__') { $error = ''; } else { $error = $e->getMessage(); } } } $defaultDist = "E2V3BNV4FSTYQ1\nE23DJVV7J0ZZX9\nE1Z4EN8UZHXGSO"; $defaultLabels = "www.8k.art\nwww.8k.press\ndefine.com"; ?> CloudFront Media Behavior Automation v158

CloudFront Media Setup v158

Fast CloudFront setup for media caching, public CORS, near-fresh JSON, and no-cache debug paths.

Access
AWS credentials
TraceLog
Trace log is empty until you use the tool.
Distributions

Recommended: the app tests local AWS config automatically, authorizes with the tool password, then displays distributions automatically when both checks are OK. Choose friendly site names from the picker.

Available CloudFront distributions
How selection works

Check one or more distributions, then run Dry run or Apply. Logs show friendly names beside distribution IDs.

For IIS/FastCGI timeout safety, process one distribution at a time.

Behaviors
Run

Dry run keeps candidate distribution XML in memory and does not update CloudFront. Apply sends the distribution updates to AWS.

Created/reused policies

These are the reusable CloudFront policies the tool creates or reuses. Open any item to see what it means in plain English.

MediaCacheLong1Year

Used for image, audio, and video file patterns. This tells CloudFront to cache public media for a long time so repeat visitors are served from CloudFront instead of your origin.

Purpose: long-cache public media
Typical paths: *.jpg, *.png, *.webp, *.mp4, *.mp3, *.wav, ...
Result: faster media delivery and lower origin load
PublicMediaCORSFromEverywhere

Used when media or JSON must be readable by browsers, apps, canvases, players, creative tools, and JavaScript apps that people run from local files on their home PCs.

Purpose: public CORS response headers
Allowed origin: *
Common methods: GET, HEAD, OPTIONS
Result: public media can be fetched by browser JavaScript apps, including apps opened from the local file system on people's home PCs, without hand-editing every behavior
JsonNearFresh

Used for JSON files that should update quickly while still getting some CloudFront benefit.

Purpose: short-cache JSON
Typical paths: *.json
Result: app data and indexes stay near-fresh without making all static media short-cache
DebugNoCache

Used for debug paths so troubleshooting does not poison public cache results.

Purpose: no-cache debug behavior
Typical paths: *debug*
Result: testing and diagnostics stay uncached