route('/knowledge/sources', 'GET', 'knowledge.manage', [$this, 'list']); $this->route('/knowledge/sources/(?P\d+)/text', 'GET', 'knowledge.manage', [$this, 'inspect'], ['page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 10000, 'default' => 1]]); // Phase 96: new sources are Private unless the caller may publish AND says so explicitly (confirm_publish); a source becomes public // later only through PATCH with the confirmation token. The same rules apply to the admin screens (see SourceRepository::publish()). $this->route('/knowledge/sources', 'POST', 'knowledge.manage', [$this, 'addPost'], [ 'post_id' => ['type' => 'integer', 'required' => true, 'minimum' => 1], 'visibility' => ['type' => 'string', 'enum' => ['public', 'private'], 'default' => 'private'], 'confirm_publish' => ['type' => 'boolean', 'default' => false], ]); $this->route('/knowledge/uploads', 'POST', 'knowledge.manage', [$this, 'upload'], [ 'visibility' => ['type' => 'string', 'enum' => ['public', 'private'], 'default' => 'private'], 'confirm_publish' => ['type' => 'boolean', 'default' => false], 'title' => ['type' => 'string', 'maxLength' => 191], ]); $this->route('/knowledge/sources/(?P\d+)', 'PATCH', 'knowledge.manage', [$this, 'update'], [ 'visibility' => ['type' => 'string', 'enum' => ['public', 'private'], 'required' => true], 'confirm_publish' => ['type' => 'boolean', 'default' => false], 'confirmation_token' => ['type' => 'string', 'maxLength' => 100, 'default' => ''], ]); $this->route('/knowledge/sources/(?P\d+)', 'DELETE', 'knowledge.manage', [$this, 'delete']); $this->route('/knowledge/sources/(?P\d+)/reindex', 'POST', 'knowledge.manage', [$this, 'reindex']); $this->route('/knowledge/sources/(?P\d+)/replace', 'POST', 'knowledge.manage', [$this, 'replace']); $this->route('/bots/(?P\d+)/sources', 'POST', 'bots.manage', [$this, 'attach'], [ 'source_id' => ['type' => 'integer', 'required' => true, 'minimum' => 1], ]); $this->route('/bots/(?P\d+)/sources/(?P\d+)', 'DELETE', 'bots.manage', [$this, 'detach']); } public function list(): \WP_REST_Response { return new \WP_REST_Response($this->plugin->sources()->list(get_current_blog_id())); } public function inspect(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $result = $this->plugin->sources()->inspect(get_current_blog_id(), (int) $request['id'], (int) $request['page']); return $result === null ? Errors::response('not_found', 404) : new \WP_REST_Response($result, 200, ['Cache-Control' => 'private, no-store']); } public function addPost(\WP_REST_Request $r): \WP_REST_Response|\WP_Error { $post = get_post((int) $r['post_id']); if (!$post instanceof \WP_Post || !KnowledgeModule::isPublic($post)) { return new \WP_Error('aiwend_not_published', __('Only published, public, non-password-protected content can be added.', 'aiwend'), ['status' => 400]); } $site = get_current_blog_id(); $visibility = $this->creationVisibility($r); if ($visibility instanceof \WP_Error) { return $visibility; } $existed = $this->plugin->sources()->originExists($site, 'post', (string) $post->ID); $id = $this->plugin->sources()->upsert($site, 'post', (string) $post->ID, get_the_title($post), (string) get_permalink($post), $visibility, get_current_user_id()); $this->module()->queueIngest($id, KnowledgeModule::postHash($post)); $this->audit('knowledge.add_post', "source:$id"); if (!$existed) { $this->auditVisibility($id, 'new', $visibility, 'created'); } return new \WP_REST_Response($this->plugin->sources()->find($site, $id), 202); } public function upload(\WP_REST_Request $r): \WP_REST_Response|\WP_Error { $upload = UploadReader::read($r->get_file_params()['file'] ?? null); if (is_string($upload)) { return self::uploadError($upload); } $name = $upload['name']; $type = $upload['type']; $content = $upload['content']; $site = get_current_blog_id(); $visibility = $this->creationVisibility($r); if ($visibility instanceof \WP_Error) { return $visibility; } $title = $r['title'] !== null && $r['title'] !== '' ? sanitize_text_field((string) $r['title']) : $name; // Each upload is its own source; the reference is random, so names never collide. $id = $this->plugin->sources()->upsert($site, 'upload', $name . ':' . wp_generate_password(12, false), $title, null, $visibility, get_current_user_id()); $hash = $this->plugin->sources()->putBlob($site, $id, $type, $content); $this->module()->queueIngest($id, $hash); $this->audit('knowledge.upload', "source:$id"); $this->auditVisibility($id, 'new', $visibility, 'created'); return new \WP_REST_Response($this->plugin->sources()->find($site, $id), 202); } public function update(\WP_REST_Request $r): \WP_REST_Response|\WP_Error { $site = get_current_blog_id(); $id = (int) $r['id']; $sources = $this->plugin->sources(); $source = $sources->find($site, $id); if ($source === null || in_array($source['status'], ['deleting', 'deleted'], true)) { return Errors::response('not_found', 404); } // Becoming private is always allowed for whoever manages knowledge, and takes effect for the next retrieval. if ((string) $r['visibility'] === 'private') { if (!$sources->setVisibility($site, $id, 'private')) { return Errors::response('not_found', 404); } if ($source['visibility'] !== 'private') { $this->auditVisibility($id, (string) $source['visibility'], 'private', 'changed'); } return new \WP_REST_Response($sources->find($site, $id)); } // Becoming public is a publication: its own capability, a confirmation that names the affected assistants, a short-lived token. $principal = $this->plugin->currentPrincipal(); if (!$this->plugin->policy->check($principal, 'knowledge.publish')->allowed) { $this->auditVisibility($id, (string) $source['visibility'], (string) $source['visibility'], 'denied-no-capability'); return Errors::response('forbidden', 403); } if ($source['visibility'] === 'public') { return new \WP_REST_Response($source); } $user = get_current_user_id(); $secret = wp_salt('auth'); $token = (string) $r['confirmation_token']; if ($token === '' || $r['confirm_publish'] !== true) { $affected = $sources->affectedAssistants($site, $id); $issued = PublishToken::issue($secret, $site, $id, $user, (int) $source['updated_at'], array_column($affected['active'], 'id'), $this->plugin->clock->now()); return new \WP_Error('aiwend_publish_confirmation_required', Errors::message('publish_confirmation_required'), [ 'status' => 409, 'source' => ['id' => $id, 'title' => $source['title'], 'visibility' => $source['visibility']], 'affected_assistants' => $affected['active'], 'other_attached_assistants' => count($affected['other']), 'confirmation_token' => $issued, 'expires_in' => PublishToken::TTL, 'how_to_confirm' => 'Repeat this request with confirm_publish=true and this confirmation_token.', ]); } $now = $this->plugin->clock->now(); $affectedIds = []; $result = $sources->publish($site, $id, function (array $row, array $ids) use ($token, $secret, $site, $id, $user, $now, &$affectedIds): bool { $affectedIds = $ids; return PublishToken::valid($token, $secret, $site, $id, $user, (int) $row['updated_at'], $ids, $now); }); $this->auditVisibility($id, 'private', $result === 'published' || $result === 'already_public' ? 'public' : 'private', $result === 'published' ? 'confirmed' : 'refused-' . $result, $affectedIds); return match ($result) { 'published', 'already_public' => new \WP_REST_Response($sources->find($site, $id)), 'not_found' => Errors::response('not_found', 404), default => new \WP_Error('aiwend_publish_stale', Errors::message('publish_stale'), ['status' => 409]), }; } /** Visibility for a new source: private unless the caller may publish and explicitly confirms. @return string|\WP_Error */ private function creationVisibility(\WP_REST_Request $r): string|\WP_Error { if ((string) $r['visibility'] !== 'public') { return 'private'; } if (!$this->plugin->policy->check($this->plugin->currentPrincipal(), 'knowledge.publish')->allowed) { return Errors::response('forbidden', 403); } if ($r['confirm_publish'] !== true) { return new \WP_Error('aiwend_publish_confirmation_required', Errors::message('publish_confirmation_required'), ['status' => 409, 'how_to_confirm' => 'Repeat this request with confirm_publish=true.']); } return 'public'; } /** Ids and states only: never a title, text or request body. @param list $assistantIds */ private function auditVisibility(int $sourceId, string $from, string $to, string $how, array $assistantIds = []): void { $p = $this->plugin->currentPrincipal(); $this->plugin->audit->record($p->siteId, $p->actorRef(), 'knowledge.visibility', 'source:' . $sourceId, $to, null, 'rest ' . $how . ' ' . \Aiwend\Knowledge\SourceRepository::visibilityDetail($from, $to, $assistantIds)); } public function delete(\WP_REST_Request $r): \WP_REST_Response|\WP_Error { $id = (int) $r['id']; $source = $this->plugin->sources()->find(get_current_blog_id(), $id); if ($source === null || $source['status'] === 'deleted') { return Errors::response('not_found', 404); } $this->module()->deleteSource($id); $this->audit('knowledge.delete', "source:$id"); return new \WP_REST_Response(['status' => 'deleting'], 202); } public function reindex(\WP_REST_Request $r): \WP_REST_Response|\WP_Error { $site = get_current_blog_id(); $id = (int) $r['id']; $source = $this->plugin->sources()->find($site, $id); if ($source === null || in_array($source['status'], ['deleting', 'deleted'], true)) { return Errors::response('not_found', 404); } $this->module()->queueIngest($id, 'manual', true); return new \WP_REST_Response(['status' => 'queued'], 202); } /** Replaces the file of an existing upload source (multipart field "file"): same source, assistants and visibility; new version. */ public function replace(\WP_REST_Request $r): \WP_REST_Response|\WP_Error { $site = get_current_blog_id(); $id = (int) $r['id']; $source = $this->plugin->sources()->find($site, $id); if ($source === null || $source['origin'] !== 'upload' || in_array($source['status'], ['deleting', 'deleted'], true)) { return Errors::response('not_found', 404); } $upload = UploadReader::read($r->get_file_params()['file'] ?? null); if (is_string($upload)) { return self::uploadError($upload); } $name = $upload['name']; $content = $upload['content']; if (!$this->module()->replaceUpload($id, $name, $upload['type'], $content, null)) { return Errors::response('not_found', 404); } $this->audit('knowledge.replace', "source:$id"); return new \WP_REST_Response($this->plugin->sources()->find($site, $id), 202); } public function attach(\WP_REST_Request $r): \WP_REST_Response|\WP_Error { $site = get_current_blog_id(); $source = $this->plugin->sources()->find($site, (int) $r['source_id']); if ($this->plugin->bots->find($site, (int) $r['bot']) === null || $source === null || in_array($source['status'], ['deleting', 'deleted'], true)) { return Errors::response('not_found', 404); } $this->plugin->sources()->attachToBot($site, (int) $r['bot'], (int) $r['source_id']); $this->audit('bots.attach_source', 'bot:' . (int) $r['bot']); return new \WP_REST_Response(['attached' => true], 201); } public function detach(\WP_REST_Request $r): \WP_REST_Response { $this->plugin->sources()->detachFromBot(get_current_blog_id(), (int) $r['bot'], (int) $r['id']); $this->audit('bots.detach_source', 'bot:' . (int) $r['bot']); return new \WP_REST_Response(['attached' => false]); } private function module(): KnowledgeModule { $m = $this->plugin->modules->get('knowledge'); assert($m instanceof KnowledgeModule); return $m; } /** @param callable(\WP_REST_Request): mixed $callback @param array $args */ /** The one mapping from an UploadReader error code to a REST error. */ private static function uploadError(string $code): \WP_Error { return match ($code) { UploadReader::SIZE => new \WP_Error('aiwend_upload_size', __('The file is larger than the 2 MB limit.', 'aiwend'), ['status' => 413]), UploadReader::PDF_UNAVAILABLE => new \WP_Error('aiwend_upload_pdf_unavailable', __('PDF files are not supported in this edition of Aiwend. Upload a TXT or Markdown file, or add the content as a post or page.', 'aiwend'), ['status' => 415]), UploadReader::TYPE => new \WP_Error('aiwend_upload_type', PdfSupport::available() ? __('Only TXT, Markdown and PDF files are supported.', 'aiwend') : __('Only TXT and Markdown files are supported.', 'aiwend'), ['status' => 415]), UploadReader::BINARY => new \WP_Error('aiwend_upload_binary', PdfSupport::available() ? __('This file could not be read as text. If it is a text file, use Save As in your editor, choose UTF-8 as the encoding and upload it again, or upload a PDF document.', 'aiwend') : __('This file could not be read as text. If it is a text file, use Save As in your editor, choose UTF-8 as the encoding and upload it again.', 'aiwend'), ['status' => 415]), default => new \WP_Error('aiwend_upload', PdfSupport::available() ? __('Upload a TXT, Markdown or PDF file.', 'aiwend') : __('Upload a TXT or Markdown file.', 'aiwend'), ['status' => 400]), }; } private function route(string $path, string $method, string $operation, callable $callback, array $args = []): void { register_rest_route(self::NS, $path, [ 'methods' => $method, 'callback' => $callback, 'args' => $args, 'permission_callback' => function () use ($operation): bool|\WP_Error { $decision = $this->plugin->policy->check($this->plugin->currentPrincipal(), $operation); return $decision->allowed ? true : Errors::response($decision->code, $decision->httpStatus()); }, ]); } private function audit(string $action, string $object): void { $p = $this->plugin->currentPrincipal(); $this->plugin->audit->record($p->siteId, $p->actorRef(), $action, $object, 'ok'); } }