generation request -> proposed content -> administrator review -> save as draft. * * Nothing is published: a saved proposal is a new post in the "draft" status, created only when the signed-in user may create that post * type. The proposal waits in one user-meta record per user and site (deleted on save or discard, ignored after a day) so a page refresh * never repeats a paid request. The brief is kept the same way, only so a failed request does not lose what was typed. */ final class ContentStudioAdmin { public const SLUG = 'aiwend-content-studio'; public function __construct(private Plugin $plugin) { } public function register(): void { add_action('admin_menu', function (): void { add_submenu_page('aiwend', __('Content Studio', 'aiwend'), __('Content Studio', 'aiwend'), Capabilities::USE_CONTENT_STUDIO, self::SLUG, [$this, 'render']); }); add_action('admin_post_aiwend_cs_generate', [$this, 'generate']); add_action('admin_post_aiwend_cs_save', [$this, 'save']); add_action('admin_post_aiwend_cs_discard', [$this, 'discard']); add_action('admin_enqueue_scripts', function (): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation parameter; no state is changed if (sanitize_key(Input::get('page')) === self::SLUG) { wp_add_inline_script('aiwend-admin', self::busyScript()); } }, 20); } /** Disables the Generate button after submit (so a double click cannot send twice) and tells the user the request can take a while. */ private static function busyScript(): string { return "document.addEventListener('submit',function(e){var f=e.target;if(!f.hasAttribute||!f.hasAttribute('data-aiwend-busy')){return;}" . "var b=f.querySelector('button[type=submit]');var s=f.querySelector('[data-aiwend-busy-text]');" . "setTimeout(function(){if(b){b.disabled=true;}if(s){s.hidden=false;}f.setAttribute('aria-busy','true');},0);});"; } // ------------------------------------------------------------------ small helpers private function metaKey(string $what): string { return ContentStudioRetention::key($what, get_current_blog_id()); } private static function typeCapability(string $type): ?string { if (!in_array($type, ContentStudio::TYPES, true) || !post_type_exists($type)) { return null; } $object = get_post_type_object($type); return $object === null ? null : (string) $object->cap->create_posts; } private function mayUse(string $type): bool { $cap = self::typeCapability($type); return $cap !== null && current_user_can(Capabilities::USE_CONTENT_STUDIO) && current_user_can($cap); } private static function requestId(string $token): string { return 'cs_' . substr(hash_hmac('sha256', get_current_blog_id() . '|' . get_current_user_id() . '|' . $token, wp_salt('auth')), 0, 24); } /** @return array|null */ private function proposal(): ?array { $p = get_user_meta(get_current_user_id(), $this->metaKey('proposal'), true); if (ContentStudioRetention::expired($p, 'proposal', time()) || !isset($p['request_id'], $p['type'])) { return null; } return $p; } private function back(string $code, array $args = []): never { wp_safe_redirect(add_query_arg(array_merge(['page' => self::SLUG, 'cs' => $code], $args), admin_url('admin.php'))); exit; } /** * Usable choices: "demo" (simulated, only where the simulated demo is on) and each verified connection's chat models. * * @return list */ private function choices(): array { $site = get_current_blog_id(); $out = []; $repo = $this->plugin->connections(); foreach ($repo === null ? [] : $repo->list($site) as $c) { if ($c['status'] !== 'verified') { continue; } $id = (int) $c['id']; foreach ($this->plugin->ai()->pickerModels($id) as $m) { $out[] = ['value' => $id . ':' . $m['id'], 'label' => (string) $c['label'] . ' · ' . $m['id'], 'simulated' => false]; } } if ($this->plugin->mockProviderEnabled()) { $out[] = ['value' => 'demo', 'label' => __('Simulated demo (no AI provider, no charge)', 'aiwend'), 'simulated' => true]; } return $out; } // ------------------------------------------------------------------ handlers public function generate(): void { if (!current_user_can(Capabilities::USE_CONTENT_STUDIO)) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } check_admin_referer('aiwend_cs_generate'); $type = sanitize_key(Input::post('type')); if (!$this->mayUse($type)) { wp_die(esc_html__('You are not allowed to create this kind of content.', 'aiwend'), '', ['response' => 403]); } $token = isset($_POST['token']) && is_string($_POST['token']) ? sanitize_key(wp_unslash($_POST['token'])) : ''; // accepted only if it is exactly 24 lowercase hex characters (next line) if (preg_match('/^[a-f0-9]{24}$/', $token) !== 1) { $this->back('invalid'); } $requestId = self::requestId($token); $existing = $this->proposal(); if ($existing !== null && $existing['request_id'] === $requestId) { $this->back('proposal'); // the same submission arriving again: show what it produced, send nothing } // The brief is sanitized (a string only, unslashed, tags and control characters removed, line breaks kept, invalid UTF-8 dropped) // BEFORE it is kept for the "keep what I typed" retention or sent anywhere; ContentStudio::validBrief then checks length and content. $brief = isset($_POST['brief']) && is_string($_POST['brief']) ? sanitize_textarea_field(wp_unslash($_POST['brief'])) : ''; ContentStudioRetention::store(get_current_user_id(), 'brief', get_current_blog_id(), ['type' => $type, 'brief' => mb_substr($brief, 0, 4000), 'at' => time()]); $site = get_current_blog_id(); // Matched against a strict pattern and then checked against the site's own verified connections and models. $target = isset($_POST['target']) && is_string($_POST['target']) ? sanitize_text_field(wp_unslash($_POST['target'])) : ''; $ai = $this->plugin->ai(); $connectionId = null; if ($target === 'demo' && $this->plugin->mockProviderEnabled()) { $model = 'mock-1'; $adapter = new MockAdapter(true, 0); } elseif (preg_match('/^([1-9][0-9]{0,9}):(.{1,128})$/', $target, $m) === 1) { $connectionId = (int) $m[1]; $model = $m[2]; $connection = $this->plugin->connections()?->find($site, $connectionId); if ($connection === null || $connection['status'] !== 'verified' || !Providers::validModelIdFor((string) $connection['provider'], $model) || !$ai->modelKnown($connectionId, $model)) { $this->back('connection'); } try { $adapter = $ai->adapter($site, $connection); } catch (\Throwable) { $this->back('connection'); } } else { $this->back('connection'); } try { $result = (new ContentStudio($this->plugin->ledger, $this->plugin->audit))->generate($this->plugin->currentPrincipal(), $requestId, $type, $brief, $adapter, $model, $connectionId); } catch (ContentStudioException $e) { $this->back($e->reason === 'duplicate' && $this->proposal() !== null ? 'proposal' : self::noticeFor($e->reason)); } ContentStudioRetention::store(get_current_user_id(), 'proposal', get_current_blog_id(), [ 'request_id' => $requestId, 'type' => $type, 'title' => $result['title'], 'excerpt' => $result['excerpt'], 'body' => $result['body'], 'model' => $result['model'], 'simulated' => $target === 'demo', 'at' => time(), ]); ContentStudioRetention::forget(get_current_user_id(), 'brief', get_current_blog_id()); $this->back('proposal'); } /** Maps a service reason to a notice code; anything unknown becomes a generic failure (provider text is never shown). */ private static function noticeFor(string $reason): string { return match (true) { in_array($reason, ['invalid_brief', 'invalid_type', 'no_budget', 'budget_exceeded', 'paused', 'duplicate', 'outcome_uncertain', 'empty_output'], true) => $reason, $reason === 'provider_timeout' => 'timeout', $reason === 'provider_auth' => 'auth', $reason === 'provider_rate_limited' || $reason === 'provider_quota_exhausted' => 'rate', default => 'failed', }; } public function save(): void { if (!current_user_can(Capabilities::USE_CONTENT_STUDIO)) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } check_admin_referer('aiwend_cs_save'); $requestId = sanitize_text_field(Input::post('request_id')); $title = ContentFormatter::plainLine(sanitize_text_field(Input::post('title')), ContentFormatter::TITLE_MAX); $excerpt = mb_substr(sanitize_textarea_field(Input::post('excerpt')), 0, ContentFormatter::EXCERPT_MAX); $body = isset($_POST['body']) && is_string($_POST['body']) ? trim(wp_kses_post(wp_unslash($_POST['body']))) : ''; if (mb_strlen($body) > ContentFormatter::BODY_MAX * 2) { $body = trim(wp_kses_post(mb_substr($body, 0, ContentFormatter::BODY_MAX * 2))); // cut, then filtered again so a cut tag cannot survive } [$code, $id] = $this->createDraft($requestId, $title, $excerpt, $body); if ($code === 'forbidden') { wp_die(esc_html__('You are not allowed to create this kind of content.', 'aiwend'), '', ['response' => 403]); } $this->back($code, $code === 'saved' ? ['post' => $id] : []); } /** Content Studio's save needs transactional tables (InnoDB): its exactly-once guarantee rests on a row lock and on rollback. Anything else is refused, not guessed. */ private function transactionalStorage(): bool { global $wpdb; static $ok = null; if ($ok === null) { $tables = [$wpdb->posts, $wpdb->postmeta, $wpdb->options]; $rows = $wpdb->get_col($wpdb->prepare('SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (%s, %s, %s)', ...$tables)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders -- engine check, cached for the request; three table names, three placeholders $ok = count($rows) === 3 && array_unique(array_map('strtoupper', array_map('strval', $rows))) === ['INNODB']; } return $ok; } /** The first draft (lowest ID, any status but auto-draft, trash included so a replay never creates another) carrying this proposal's marker. Direct query: it must not read a cached result (it runs inside the save transaction). */ private function existingDraft(string $type, string $requestId): int { global $wpdb; return (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- uncached by design "SELECT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID WHERE m.meta_key = %s AND m.meta_value = %s AND p.post_type = %s AND p.post_status <> 'auto-draft' ORDER BY p.ID ASC LIMIT 1", ContentStudioRetention::REQUEST_META, $requestId, $type )); } /** * Creates the draft for the stored proposal. Every rule is enforced here, not only in the handler: the proposal must belong to the current * user and site and be unexpired, its post type (taken from the stored record) must still be creatable by the user now, and the * Content Studio capability is required. Returns [code, post id]; code is saved | stale | forbidden | empty | busy | unsupported | save_failed. * * Exactly-once: the insert runs inside ONE database transaction that first takes a row lock (SELECT ... FOR UPDATE) on a per-proposal * row in the options table. A second save of the same proposal waits for that lock, then finds the committed draft by its marker and * returns it; it can never reach wp_insert_post() while another writer is between the lock and the commit. There is no lease to * expire and no takeover: if the writer stalls, others wait (up to the server's lock wait timeout, then `busy`); if it dies, the * server rolls the whole transaction back, post and marker together, so a retry simply creates the draft. The post and its marker commit * together, so a draft without a marker cannot exist. A COMMIT whose result is unknown reports `busy`; the retry then returns the * draft if it was committed. Nothing is ever deleted. * * @return array{0:string, 1:int} */ public function createDraft(string $requestId, string $title, string $excerpt, string $body): array { global $wpdb; $proposal = $this->proposal(); if ($proposal === null || !hash_equals((string) $proposal['request_id'], $requestId)) { return ['stale', 0]; } $type = (string) $proposal['type']; if (!$this->mayUse($type)) { return ['forbidden', 0]; } $done = function (int $id): array { ContentStudioRetention::forget(get_current_user_id(), 'proposal', get_current_blog_id()); ContentStudioRetention::forget(get_current_user_id(), 'brief', get_current_blog_id()); return ['saved', $id]; }; if (($id = $this->existingDraft($type, $requestId)) > 0) { return $done($id); } if ($title === '' || $body === '') { return ['empty', 0]; } if (!$this->transactionalStorage()) { return ['unsupported', 0]; } $lock = ContentStudioRetention::LOCK_PREFIX . substr($requestId, 3, 24); // The serialization row: committed at once, harmless if it exists, removed again after the save and by the daily sweep. $wpdb->query($wpdb->prepare("INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", $lock, (string) time())); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- see above $wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the save transaction $wpdb->last_error = ''; $row = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s FOR UPDATE", $lock)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the row lock is the serialization point if ($row === null || $wpdb->last_error !== '') { // lock wait timeout, or the row vanished: nothing was written $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the save transaction return ['busy', 0]; } $code = 'save_failed'; $id = 0; try { if (($id = $this->existingDraft($type, $requestId)) > 0) { // committed by the previous holder of the lock $code = 'existing'; } else { $new = wp_insert_post([ 'post_type' => $type, 'post_status' => 'draft', // fixed: this plugin never publishes, schedules or privately publishes 'post_title' => $title, 'post_content' => $body, 'post_excerpt' => $excerpt, 'post_author' => get_current_user_id(), 'meta_input' => [ContentStudioRetention::REQUEST_META => $requestId], ], true); if (!is_wp_error($new) && (int) $new > 0) { $id = (int) $new; $code = 'created'; } } } catch (\Throwable) { $code = 'save_failed'; } if ($code === 'save_failed') { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the save transaction return ['save_failed', 0]; } if ($wpdb->query('COMMIT') === false) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the save transaction return ['busy', 0]; // unknown outcome: the retry finds the draft if it was committed } $wpdb->delete($wpdb->options, ['option_name' => $lock]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- best effort; waiters that already hold the row see the committed draft if ($code === 'created') { $this->plugin->audit->record(get_current_blog_id(), $this->plugin->currentPrincipal()->actorRef(), 'content_studio.save', $type . ':' . $id, 'ok', $requestId); } return $done($id); } public function discard(): void { if (!current_user_can(Capabilities::USE_CONTENT_STUDIO)) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } check_admin_referer('aiwend_cs_discard'); ContentStudioRetention::forget(get_current_user_id(), 'proposal', get_current_blog_id()); ContentStudioRetention::forget(get_current_user_id(), 'brief', get_current_blog_id()); $this->back('discarded'); } // ------------------------------------------------------------------ screen private static function notices(): array { return [ 'invalid_brief' => [__('Write a brief of at least 8 characters (up to 2,000) describing what the content should be about.', 'aiwend'), 'error'], 'invalid_type' => [__('Choose Post or Page.', 'aiwend'), 'error'], 'invalid' => [__('That form was not valid. Try again.', 'aiwend'), 'error'], 'connection' => [__('Choose one of the listed AI connections and models. If none is listed, check the connection under AI Connections.', 'aiwend'), 'error'], 'no_budget' => [__('Set a site usage limit first (Usage page). Content Studio only works within your usage limit.', 'aiwend'), 'error'], 'budget_exceeded' => [__('Your usage limit does not leave room for this request. Nothing was sent. Raise the limit on the Usage page or wait for the period to reset.', 'aiwend'), 'error'], 'paused' => [__('New AI requests are paused on the Usage page. Nothing was sent.', 'aiwend'), 'error'], 'duplicate' => [__('That request was already submitted. If it is still running, wait for it to finish and reload this page.', 'aiwend'), 'warn'], 'outcome_uncertain' => [__('The outcome of that request is not known, so it was not repeated and the amount held stays reserved until it is reviewed on the Usage page. You can write the brief again as a new request.', 'aiwend'), 'warn'], 'timeout' => [__('The AI provider did not answer in time. The request is recorded and was not repeated; the usage may be uncertain. You can try again.', 'aiwend'), 'error'], 'auth' => [__('The AI provider refused the connection’s key. Check the connection under AI Connections.', 'aiwend'), 'error'], 'rate' => [__('The AI provider is limiting requests or the provider account has no quota. Try again later.', 'aiwend'), 'error'], 'failed' => [__('The AI provider could not produce content. Nothing was saved. See Usage for the request status.', 'aiwend'), 'error'], 'empty_output' => [__('The AI returned no usable text. Nothing was saved. The request was still counted if the provider reported usage.', 'aiwend'), 'error'], 'stale' => [__('That proposal is no longer available. Generate it again.', 'aiwend'), 'warn'], 'empty' => [__('A title and some text are needed before the draft can be saved.', 'aiwend'), 'error'], 'busy' => [__('A save for this proposal is already running. Reload in a moment.', 'aiwend'), 'warn'], 'unsupported' => [__('Saving drafts needs database tables that support transactions (InnoDB). Nothing was saved.', 'aiwend'), 'error'], 'save_failed' => [__('WordPress could not save the draft. Nothing was published.', 'aiwend'), 'error'], 'discarded' => [__('The proposal was discarded.', 'aiwend'), 'success'], ]; } public function render(): void { if (!current_user_can(Capabilities::USE_CONTENT_STUDIO)) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } nocache_headers(); AdminView::open($this->plugin, self::SLUG, __('Content Studio', 'aiwend'), __('Ask the AI for a first draft of a post or page. You review and edit it, then save it as a WordPress draft. Nothing is ever published for you.', 'aiwend')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation parameter; no state is changed $code = sanitize_key(Input::get('cs')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation parameter; no state is changed $savedId = absint($_GET['post'] ?? 0); $notices = self::notices(); if ($code === 'saved' && $savedId > 0) { $this->savedNotice($savedId); } elseif (isset($notices[$code])) { echo '

' . esc_html($notices[$code][0]) . '

'; } $proposal = $this->proposal(); if ($proposal !== null && $this->mayUse((string) $proposal['type'])) { $this->review($proposal); } else { $this->briefForm(); } AdminView::close(); } private function savedNotice(int $postId): void { $post = get_post($postId); $ours = $post !== null && get_post_meta($postId, ContentStudioRetention::REQUEST_META, true) !== '' && current_user_can('edit_post', $postId); echo '

' . esc_html__('Draft saved.', 'aiwend') . ' ' . esc_html__('It is not published.', 'aiwend'); if ($ours) { echo ' ' . esc_html__('Edit the draft', 'aiwend') . ''; } echo '

'; } private function briefForm(): void { $site = get_current_blog_id(); $choices = $this->choices(); $balance = $this->plugin->ledger->balance($site, 'site'); $types = []; foreach (ContentStudio::TYPES as $t) { if ($this->mayUse($t)) { $types[$t] = $t === 'post' ? __('Post', 'aiwend') : __('Page', 'aiwend'); } } echo '

' . esc_html__('New draft', 'aiwend') . '

'; echo '

' . esc_html__('Your brief is sent to the AI connection you choose below, and only the brief: Aiwend does not send other posts, pages, comments or any visitor or customer data. In the simulated demo nothing leaves this site. AI text can be wrong or invented, so check every fact before you publish.', 'aiwend') . '

'; if ($choices === []) { echo '

' . esc_html__('There is no AI connection ready to use.', 'aiwend') . ' ' . esc_html__('Add or check a connection', 'aiwend') . '

'; return; } if ($balance === null || (int) ($balance['limit'] ?? 0) <= 0) { echo '

' . esc_html__('No usage limit is set, so nothing can be generated yet.', 'aiwend') . ' ' . esc_html__('Set a limit', 'aiwend') . '

'; return; } if ($this->plugin->ledger->isHalted()) { echo '

' . esc_html__('New AI requests are paused on the Usage page.', 'aiwend') . '

'; } if ($types === []) { echo '

' . esc_html__('Your account may not create posts or pages.', 'aiwend') . '

'; return; } $kept = get_user_meta(get_current_user_id(), $this->metaKey('brief'), true); $kept = ContentStudioRetention::expired($kept, 'brief', time()) ? [] : $kept; $token = bin2hex(random_bytes(12)); echo '
'; echo ''; echo ''; echo '

'; echo '

' . esc_html__('Requests count against your site usage limit.', 'aiwend') . '

'; echo '

' . esc_html__('Describe the subject, audience and tone. Up to 2,000 characters. Do not include passwords, personal data about customers, or anything you would not send to your AI provider.', 'aiwend') . '

'; echo '

'; } /** @param array $p */ private function review(array $p): void { $type = (string) $p['type']; echo '

' . esc_html__('Review the proposal', 'aiwend') . '

'; echo '

' . esc_html__('AI-generated proposal, not reviewed.', 'aiwend') . ' ' . esc_html(!empty($p['simulated']) ? __('This text is from the simulated demo, not a real AI.', 'aiwend') : sprintf( /* translators: %s: model name */ __('Generated by %s. Check the facts, names and wording before you publish.', 'aiwend'), (string) ($p['model'] ?? '') )) . '

'; echo '
'; echo ''; echo ''; echo '

' . esc_html($type === 'page' ? __('Will be saved as a Page draft.', 'aiwend') : __('Will be saved as a Post draft.', 'aiwend')) . '

'; echo '

'; echo '

'; echo '

' . esc_html__('Basic formatting only. WordPress removes anything unsafe when the draft is saved. You can refine it further in the editor.', 'aiwend') . '

'; echo '

'; echo '
'; echo ''; echo '

'; } }