{ title, target_page, prompt, draft, assets } * GET /api/admintasks/list -> [{ task_id, title, status, ... }] * GET /api/admintasks/get?id= -> full row + result * POST /api/admintasks/cancel -> { id } (only if still queued) * * All admin-gated. This controller only does CRUD on the queue; the runner owns * execution. The Node runner claims/finishes rows via api/cli/tasks-*.php. */ class AdminTasks extends PublicController { private const STATUSES = ['queued', 'running', 'published', 'failed', 'canceled']; public function create(): void { $this->requireAdmin(); $this->guardPublic('admintasks_create', 60, 300); $in = json_decode((string) file_get_contents('php://input'), true) ?: []; $title = mb_substr(trim((string) ($in['title'] ?? '')), 0, 200); $target = mb_substr(trim((string) ($in['target_page'] ?? '')), 0, 200); $prompt = trim((string) ($in['prompt'] ?? '')); $draft = (string) ($in['draft'] ?? ''); if ($prompt === '') { $this->json(null, 400, ['code' => 'no_prompt', 'message' => 'Describe what you want done.']); } if ($target === '') { $this->json(null, 400, ['code' => 'no_target', 'message' => 'Pick a page (or a new page) for the task.']); } if ($title === '') { $title = mb_substr($prompt, 0, 80); } // Assets: only known-safe local /media/ images and real http(s) video URLs. $images = []; foreach ((array) ($in['assets']['images'] ?? []) as $img) { $url = trim((string) ($img['url'] ?? '')); if (preg_match('#^/media/[\w./-]+$#', $url)) { $images[] = ['url' => $url, 'alt' => mb_substr(trim((string) ($img['alt'] ?? '')), 0, 200)]; } } $videos = []; foreach ((array) ($in['assets']['videos'] ?? []) as $v) { $url = trim((string) $v); if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL) && preg_match('#^https?://#i', $url)) { $videos[] = mb_substr($url, 0, 500); } } $assets = json_encode(['images' => $images, 'videos' => $videos], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $order = (int) \Db::getValue('SELECT COALESCE(MAX(sort_order), 0) + 10 FROM cja_tasks'); \Db::insert('cja_tasks', [ 'title' => $title, 'target_page' => $target, 'prompt' => $prompt, 'draft' => $draft, 'assets' => $assets, 'status' => 'queued', 'sort_order' => $order, ]); $this->json(['ok' => true, 'id' => (int) \Db::getValue('SELECT LAST_INSERT_ID()')]); } public function list(): void { $this->requireAdmin(); $rows = \Db::select( 'SELECT task_id, title, target_page, status, result, created_at, finished_at FROM cja_tasks ORDER BY sort_order DESC, task_id DESC LIMIT 100' ); $out = array_map(function ($r) { $result = json_decode($r['result'] ?? '', true) ?: []; return [ 'id' => (int) $r['task_id'], 'title' => $r['title'], 'target' => $r['target_page'], 'status' => $r['status'], 'commit' => $result['commit'] ?? '', 'summary' => $result['summary'] ?? '', 'live' => $result['live'] ?? '', 'error' => $result['error'] ?? '', 'created' => $r['created_at'], 'finished' => $r['finished_at'], ]; }, $rows); $this->json(['tasks' => $out]); } public function get(): void { $this->requireAdmin(); $id = (int) ($_GET['id'] ?? 0); $r = \Db::getRow('SELECT * FROM cja_tasks WHERE task_id = ?', [$id]); if (!$r) { $this->json(null, 404, ['code' => 'not_found', 'message' => 'No such task.']); } $r['assets'] = json_decode($r['assets'] ?? '{}', true) ?: []; $r['result'] = json_decode($r['result'] ?? '{}', true) ?: []; $this->json($r); } /** Cancel a task that has not started yet. */ public function cancel(): void { $this->requireAdmin(); $in = json_decode((string) file_get_contents('php://input'), true) ?: []; $id = (int) ($in['id'] ?? 0); $status = (string) \Db::getValue('SELECT status FROM cja_tasks WHERE task_id = ?', [$id]); if ($status === '') { $this->json(null, 404, ['code' => 'not_found', 'message' => 'No such task.']); } if ($status !== 'queued') { $this->json(null, 409, ['code' => 'not_queued', 'message' => 'Only queued tasks can be canceled.']); } \Db::update('cja_tasks', ['status' => 'canceled', 'finished_at' => date('Y-m-d H:i:s')], 'task_id = ?', [$id]); $this->json(['ok' => true]); } }