prefix . 'sarcio_requests'; } public static function schema(string $collate): string { $table = self::table(); return "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, public_id varchar(32) NOT NULL, user_id bigint(20) unsigned NOT NULL, title varchar(200) NOT NULL DEFAULT '', changes longtext NULL, status varchar(20) NOT NULL DEFAULT 'pending', note text NULL, created_at datetime NOT NULL, decided_by bigint(20) unsigned NULL, decided_at datetime NULL, results longtext NULL, origin varchar(20) NOT NULL DEFAULT 'site', PRIMARY KEY (id), UNIQUE KEY public_id (public_id), KEY status_created (status, created_at) ) {$collate};"; } /** Whether applying is kept to administrators on this site. */ public static function approvalRequired(): bool { return (string) Options::get('assistant_apply') === 'admins'; } /** Whether the signed-in user may apply changes here. */ public static function canApply(): bool { return !self::approvalRequired() || current_user_can(self::CAPABILITY); } /** * Turns the active preview into a request and ends the preview. * * @param array> $previews what each staged change showed, by id: summary, before, after * @return array{ok:bool,error?:string,request?:array} */ public static function fromStage(string $title, array $previews, string $origin = self::ORIGIN_SITE): array { $changes = Stage::changes(); if (!Stage::active() || $changes === []) { return ['error' => __('There is nothing in the preview to send.', 'sarcio'), 'ok' => false]; } $items = []; foreach ($changes as $change) { $id = (string) $change['id']; $preview = is_array($previews[$id] ?? null) ? $previews[$id] : []; $items[] = [ 'ability' => (string) $change['ability'], 'after' => mb_substr(sanitize_textarea_field((string) ($preview['after'] ?? '')), 0, 20000), 'args' => $change['args'], 'before' => mb_substr(sanitize_textarea_field((string) ($preview['before'] ?? '')), 0, 20000), 'id' => $id, 'label' => Catalog::label((string) $change['ability']), 'summary' => mb_substr(sanitize_text_field((string) ($preview['summary'] ?? '')), 0, 300), ]; } if ($origin === self::ORIGIN_AGENCY) { foreach ($items as $item) { if (!Remote::allowsChange($item['ability'], is_array($item['args']) ? $item['args'] : [])) { return ['error' => __('That is not something an agency can ask for on this site.', 'sarcio'), 'ok' => false]; } } } $reviewToken = Review::tokenForStage(); $created = self::create($title, $items, $origin, $reviewToken); if ($created['ok']) { Stage::clear((string) Stage::token()); } return $created; } /** * @param array $changeIds * @return array{ok:bool,error?:string,request?:array} */ public static function fromAgencyUndo(array $changeIds): array { $items = []; foreach (array_reverse($changeIds) as $changeId) { $args = ['id' => (int) $changeId]; if (!Remote::allowsChange(Remote::UNDO, $args)) { continue; } $preview = Catalog::preview(Remote::UNDO, $args); if (!$preview['ok']) { continue; } $items[] = [ 'ability' => Remote::UNDO, 'after' => mb_substr(sanitize_textarea_field((string) ($preview['after'] ?? '')), 0, 20000), 'args' => $args, 'before' => mb_substr(sanitize_textarea_field((string) ($preview['before'] ?? '')), 0, 20000), 'id' => 'undo-' . (int) $changeId, 'label' => Catalog::label(Remote::UNDO), 'summary' => mb_substr(sanitize_text_field((string) ($preview['summary'] ?? '')), 0, 300), ]; } if ($items === []) { return ['error' => __('None of those changes can be undone on this site any more.', 'sarcio'), 'ok' => false]; } return self::create(__('Undo changes your agency made', 'sarcio'), $items, self::ORIGIN_AGENCY, null); } /** * @param array> $items * @return array{ok:bool,error?:string,request?:array} */ private static function create(string $title, array $items, string $origin, ?string $reviewToken): array { global $wpdb; $publicId = bin2hex(random_bytes(8)); if ($reviewToken !== null) { $reviews = get_option(self::REVIEWS_OPTION, []); $reviews = is_array($reviews) ? array_slice($reviews, -200, null, true) : []; $reviews[$publicId] = $reviewToken; update_option(self::REVIEWS_OPTION, $reviews, false); } $title = mb_substr(sanitize_text_field($title), 0, 200); if ($title === '') { $title = $items[0]['summary'] !== '' ? $items[0]['summary'] : $items[0]['label']; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- the plugin's own table. $wpdb->insert(self::table(), [ 'changes' => (string) wp_json_encode($items), 'created_at' => current_time('mysql', true), 'origin' => $origin === self::ORIGIN_AGENCY ? self::ORIGIN_AGENCY : self::ORIGIN_SITE, 'public_id' => $publicId, 'status' => 'pending', 'title' => $title, 'user_id' => get_current_user_id(), ], ['%s', '%s', '%s', '%s', '%s', '%s', '%d']); $request = self::get($publicId); if ($request === null) { return ['error' => __('The request could not be saved.', 'sarcio'), 'ok' => false]; } self::prune(); self::announce($request); self::notify($request); return ['ok' => true, 'request' => self::describe($request)]; } /** @return array|null */ public static function get(string $publicId): ?array { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the plugin's own table. $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE public_id = %s", $publicId), ARRAY_A); return is_array($row) ? self::fromRow($row) : null; } /** * Pending first, newest first; an administrator sees every request, anyone * else only their own. * * @return array> */ public static function list(int $limit = 50): array { global $wpdb; $table = self::table(); $limit = max(1, min(200, $limit)); if (current_user_can(self::CAPABILITY)) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the plugin's own table. $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} ORDER BY (status = 'pending') DESC, id DESC LIMIT %d", $limit), ARRAY_A); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the plugin's own table. $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE user_id = %d ORDER BY (status = 'pending') DESC, id DESC LIMIT %d", get_current_user_id(), $limit), ARRAY_A); } return array_map(static fn (array $row): array => self::describe(self::fromRow($row)), is_array($rows) ? $rows : []); } public static function pendingCount(): int { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the plugin's own table. return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE status = 'pending'"); } /** * Applies every change of a pending request as the signed-in * administrator, in order; one that no longer fits is reported and the * rest still run, so a request is never half-lost. */ public static function apply(string $publicId): array { if (!current_user_can(self::CAPABILITY)) { return ['error' => __('Only an administrator can apply a request.', 'sarcio'), 'ok' => false]; } $request = self::get($publicId); if ($request === null || $request['status'] !== 'pending') { return ['error' => __('That request is no longer waiting.', 'sarcio'), 'ok' => false]; } if (!self::claim($publicId, 'applying')) { return ['error' => __('Someone else is applying that request.', 'sarcio'), 'ok' => false]; } $results = []; $applied = 0; foreach ($request['changes'] as $change) { $result = $request['origin'] === self::ORIGIN_AGENCY && !Remote::allowsChange((string) $change['ability'], (array) $change['args']) ? ['error' => __('That is not something an agency can ask for on this site.', 'sarcio'), 'ok' => false] : Catalog::run((string) $change['ability'], (array) $change['args']); $one = ['id' => $change['id'], 'ok' => (bool) $result['ok']]; if ($result['ok']) { $applied++; $one['changeId'] = (int) ($result['changeId'] ?? 0); } else { $one['error'] = (string) ($result['error'] ?? ''); } $results[] = $one; } $status = $applied === 0 ? 'failed' : ($applied < count($request['changes']) ? 'partial' : 'applied'); self::finish($publicId, $status, $results, ''); $request = self::get($publicId); if ($request !== null) { self::report($request, $status === 'failed' ? 'DECLINED' : 'APPLIED'); self::notifyRequester($request); } return ['ok' => $applied > 0, 'request' => $request === null ? null : self::describe($request), 'results' => $results]; } public static function decline(string $publicId, string $note): array { if (!current_user_can(self::CAPABILITY)) { return ['error' => __('Only an administrator can decline a request.', 'sarcio'), 'ok' => false]; } $request = self::get($publicId); if ($request === null || $request['status'] !== 'pending') { return ['error' => __('That request is no longer waiting.', 'sarcio'), 'ok' => false]; } if (!self::claim($publicId, 'declined')) { return ['error' => __('That request was just decided by someone else.', 'sarcio'), 'ok' => false]; } self::finish($publicId, 'declined', [], mb_substr(sanitize_textarea_field($note), 0, 1000)); $request = self::get($publicId); if ($request !== null) { self::report($request, 'DECLINED'); self::notifyRequester($request); } return ['ok' => true, 'request' => $request === null ? null : self::describe($request)]; } /** The requester withdrawing their own pending request. */ public static function withdraw(string $publicId): array { $request = self::get($publicId); if ($request === null || $request['status'] !== 'pending') { return ['error' => __('That request is no longer waiting.', 'sarcio'), 'ok' => false]; } if ((int) $request['userId'] !== get_current_user_id() && !current_user_can(self::CAPABILITY)) { return ['error' => __('Only the person who sent this can withdraw it.', 'sarcio'), 'ok' => false]; } if (!self::claim($publicId, 'withdrawn')) { return ['error' => __('That request was just decided.', 'sarcio'), 'ok' => false]; } self::finish($publicId, 'withdrawn', [], ''); $request = self::get($publicId); if ($request !== null) { self::report($request, 'DECLINED'); } return ['ok' => true]; } /** @param array $request */ public static function describe(array $request, bool $withChanges = true): array { $out = [ 'createdAt' => (string) $request['createdAt'], 'decidedAt' => $request['decidedAt'], 'decidedBy' => $request['decidedBy'] > 0 ? self::userName((int) $request['decidedBy']) : '', 'id' => (string) $request['publicId'], 'mine' => (int) $request['userId'] === get_current_user_id(), 'note' => (string) $request['note'], 'origin' => (string) $request['origin'], 'requestedBy' => self::requester($request), 'results' => $request['results'], 'status' => (string) $request['status'], 'title' => (string) $request['title'], ]; $reviews = get_option(self::REVIEWS_OPTION, []); $reviewToken = is_array($reviews) ? ($reviews[(string) $request['publicId']] ?? null) : null; $out['notes'] = is_string($reviewToken) ? (Review::notes($reviewToken)['notes'] ?? []) : []; if ($withChanges) { $out['changes'] = array_map(static fn (array $change): array => [ 'after' => $change['after'], 'before' => $change['before'], 'id' => $change['id'], 'label' => $change['label'], 'summary' => $change['summary'], ], $request['changes']); } return $out; } /** @param array $request */ public static function requester(array $request): string { if (($request['origin'] ?? self::ORIGIN_SITE) === self::ORIGIN_AGENCY) { return Brand::custom() ? sprintf( /* translators: %s: the agency's name. */ __('%s, through Across sites', 'sarcio'), Brand::name(), ) : __('Your agency, through Across sites', 'sarcio'); } return self::userName((int) $request['userId']); } public static function userName(int $userId): string { $user = $userId > 0 && function_exists('get_userdata') ? get_userdata($userId) : null; return is_object($user) && isset($user->display_name) ? (string) $user->display_name : ''; } /** A guarded status move; false when another request got there first. */ private static function claim(string $publicId, string $to): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own table. $moved = $wpdb->update(self::table(), ['status' => $to], ['public_id' => $publicId, 'status' => 'pending'], ['%s'], ['%s', '%s']); return (int) $moved === 1; } /** @param array> $results */ private static function finish(string $publicId, string $status, array $results, string $note): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own table. $wpdb->update(self::table(), [ 'decided_at' => current_time('mysql', true), 'decided_by' => get_current_user_id(), 'note' => $note, 'results' => (string) wp_json_encode($results), 'status' => $status, ], ['public_id' => $publicId], ['%s', '%d', '%s', '%s', '%s'], ['%s']); } private static function prune(): void { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the plugin's own table. $ids = $wpdb->get_col($wpdb->prepare("SELECT id FROM {$table} WHERE status <> 'pending' ORDER BY id DESC LIMIT %d, 1000", self::KEEP_DECIDED)); if (is_array($ids) && $ids !== []) { $list = implode(',', array_map('intval', $ids)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the plugin's own table; the ids are integers. $wpdb->query("DELETE FROM {$table} WHERE id IN ({$list})"); } } /** @return array */ private static function fromRow(array $row): array { $changes = json_decode((string) ($row['changes'] ?? ''), true); $results = json_decode((string) ($row['results'] ?? ''), true); return [ 'changes' => is_array($changes) ? array_values(array_filter($changes, 'is_array')) : [], 'createdAt' => (string) $row['created_at'], 'decidedAt' => $row['decided_at'] ?? null, 'decidedBy' => (int) ($row['decided_by'] ?? 0), 'id' => (int) $row['id'], 'note' => (string) ($row['note'] ?? ''), 'origin' => (string) ($row['origin'] ?? self::ORIGIN_SITE) === self::ORIGIN_AGENCY ? self::ORIGIN_AGENCY : self::ORIGIN_SITE, 'publicId' => (string) $row['public_id'], 'results' => is_array($results) ? $results : [], 'status' => (string) $row['status'], 'title' => (string) $row['title'], 'userId' => (int) $row['user_id'], ]; } /** The card for the workspace Inbox, best effort: the request stands without it. */ private static function announce(array $request): void { self::sarcio('', [ 'adminUrl' => RequestsPage::url($request['publicId']), 'externalId' => (string) $request['publicId'], 'requestedBy' => self::requester($request), 'summary' => array_values(array_map(static fn (array $change): string => $change['summary'] !== '' ? $change['summary'] : $change['label'], $request['changes'])), 'title' => (string) $request['title'], ]); } private static function report(array $request, string $status): void { self::sarcio('/' . rawurlencode((string) $request['publicId']) . '/decision', [ 'decidedBy' => self::userName((int) $request['decidedBy']) ?: __('an administrator', 'sarcio'), 'status' => $status, ]); } /** @param array $body */ private static function sarcio(string $path, array $body): void { $siteKey = (string) Options::get('site_key'); $secret = (string) Options::get('sidecar_key'); if ($siteKey === '' || $secret === '') { return; } wp_remote_post( sprintf('%s/api/sites/%s/change-requests%s', Options::apiUrl(), rawurlencode($siteKey), $path), [ 'blocking' => false, 'body' => (string) wp_json_encode($body), 'headers' => ['Authorization' => 'Bearer ' . $secret, 'Content-Type' => 'application/json'], 'redirection' => 0, 'timeout' => 5, ], ); \Sarcio\WordPress\InboxSummary::forget(); } /** Mails the site's administrators (and the report recipients) about a new request. */ private static function notify(array $request): void { $to = array_values(array_unique(array_filter(array_merge( [(string) get_option('admin_email')], Report::recipients(), )))); if ($to === []) { return; } $lines = array_map(static fn (array $change): string => '• ' . ($change['summary'] !== '' ? $change['summary'] : $change['label']), $request['changes']); wp_mail( $to, sprintf( /* translators: 1: who asked, 2: the site name. */ __('%1$s asked for a change on %2$s', 'sarcio'), self::requester($request), html_entity_decode((string) get_bloginfo('name'), ENT_QUOTES | ENT_HTML5, 'UTF-8'), ), sprintf( /* translators: 1: the request title, 2: the changes, 3: the link, 4: the brand name. */ __("%1\$s\n\n%2\$s\n\nLook at the preview and apply or decline it here:\n%3\$s\n\nSent by %4\$s.", 'sarcio'), $request['origin'] === self::ORIGIN_AGENCY ? (string) $request['title'] . "\n\n" . __('Your agency wrote this change in Sarcio. Nothing on the site changes until an administrator applies it.', 'sarcio') : (string) $request['title'], implode("\n", $lines), RequestsPage::url((string) $request['publicId']), Brand::name(), ), ); } private static function notifyRequester(array $request): void { $user = function_exists('get_userdata') ? get_userdata((int) $request['userId']) : null; $email = is_object($user) && isset($user->user_email) ? (string) $user->user_email : ''; if ($email === '') { return; } $applied = in_array($request['status'], ['applied', 'partial'], true); wp_mail( $email, $applied ? sprintf( /* translators: %s: the request title. */ __('Your change was made: %s', 'sarcio'), (string) $request['title'], ) : sprintf( /* translators: %s: the request title. */ __('Your change was not made: %s', 'sarcio'), (string) $request['title'], ), trim(($applied ? __('It is live on the site now.', 'sarcio') : __('It was declined.', 'sarcio')) . ($request['note'] !== '' ? "\n\n" . $request['note'] : '') . "\n\n" . sprintf( /* translators: %s: the brand name. */ __('Sent by %s.', 'sarcio'), Brand::name(), )), ); } }