false], $reason); } } $actor = raijuAgentActor(); if ($actor['userKey'] === '') { http_response_code(403); jsonResponse(false, null, 'Forbidden'); } return $actor; } function raijuAgentApiRateLimit(array $actor, string $bucket): void { $limits = raijuAgentSettings()['limits']; $config = ['runs' => ['limit' => $limits['runsPerHour'], 'window' => 3600], 'steps' => ['limit' => $limits['stepsPerMinute'], 'window' => 60]][$bucket]; $result = raijuAgentRateLimit($actor['userKey'], $bucket, $config); if (!$result['allowed']) { http_response_code(429); header('Retry-After: ' . $result['retryAfter']); jsonResponse(false, ['retryAfter' => $result['retryAfter']], 'Too many agent requests. Please wait a moment and try again.'); } } function raijuAgentApiRunId(): string { $id = $_POST['runId'] ?? $_GET['runId'] ?? ''; if (!is_string($id) || !raijuAgentValidRunId($id)) { jsonResponse(false, null, 'Invalid run'); } return $id; } function raijuAgentApiChangesetId(): string { $id = $_POST['id'] ?? $_GET['id'] ?? ''; if (!is_string($id) || !raijuAgentValidChangesetId($id)) { jsonResponse(false, null, 'Invalid change set'); } return $id; } } switch ($action) { case 'ai-agent-start': $actor = raijuAgentApiGuard(true); try { raijuCopilotAssertBurstLimit('agent', 120, 60); } catch (Throwable $e) { jsonResponse(false, null, $e->getMessage()); } raijuAgentApiRateLimit($actor, 'runs'); try { $attached = (string)($_POST['changesetId'] ?? ''); if ($attached !== '' && !raijuAgentValidChangesetId($attached)) { jsonResponse(false, null, 'Invalid change set'); } $run = raijuAgentRunStart($actor, (string)($_POST['goal'] ?? ''), $attached); } catch (InvalidArgumentException $e) { jsonResponse(false, null, $e->getMessage()); } // Housekeeping is cheap and rare: once an hour is enough. $marker = raijuAgentDir() . '/cleanup.stamp'; if (!is_file($marker) || filemtime($marker) < time() - 3600) { @touch($marker); raijuAgentCleanup(); } jsonResponse(true, raijuAgentRunPublicState($run), 'Run started'); break; case 'ai-agent-step': $actor = raijuAgentApiGuard(true); $runId = raijuAgentApiRunId(); try { raijuCopilotAssertBurstLimit('agent', 120, 60); } catch (Throwable $e) { jsonResponse(false, null, $e->getMessage()); } raijuAgentApiRateLimit($actor, 'steps'); // A step can take a while; do not hold the session lock (other tabs, the editor) meanwhile. session_write_close(); $result = raijuAgentRunStep($runId, $actor); if (($result['status'] ?? '') === 'missing') { http_response_code(404); jsonResponse(false, null, 'Unknown run'); } if (!empty($result['busy'])) { jsonResponse(true, ['runId' => $runId, 'status' => 'busy'], 'Another step of this run is in progress'); } jsonResponse(true, $result['state'], 'OK'); break; case 'ai-agent-upload': $actor = raijuAgentApiGuard(true); if (!raijuAgentCan('uploadImages')) { http_response_code(403); jsonResponse(false, null, 'Forbidden'); } $limits = raijuAgentSettings()['limits']; $rate = raijuAgentRateLimit($actor['userKey'], 'uploads', ['limit' => $limits['uploadsPer10Minutes'], 'window' => 600]); if (!$rate['allowed']) { http_response_code(429); header('Retry-After: ' . $rate['retryAfter']); jsonResponse(false, ['retryAfter' => $rate['retryAfter']], 'Too many uploads. Please wait a moment and try again.'); } $file = $_FILES['image'] ?? $_FILES['file'] ?? null; if (!is_array($file) || !is_string($file['tmp_name'] ?? null) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'])) { jsonResponse(false, null, 'Upload error'); } $imageConfig = getMediaConfig('image'); $extension = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION)); if (!in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) { jsonResponse(false, null, 'Only JPEG, PNG and WebP images are accepted.'); } if ((int)$file['size'] > (int)$imageConfig['maxSize']) { jsonResponse(false, null, 'File too large'); } $attached = (string)($_POST['changesetId'] ?? ''); if ($attached !== '') { $target = raijuAgentValidChangesetId($attached) ? raijuAgentChangesetLoad($attached) : null; if ($target === null || (string)$target['owner'] !== $actor['userKey'] || $target['status'] !== 'staging') { jsonResponse(false, null, 'Unknown change set'); } } else { $attached = raijuAgentChangesetCreate(['owner' => $actor['userKey']])['id']; } try { $image = raijuAgentIngestImage($file['tmp_name'], ['maxBytes' => (int)$imageConfig['maxSize']]); $record = raijuAgentMediaRegister($attached, $image, ['source' => 'upload', 'name' => (string)$file['name'], 'ai' => $image['ai']]); } catch (RuntimeException $e) { jsonResponse(false, null, $e->getMessage()); } raijuAgentAudit('agent-upload', true, ['cs' => $attached, 'ref' => $record['ref'], 'bytes' => $record['bytes']]); jsonResponse(true, ['changesetId' => $attached, 'media' => raijuAgentMediaSummary($record['ref'], $record), 'thumbnail' => 'admin/api.php?action=ai-agent-media&id=' . $attached . '&ref=' . $record['ref']], 'Uploaded'); break; case 'ai-agent-clear-cache': // The cached scan of the page templates; rebuilt on demand. Settings area, administrators only. if (!dashboardAiModuleEnabled()) jsonResponse(false, null, 'AI module is disabled'); if (!isAdmin()) { http_response_code(403); jsonResponse(false, null, 'Forbidden'); } $removed = raijuAgentClearCapabilityCache(); raijuAgentAudit('agent-cache-clear', true, ['files' => $removed]); jsonResponse(true, ['removed' => $removed], 'Cache cleared'); break; case 'ai-agent-media': // Staged images (uploads, stock photos, generated images) for the review panel; owner and admins only. $actor = raijuAgentApiGuard(false); $cs = raijuAgentChangesetLoad(raijuAgentApiChangesetId()); $ref = (string)($_GET['ref'] ?? ''); $media = ($cs !== null && preg_match('/^m_[a-f0-9]{8}$/', $ref)) ? ($cs['media'][$ref] ?? null) : null; if ($cs === null || $media === null || !raijuAgentCanSeeChangeset($cs, $actor)) { http_response_code(404); jsonResponse(false, null, 'Unknown image'); } $stagingDir = realpath(raijuAgentMediaStagingDir($cs['id'])); $file = realpath(raijuAgentAbsolute((string)$media['stagingFile'])); if ($stagingDir === false || $file === false || !str_starts_with($file, $stagingDir . DIRECTORY_SEPARATOR) || !is_file($file)) { http_response_code(404); jsonResponse(false, null, 'The image is no longer staged'); } header_remove('Content-Type'); header('Content-Type: ' . ($media['mime'] ?? 'image/webp')); header('X-Content-Type-Options: nosniff'); header('Cache-Control: private, max-age=300'); header('Content-Length: ' . filesize($file)); readfile($file); exit; case 'ai-agent-status': $actor = raijuAgentApiGuard(false); $state = raijuAgentRunStatus(raijuAgentApiRunId(), $actor, (int)($_GET['after'] ?? 0)); if ($state === null) { http_response_code(404); jsonResponse(false, null, 'Unknown run'); } jsonResponse(true, $state, 'OK'); break; case 'ai-agent-reply': $actor = raijuAgentApiGuard(true); $result = raijuAgentRunReply(raijuAgentApiRunId(), $actor, (string)($_POST['message'] ?? '')); if (($result['status'] ?? '') === 'missing') { http_response_code(404); jsonResponse(false, null, 'Unknown run'); } if (!empty($result['busy'])) { jsonResponse(true, ['status' => 'busy'], 'The agent is working; try again in a moment'); } if (isset($result['error'])) { jsonResponse(false, null, $result['error']); } jsonResponse(true, $result['state'], 'OK'); break; case 'ai-agent-cancel': $actor = raijuAgentApiGuard(false); $result = raijuAgentRunCancel(raijuAgentApiRunId(), $actor); if (($result['status'] ?? '') === 'missing') { http_response_code(404); jsonResponse(false, null, 'Unknown run'); } if (isset($result['error'])) { jsonResponse(false, null, $result['error']); } jsonResponse(true, $result['state'] ?? ['status' => $result['status'] ?? 'cancelling'], 'OK'); break; case 'ai-agent-changeset': $actor = raijuAgentApiGuard(false); $cs = raijuAgentChangesetLoad(raijuAgentApiChangesetId()); if ($cs === null || !raijuAgentCanSeeChangeset($cs, $actor)) { http_response_code(404); jsonResponse(false, null, 'Unknown change set'); } // After the apply the owner needs the items to publish and the undo token, also after a reload. $applied = $cs['status'] === 'applied' && !empty($cs['applied']) ? ['items' => array_values((array)$cs['applied']['items']), 'undoToken' => raijuAgentUndoToken($cs['id'])] : null; jsonResponse(true, raijuAgentChangesetPreview($cs) + ['canPublish' => $actor['canPublish'], 'applied' => $applied], 'OK'); break; case 'ai-agent-revalidate': $actor = raijuAgentApiGuard(false); $id = raijuAgentApiChangesetId(); $result = raijuAgentChangesetRevalidate($id, $actor); if (empty($result['ok'])) { // The status of a change set is only for its owner (and admins). $current = raijuAgentChangesetLoad($id); $visible = $current !== null && raijuAgentCanSeeChangeset($current, $actor); jsonResponse(false, ['status' => $visible ? $current['status'] : 'missing'], (string)($result['error'] ?? 'Revalidation failed')); } $report = raijuAgentChangesetFinalize($id); jsonResponse(true, ['status' => $report['status'], 'renderCheck' => $report['renderCheck']['status'] ?? 'skipped', 'previewHash' => $report['previewHash']], 'OK'); break; case 'ai-agent-apply': $actor = raijuAgentApiGuard(false); $id = raijuAgentApiChangesetId(); if (($_POST['confirmed'] ?? '') !== '1') { jsonResponse(false, null, 'Confirmation required'); } $result = raijuAgentChangesetApply($id, (string)($_POST['previewHash'] ?? ''), $actor, ['confirmRisk' => ($_POST['confirmRisk'] ?? '') === '1']); if (empty($result['ok'])) { if (($result['status'] ?? '') === 'conflict') { http_response_code(409); } jsonResponse(false, ['status' => $result['status'] ?? 'failed', 'needsRiskConfirm' => !empty($result['needsRiskConfirm'])], (string)$result['error']); } jsonResponse(true, ['status' => 'applied', 'undoToken' => $result['undoToken'], 'items' => $result['items'], 'files' => $result['files']], 'Applied'); break; case 'ai-agent-discard': $actor = raijuAgentApiGuard(false); if (!raijuAgentChangesetDiscard(raijuAgentApiChangesetId(), $actor)) { jsonResponse(false, null, 'This change set cannot be discarded'); } jsonResponse(true, ['status' => 'discarded'], 'Discarded'); break; case 'ai-agent-undo': $actor = raijuAgentApiGuard(false); $result = raijuAgentChangesetUndo(raijuAgentApiChangesetId(), (string)($_POST['token'] ?? ''), $actor); if (empty($result['ok'])) { jsonResponse(false, ['status' => $result['status'] ?? 'failed', 'needsRiskConfirm' => !empty($result['needsRiskConfirm'])], (string)$result['error']); } jsonResponse(true, ['status' => 'undone'], 'Undone'); break; case 'ai-agent-publish': $actor = raijuAgentApiGuard(false); $id = raijuAgentApiChangesetId(); $item = json_decode((string)($_POST['item'] ?? ''), true); if (!is_array($item) || !is_string($item['kind'] ?? null) || !is_string($item['target'] ?? null)) { jsonResponse(false, null, 'Invalid item'); } $item = array_intersect_key($item, array_flip(['kind', 'target', 'listKey', 'index', 'sectionId'])); $result = raijuAgentChangesetPublish($id, $item, $actor); if (empty($result['ok'])) { if (str_contains((string)$result['error'], 'not allowed to publish')) { http_response_code(403); } jsonResponse(false, null, (string)$result['error']); } jsonResponse(true, ['status' => 'published'], 'Published'); break; }