fix(uploads): reclaim abandoned front-submit attachments
Mark new regular and chunked uploads as pending and claim persisted article references, including partial saves and large reference sets. Protect associated media from cleanup and add upload lifecycle regression coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f9b7997ec2
commit
32ac2112b0
10 files changed
+960
-26
No files matched your search
@@ -80,34 +80,48 @@ final class PendingUploads {
|
||||
|
||||
/** 删除用户自己仍待认领的附件;已被引用的附件绝不删除。 */
|
||||
public static function discard(int $attachment_id, int $user_id): bool {
|
||||
if (!self::is_owned_pending($attachment_id, $user_id)) {
|
||||
if (!self::is_owned_pending($attachment_id, $user_id, true)) {
|
||||
return false;
|
||||
}
|
||||
return (bool) wp_delete_attachment($attachment_id, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 URL 列表认领(工单附件)。
|
||||
*
|
||||
* @param list<string> $urls
|
||||
*/
|
||||
public static function claim_urls(array $urls, int $user_id): void {
|
||||
/** @param list<int> $ids */
|
||||
public static function claim_ids(array $ids, int $user_id): void {
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
foreach (array_slice(array_unique($urls), 0, self::CLAIM_MAX) as $url) {
|
||||
if (!is_string($url) || $url === '') {
|
||||
continue;
|
||||
}
|
||||
$id = (int) attachment_url_to_postid($url);
|
||||
foreach (array_unique(array_map('absint', $ids)) as $id) {
|
||||
if (self::is_owned_pending($id, $user_id)) {
|
||||
delete_post_meta($id, self::META);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 从正文中提取本站上传目录下的 URL 并认领(评论 [img=…]、举报举证)。 */
|
||||
public static function claim_text(string $text, int $user_id): void {
|
||||
/**
|
||||
* 按 URL 列表认领;已保存文章可传 null 认领全部引用。
|
||||
*
|
||||
* @param list<string> $urls
|
||||
*/
|
||||
public static function claim_urls(array $urls, int $user_id, ?int $limit = self::CLAIM_MAX): void {
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
$urls = array_values(array_unique($urls));
|
||||
if ($limit !== null) {
|
||||
$urls = array_slice($urls, 0, max(0, $limit));
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($urls as $url) {
|
||||
if (is_string($url) && $url !== '') {
|
||||
$ids[] = (int) attachment_url_to_postid($url);
|
||||
}
|
||||
}
|
||||
self::claim_ids($ids, $user_id);
|
||||
}
|
||||
|
||||
/** 从正文中提取本站上传目录下的 URL 并认领(评论 [img=…]、举报举证、已保存文章)。 */
|
||||
public static function claim_text(string $text, int $user_id, ?int $limit = self::CLAIM_MAX): void {
|
||||
if ($user_id <= 0 || $text === '') {
|
||||
return;
|
||||
}
|
||||
@@ -121,7 +135,7 @@ final class PendingUploads {
|
||||
$urls = array_filter($matches[0], static function (string $url) use ($base): bool {
|
||||
return strpos(set_url_scheme($url), set_url_scheme($base)) === 0;
|
||||
});
|
||||
self::claim_urls(array_values($urls), $user_id);
|
||||
self::claim_urls(array_values($urls), $user_id, $limit);
|
||||
}
|
||||
|
||||
/** @param int|string $approved */
|
||||
@@ -143,18 +157,25 @@ final class PendingUploads {
|
||||
$ids = $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT p.ID FROM {$wpdb->posts} p
|
||||
INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s
|
||||
WHERE p.post_type = 'attachment' AND CAST(m.meta_value AS UNSIGNED) < %d
|
||||
WHERE p.post_type = 'attachment' AND p.post_parent = 0 AND CAST(m.meta_value AS UNSIGNED) < %d
|
||||
ORDER BY p.ID ASC LIMIT %d",
|
||||
self::META,
|
||||
time() - self::TTL,
|
||||
self::BATCH_SIZE
|
||||
));
|
||||
foreach ((array) $ids as $id) {
|
||||
wp_delete_attachment((int) $id, true);
|
||||
$id = (int) $id;
|
||||
$post = get_post($id);
|
||||
$pending_at = (int) get_post_meta($id, self::META, true);
|
||||
if ($post && $post->post_type === 'attachment' && (int) $post->post_parent === 0
|
||||
&& $pending_at > 0 && $pending_at < time() - self::TTL
|
||||
) {
|
||||
wp_delete_attachment($id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function is_owned_pending(int $attachment_id, int $user_id): bool {
|
||||
private static function is_owned_pending(int $attachment_id, int $user_id, bool $unattached = false): bool {
|
||||
if ($attachment_id <= 0 || $user_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -162,6 +183,7 @@ final class PendingUploads {
|
||||
return $post
|
||||
&& $post->post_type === 'attachment'
|
||||
&& (int) $post->post_author === $user_id
|
||||
&& (!$unattached || (int) $post->post_parent === 0)
|
||||
&& get_post_meta($attachment_id, self::META, true) !== '';
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,6 @@ class FrontSubmit {
|
||||
add_action('wp_ajax_nopriv_aksrt_submit_post', [$this, 'ajax_submit_post']);
|
||||
|
||||
add_action('rest_api_init', [$this, 'register_rest_routes']);
|
||||
add_action('aksrt_new_add_posts', [$this, 'sync_post_attachments']);
|
||||
add_action('aksrt_new_edit_posts', [$this, 'sync_post_attachments']);
|
||||
|
||||
add_filter('body_class', [$this, 'body_class']);
|
||||
add_filter('wp_robots', [$this, 'robots_noindex']);
|
||||
@@ -231,6 +229,7 @@ class FrontSubmit {
|
||||
return new \WP_Error('aksrt_upload_failed', __('上传后无法读取文件。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
\AKSRT\Attachment\PendingUploads::mark($attachment_id);
|
||||
$attachment['success'] = true;
|
||||
return new \WP_REST_Response($attachment, 201);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
namespace AKSRT\Core;
|
||||
|
||||
use AKSRT\Attachment\PendingUploads;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
@@ -197,7 +199,10 @@ trait FrontSubmitMedia {
|
||||
? aksrt_get_pay_meta($post_id)
|
||||
: get_post_meta($post_id, DomainModel::PAY_META_KEY, true);
|
||||
$pay_meta = is_array($pay_meta) ? $pay_meta : [];
|
||||
$download_urls = [];
|
||||
foreach (is_array($pay_meta['downloads'] ?? null) ? $pay_meta['downloads'] : [] as $download) {
|
||||
$url = (string) ($download['url'] ?? '');
|
||||
if ($url !== '') $download_urls[] = $url;
|
||||
$id = absint($download['attachment_id'] ?? 0);
|
||||
if ($id > 0 && $this->attachment_can_attach($id, $user_id, $post_id, 'file')) {
|
||||
$desired[$id] = 'file';
|
||||
@@ -249,6 +254,9 @@ trait FrontSubmitMedia {
|
||||
$wpdb->update($wpdb->posts, ['post_parent' => $post_id], ['ID' => $attachment_id], ['%d'], ['%d']);
|
||||
clean_post_cache($attachment_id);
|
||||
}
|
||||
PendingUploads::claim_ids(array_keys($desired), $user_id);
|
||||
PendingUploads::claim_urls($download_urls, $user_id, null);
|
||||
PendingUploads::claim_text((string) $post->post_content, $user_id, null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -192,15 +192,19 @@ trait FrontSubmitSave {
|
||||
$this->apply_custom_filters((int) $in_id, $data, $settings);
|
||||
|
||||
$pay_result = $this->save_pay_meta((int) $in_id, $data, $user_id, $settings);
|
||||
if ($pay_result['success']) {
|
||||
$this->save_featured_meta((int) $in_id, $data, $user_id);
|
||||
}
|
||||
|
||||
// The post is already persisted even when pay validation fails.
|
||||
$post = get_post((int) $in_id);
|
||||
$this->sync_post_attachments($post);
|
||||
if (!$pay_result['success']) {
|
||||
$pay_result['post_id'] = (int) $in_id;
|
||||
$pay_result['posts_id'] = (int) $in_id;
|
||||
return $pay_result;
|
||||
}
|
||||
|
||||
$this->save_featured_meta((int) $in_id, $data, $user_id);
|
||||
|
||||
$post = get_post((int) $in_id);
|
||||
if ($post instanceof \WP_Post) {
|
||||
do_action($is_edit ? 'aksrt_new_edit_posts' : 'aksrt_new_add_posts', $post);
|
||||
if ($status === 'pending') {
|
||||
|
||||
@@ -10,6 +10,7 @@ const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
const frontSubmit = read('src/Core/FrontSubmit.php');
|
||||
const mediaTrait = read('src/Core/FrontSubmitMedia.php');
|
||||
const saveTrait = read('src/Core/FrontSubmitSave.php');
|
||||
const page = read('templates/pages/page-submit.php');
|
||||
const editor = read('assets/js/front-submit-editor.js');
|
||||
const submit = read('assets/js/front-submit.js');
|
||||
@@ -26,8 +27,8 @@ const runtimeFiles = [
|
||||
test('front submit owns the typed media REST routes and media trait wiring', () => {
|
||||
assert.match(frontSubmit, /use FrontSubmitMedia\s*;/);
|
||||
assert.match(frontSubmit, /add_action\('rest_api_init', \[\$this, 'register_rest_routes'\]\)/);
|
||||
assert.match(frontSubmit, /add_action\('aksrt_new_add_posts', \[\$this, 'sync_post_attachments'\]\)/);
|
||||
assert.match(frontSubmit, /add_action\('aksrt_new_edit_posts', \[\$this, 'sync_post_attachments'\]\)/);
|
||||
assert.doesNotMatch(frontSubmit, /add_action\('aksrt_new_(?:add|edit)_posts', \[\$this, 'sync_post_attachments'\]\)/);
|
||||
assert.match(saveTrait, /\$this->sync_post_attachments\(\$post\);\s*if \(!\$pay_result\['success'\]\)/);
|
||||
|
||||
assert.match(frontSubmit, /register_rest_route\('aksrt\/v1', '\/front-submit\/media\/\(\?P<type>image\|video\|file\)'/);
|
||||
assert.match(frontSubmit, /'methods'\s*=>\s*\\WP_REST_Server::READABLE/);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
test('front-submit pending upload lifecycle runs against production PHP traits', () => {
|
||||
const harness = path.join(__dirname, 'front-submit-pending-harness.php');
|
||||
const output = execFileSync('php', [harness], { encoding: 'utf8' });
|
||||
assert.match(output, /Front-submit pending behavior harness passed/);
|
||||
});
|
||||
@@ -0,0 +1,609 @@
|
||||
<?php
|
||||
/**
|
||||
* WordPress-free behavior harness for front-submit pending-upload ownership.
|
||||
* Exercises the production FrontSubmitSave, FrontSubmitMedia, FrontSubmit,
|
||||
* and PendingUploads implementations against a small in-memory WP surface.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {
|
||||
if (!defined('ABSPATH')) define('ABSPATH', __DIR__ . '/');
|
||||
if (!defined('DAY_IN_SECONDS')) define('DAY_IN_SECONDS', 86400);
|
||||
if (!defined('HOUR_IN_SECONDS')) define('HOUR_IN_SECONDS', 3600);
|
||||
|
||||
class WP_Post {
|
||||
public int $ID = 0;
|
||||
public string $post_type = 'post';
|
||||
public int $post_author = 0;
|
||||
public string $post_status = 'draft';
|
||||
public int $post_parent = 0;
|
||||
public string $post_content = '';
|
||||
public string $post_title = '';
|
||||
public string $post_mime_type = '';
|
||||
|
||||
public function __construct(array $data = []) {
|
||||
foreach ($data as $key => $value) {
|
||||
if (property_exists($this, (string) $key)) {
|
||||
$this->{$key} = match ($key) {
|
||||
'ID', 'post_author', 'post_parent' => (int) $value,
|
||||
default => (string) $value,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WP_Error {
|
||||
private string $code;
|
||||
private string $message;
|
||||
|
||||
public function __construct($code = '', $message = '', $data = null) {
|
||||
$this->code = (string) $code;
|
||||
$this->message = (string) $message;
|
||||
}
|
||||
|
||||
public function get_error_code(): string { return $this->code; }
|
||||
public function get_error_message(): string { return $this->message; }
|
||||
}
|
||||
|
||||
class WP_REST_Request {
|
||||
private array $params;
|
||||
private array $files;
|
||||
|
||||
public function __construct(array $params = [], array $files = []) {
|
||||
$this->params = $params;
|
||||
$this->files = $files;
|
||||
}
|
||||
|
||||
public function get_param(string $key) { return $this->params[$key] ?? null; }
|
||||
public function get_file_params(): array { return $this->files; }
|
||||
}
|
||||
|
||||
class WP_REST_Response {
|
||||
private $data;
|
||||
private int $status;
|
||||
|
||||
public function __construct($data = [], int $status = 200) {
|
||||
$this->data = $data;
|
||||
$this->status = $status;
|
||||
}
|
||||
|
||||
public function get_data() { return $this->data; }
|
||||
public function get_status(): int { return $this->status; }
|
||||
}
|
||||
|
||||
class WP_REST_Server { public const READABLE = 'GET'; }
|
||||
|
||||
final class FrontSubmitPendingWpdb {
|
||||
public string $posts = 'wp_posts';
|
||||
public string $postmeta = 'wp_postmeta';
|
||||
|
||||
public function update($table, array $data, array $where, array $format = [], array $where_format = []) {
|
||||
$state = &$GLOBALS['front_pending_state'];
|
||||
if (!empty($state['fail_relation_updates'])) return false;
|
||||
$id = (int) ($where['ID'] ?? 0);
|
||||
$post = $state['posts'][$id] ?? null;
|
||||
if (!$post instanceof WP_Post) return 0;
|
||||
foreach ($data as $key => $value) {
|
||||
if ($key === 'post_parent') $post->post_parent = (int) $value;
|
||||
}
|
||||
$state['relation_updates'][] = ['id' => $id, 'data' => $data];
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function prepare(string $query, ...$args): string { return $query; }
|
||||
public function get_col(string $query): array { return []; }
|
||||
}
|
||||
|
||||
function fp_assert(bool $condition, string $message): void {
|
||||
if (!$condition) throw new \RuntimeException($message);
|
||||
}
|
||||
|
||||
function fp_reset(): void {
|
||||
$GLOBALS['front_pending_state'] = [
|
||||
'user_id' => 7,
|
||||
'posts' => [],
|
||||
'post_meta' => [],
|
||||
'post_thumbnails' => [],
|
||||
'pay_meta' => [],
|
||||
'url_map' => [],
|
||||
'deleted' => [],
|
||||
'relation_updates' => [],
|
||||
'meta_writes' => [],
|
||||
'upload_calls' => [],
|
||||
'featured_save_calls' => 0,
|
||||
'fail_relation_updates' => false,
|
||||
'fail_insert' => false,
|
||||
'fail_update' => false,
|
||||
'insert_zero' => false,
|
||||
'update_zero' => false,
|
||||
'next_post_id' => 1000,
|
||||
'next_attachment_id' => 2000,
|
||||
'failed_js_ids' => [],
|
||||
];
|
||||
$GLOBALS['wpdb'] = new FrontSubmitPendingWpdb();
|
||||
$_FILES = [];
|
||||
}
|
||||
|
||||
function fp_post(int $id, string $type = 'post', int $author = 7, string $status = 'draft', string $content = '', int $parent = 0, string $mime = ''): WP_Post {
|
||||
$post = new WP_Post([
|
||||
'ID' => $id,
|
||||
'post_type' => $type,
|
||||
'post_author' => $author,
|
||||
'post_status' => $status,
|
||||
'post_content' => $content,
|
||||
'post_parent' => $parent,
|
||||
'post_mime_type' => $mime,
|
||||
'post_title' => 'Harness post ' . $id,
|
||||
]);
|
||||
$GLOBALS['front_pending_state']['posts'][$id] = $post;
|
||||
return $post;
|
||||
}
|
||||
|
||||
function fp_attachment(int $id, int $author = 7, string $mime = 'image/jpeg', int $parent = 0, string $status = 'inherit', bool $pending = true, ?string $kind = 'image'): WP_Post {
|
||||
$post = fp_post($id, 'attachment', $author, $status, '', $parent, $mime);
|
||||
if ($kind !== null) update_post_meta($id, '_aksrt_front_submit_media', $kind);
|
||||
$GLOBALS['front_pending_state']['url_map']['https://example.test/uploads/media-' . $id . '.' . fp_ext($mime)] = $id;
|
||||
if ($pending) \AKSRT\Attachment\PendingUploads::mark($id);
|
||||
return $post;
|
||||
}
|
||||
|
||||
function fp_ext(string $mime): string {
|
||||
return match ($mime) {
|
||||
'image/jpeg' => 'jpg', 'image/png' => 'png', 'video/mp4' => 'mp4', 'application/pdf' => 'pdf', default => 'bin',
|
||||
};
|
||||
}
|
||||
|
||||
function fp_pending(int $id): bool {
|
||||
return get_post_meta($id, \AKSRT\Attachment\PendingUploads::META, true) !== '';
|
||||
}
|
||||
|
||||
function fp_article_data(string $content, array $extra = []): array {
|
||||
return array_merge([
|
||||
'post_title' => 'Pending lifecycle behavior test',
|
||||
'post_content' => $content,
|
||||
'category' => [1],
|
||||
], $extra);
|
||||
}
|
||||
|
||||
function __($text, $domain = ''): string { return (string) $text; }
|
||||
function absint($value): int { return abs((int) $value); }
|
||||
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
|
||||
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
|
||||
function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); }
|
||||
function sanitize_file_name($value): string { return preg_replace('/[^A-Za-z0-9._\-]/', '', (string) $value) ?? ''; }
|
||||
function wp_unslash($value) { return $value; }
|
||||
function wp_strip_all_tags($value): string { return trim(strip_tags((string) $value)); }
|
||||
function wp_kses_post($value): string { return (string) $value; }
|
||||
function is_wp_error($value): bool { return $value instanceof WP_Error; }
|
||||
function get_current_user_id(): int { return (int) $GLOBALS['front_pending_state']['user_id']; }
|
||||
function current_user_can($capability, ...$args): bool { return false; }
|
||||
function user_can($user_id, $capability, ...$args): bool { return false; }
|
||||
function add_filter($tag, $callback, $priority = 10, $accepted_args = 1): bool { return true; }
|
||||
function remove_filter($tag, $callback, $priority = 10): bool { return true; }
|
||||
function do_action($tag, ...$args): void { $GLOBALS['front_pending_state']['actions'][] = $tag; }
|
||||
function current_time($type): string { return '2026-10-30 12:00:00'; }
|
||||
function wp_set_post_categories($post_id, $categories): void {}
|
||||
function wp_set_post_tags($post_id, $tags): void {}
|
||||
function get_permalink($post_id): string { return 'https://example.test/?p=' . (int) $post_id; }
|
||||
function clean_post_cache($post_id): void {}
|
||||
function wp_max_upload_size(): int { return 50 * 1024 * 1024; }
|
||||
function size_format($bytes): string { return (string) $bytes . 'B'; }
|
||||
function get_allowed_mime_types(): array { return ['pdf' => 'application/pdf']; }
|
||||
function wp_check_filetype_and_ext($file, $filename, $mimes = []): array {
|
||||
$ext = strtolower(pathinfo((string) $filename, PATHINFO_EXTENSION));
|
||||
return ['ext' => $ext, 'type' => $mimes[$ext] ?? ''];
|
||||
}
|
||||
function get_post($post_id) {
|
||||
if ($post_id instanceof WP_Post) return $post_id;
|
||||
return $GLOBALS['front_pending_state']['posts'][(int) $post_id] ?? null;
|
||||
}
|
||||
function get_post_meta($post_id, $key = '', $single = false) {
|
||||
$value = $GLOBALS['front_pending_state']['post_meta'][(int) $post_id][(string) $key] ?? null;
|
||||
if ($single) return $value ?? '';
|
||||
return $value === null ? [] : [$value];
|
||||
}
|
||||
function update_post_meta($post_id, $key, $value): bool {
|
||||
$state = &$GLOBALS['front_pending_state'];
|
||||
$id = (int) $post_id;
|
||||
$meta_key = (string) $key;
|
||||
$state['post_meta'][$id][$meta_key] = $value;
|
||||
$state['meta_writes'][] = ['id' => $id, 'key' => $meta_key, 'value' => $value];
|
||||
return true;
|
||||
}
|
||||
function delete_post_meta($post_id, $key): bool {
|
||||
unset($GLOBALS['front_pending_state']['post_meta'][(int) $post_id][(string) $key]);
|
||||
return true;
|
||||
}
|
||||
function get_post_thumbnail_id($post_id): int { return (int) ($GLOBALS['front_pending_state']['post_thumbnails'][(int) $post_id] ?? 0); }
|
||||
function set_post_thumbnail($post_id, $thumbnail_id): bool {
|
||||
$GLOBALS['front_pending_state']['post_thumbnails'][(int) $post_id] = (int) $thumbnail_id;
|
||||
return true;
|
||||
}
|
||||
function delete_post_thumbnail($post_id): bool {
|
||||
unset($GLOBALS['front_pending_state']['post_thumbnails'][(int) $post_id]);
|
||||
return true;
|
||||
}
|
||||
function wp_attachment_is_image($attachment_id): bool {
|
||||
$post = get_post((int) $attachment_id);
|
||||
return $post instanceof WP_Post && strpos($post->post_mime_type, 'image/') === 0;
|
||||
}
|
||||
function get_posts(array $args = []): array {
|
||||
$results = [];
|
||||
foreach ($GLOBALS['front_pending_state']['posts'] as $id => $post) {
|
||||
if (!$post instanceof WP_Post) continue;
|
||||
if (($args['post_type'] ?? '') !== $post->post_type) continue;
|
||||
if (isset($args['post_parent']) && $post->post_parent !== (int) $args['post_parent']) continue;
|
||||
if (isset($args['author']) && $post->post_author !== (int) $args['author']) continue;
|
||||
if (isset($args['post_status']) && !in_array($post->post_status, (array) $args['post_status'], true)) continue;
|
||||
if (!empty($args['meta_key']) && get_post_meta((int) $id, (string) $args['meta_key'], true) === '') continue;
|
||||
$results[] = !empty($args['fields']) && $args['fields'] === 'ids' ? (int) $id : $post;
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
function wp_insert_post(array $postarr, $wp_error = false) {
|
||||
$state = &$GLOBALS['front_pending_state'];
|
||||
if ($state['fail_insert']) return new WP_Error('insert_failed', 'insert failed');
|
||||
if ($state['insert_zero']) return 0;
|
||||
$id = (int) $state['next_post_id']++;
|
||||
$postarr['ID'] = $id;
|
||||
$state['posts'][$id] = new WP_Post($postarr + ['post_type' => 'post', 'post_status' => 'draft']);
|
||||
return $id;
|
||||
}
|
||||
function wp_update_post(array $postarr, $wp_error = false) {
|
||||
$state = &$GLOBALS['front_pending_state'];
|
||||
$id = (int) ($postarr['ID'] ?? 0);
|
||||
if ($id <= 0 || !isset($state['posts'][$id])) return new WP_Error('update_missing', 'post missing');
|
||||
if ($state['fail_update']) return new WP_Error('update_failed', 'update failed');
|
||||
if ($state['update_zero']) return 0;
|
||||
$post = $state['posts'][$id];
|
||||
foreach ($postarr as $key => $value) {
|
||||
if (property_exists($post, (string) $key)) {
|
||||
$post->{$key} = in_array($key, ['ID', 'post_author', 'post_parent'], true) ? (int) $value : (string) $value;
|
||||
}
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
function aksrt_get_pay_meta($post_id): array {
|
||||
return $GLOBALS['front_pending_state']['pay_meta'][(int) $post_id] ?? [];
|
||||
}
|
||||
function attachment_url_to_postid($url): int {
|
||||
return (int) ($GLOBALS['front_pending_state']['url_map'][(string) $url] ?? 0);
|
||||
}
|
||||
function wp_get_upload_dir(): array { return ['baseurl' => 'https://example.test/uploads']; }
|
||||
function set_url_scheme($url, $scheme = null): string {
|
||||
$url = (string) $url;
|
||||
return preg_match('#^https?://#i', $url) ? $url : 'https://' . ltrim($url, '/');
|
||||
}
|
||||
function wp_parse_url($url, $component = -1) { return parse_url((string) $url, $component); }
|
||||
function wp_get_attachment_url($attachment_id): string {
|
||||
foreach ($GLOBALS['front_pending_state']['url_map'] as $url => $id) {
|
||||
if ((int) $id === (int) $attachment_id) return (string) $url;
|
||||
}
|
||||
return 'https://example.test/uploads/media-' . (int) $attachment_id . '.bin';
|
||||
}
|
||||
function wp_get_attachment_image_url($attachment_id, $size = 'thumbnail') { return wp_get_attachment_url($attachment_id); }
|
||||
function wp_prepare_attachment_for_js($attachment_id) {
|
||||
if (in_array((int) $attachment_id, $GLOBALS['front_pending_state']['failed_js_ids'], true)) return false;
|
||||
$post = get_post((int) $attachment_id);
|
||||
if (!$post instanceof WP_Post) return false;
|
||||
$mime = $post->post_mime_type;
|
||||
[$type, $subtype] = array_pad(explode('/', $mime, 2), 2, '');
|
||||
$url = wp_get_attachment_url((int) $attachment_id);
|
||||
return [
|
||||
'id' => (int) $attachment_id,
|
||||
'title' => $post->post_title,
|
||||
'name' => basename($url),
|
||||
'filename' => basename($url),
|
||||
'url' => $url,
|
||||
'type' => $type,
|
||||
'subtype' => $subtype,
|
||||
'mime' => $mime,
|
||||
'sizes' => [],
|
||||
'filesizeInBytes' => 1,
|
||||
];
|
||||
}
|
||||
function get_the_title($post_id): string { return (string) (get_post((int) $post_id)->post_title ?? ''); }
|
||||
function get_attached_file($attachment_id): string { return sys_get_temp_dir() . '/front-submit-' . (int) $attachment_id; }
|
||||
function wp_basename($path): string { return basename((string) $path); }
|
||||
function media_handle_upload($file_id, $post_id, $post_data = [], $overrides = []) {
|
||||
$state = &$GLOBALS['front_pending_state'];
|
||||
$id = (int) $state['next_attachment_id']++;
|
||||
$file = $_FILES[$file_id] ?? [];
|
||||
$state['posts'][$id] = new WP_Post([
|
||||
'ID' => $id,
|
||||
'post_type' => 'attachment',
|
||||
'post_author' => 0,
|
||||
'post_status' => 'inherit',
|
||||
'post_parent' => 0,
|
||||
'post_title' => (string) ($file['name'] ?? 'upload'),
|
||||
'post_mime_type' => 'application/pdf',
|
||||
]);
|
||||
$url = 'https://example.test/uploads/upload-' . $id . '.pdf';
|
||||
$state['url_map'][$url] = $id;
|
||||
$state['upload_calls'][] = ['id' => $id, 'overrides' => $overrides];
|
||||
return $id;
|
||||
}
|
||||
function wp_delete_attachment($attachment_id, $force_delete = false) {
|
||||
$state = &$GLOBALS['front_pending_state'];
|
||||
$id = (int) $attachment_id;
|
||||
$state['deleted'][] = $id;
|
||||
unset($state['posts'][$id], $state['post_meta'][$id]);
|
||||
return (object) ['ID' => $id];
|
||||
}
|
||||
|
||||
fp_reset();
|
||||
require_once dirname(__DIR__, 2) . '/src/Attachment/PendingUploads.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitSave.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitPay.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitFeatured.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitMedia.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmitChunk.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Core/FrontSubmit.php';
|
||||
}
|
||||
|
||||
namespace AKSRT\Core {
|
||||
final class DomainModel {
|
||||
public const PAY_META_KEY = '_aksrt_pay';
|
||||
public const POST_META_KEY = 'aksrt_post_meta';
|
||||
}
|
||||
|
||||
final class FrontSubmitPendingProbe {
|
||||
use FrontSubmitSave;
|
||||
use FrontSubmitMedia;
|
||||
|
||||
public function settings(): array {
|
||||
return [
|
||||
'enabled' => true,
|
||||
'default_user_id' => 1,
|
||||
'title_min' => 3,
|
||||
'title_max' => 100,
|
||||
'allowed_cat_ids' => [],
|
||||
'allow_tags' => false,
|
||||
'custom_filter_s' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function can_submit(?int $user_id = null): bool { return true; }
|
||||
|
||||
public function can_edit_post(int $post_id, ?int $user_id = null): bool {
|
||||
$post = \get_post($post_id);
|
||||
return $post instanceof \WP_Post && (int) $post->post_author === (int) ($user_id ?? \get_current_user_id());
|
||||
}
|
||||
|
||||
public function user_can_cap(string $cap, int $user_id = 0, ?int $context = null): bool {
|
||||
return $cap !== 'new_post_audit_no';
|
||||
}
|
||||
|
||||
private function save_pay_meta(int $post_id, array $data, int $user_id, array $settings): array {
|
||||
if (isset($data['fake_saved_pay_meta']) && is_array($data['fake_saved_pay_meta'])) {
|
||||
$GLOBALS['front_pending_state']['pay_meta'][$post_id] = $data['fake_saved_pay_meta'];
|
||||
\update_post_meta($post_id, DomainModel::PAY_META_KEY, $data['fake_saved_pay_meta']);
|
||||
}
|
||||
if (!empty($data['fake_pay_failure'])) {
|
||||
return ['success' => false, 'message' => 'pay validation failed'];
|
||||
}
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
private function save_featured_meta(int $post_id, array $data, int $user_id): void {
|
||||
$GLOBALS['front_pending_state']['featured_save_calls']++;
|
||||
$meta = $data['fake_featured_meta'] ?? null;
|
||||
if (is_array($meta)) \update_post_meta($post_id, DomainModel::POST_META_KEY, $meta);
|
||||
$thumbnail = (int) ($data['fake_thumbnail_id'] ?? 0);
|
||||
if ($thumbnail > 0) \set_post_thumbnail($post_id, $thumbnail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace AKSRT\User {
|
||||
final class Restriction {
|
||||
private static ?self $instance = null;
|
||||
public static function get_instance(): self { return self::$instance ??= new self(); }
|
||||
public function is_suspended(int $user_id): bool { return false; }
|
||||
public function get_active_suspend(int $user_id): array { return []; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use AKSRT\Attachment\PendingUploads;
|
||||
use AKSRT\Core\FrontSubmit;
|
||||
use AKSRT\Core\FrontSubmitPendingProbe;
|
||||
use AKSRT\Core\DomainModel;
|
||||
|
||||
$submit = new FrontSubmitPendingProbe();
|
||||
|
||||
// Successful single-file and sideload persistence both pass through the real
|
||||
// FrontSubmit normalizer and mark the resulting attachment as pending.
|
||||
fp_reset();
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'front-submit-pending-');
|
||||
file_put_contents($tmp, 'pdf test');
|
||||
$real_submit = new FrontSubmit();
|
||||
foreach ([false, true] as $sideload) {
|
||||
$response = $real_submit->persist_uploaded_file('resource', [
|
||||
'name' => 'document.pdf', 'tmp_name' => $tmp, 'size' => filesize($tmp), 'error' => UPLOAD_ERR_OK,
|
||||
], $sideload);
|
||||
fp_assert($response instanceof \WP_REST_Response && $response->get_status() === 201, 'real upload persistence should return a created response');
|
||||
$id = (int) ($response->get_data()['attachment_id'] ?? 0);
|
||||
fp_assert($id > 0 && fp_pending($id), 'each normalized upload must be marked pending');
|
||||
fp_assert(($GLOBALS['front_pending_state']['post_meta'][$id]['_aksrt_front_submit_media'] ?? '') === 'resource', 'upload should retain the normalized media type');
|
||||
}
|
||||
fp_assert(count($GLOBALS['front_pending_state']['upload_calls']) === 2, 'single and sideload calls should each persist once');
|
||||
fp_assert(empty($GLOBALS['front_pending_state']['upload_calls'][0]['overrides']['action']), 'ordinary upload should use the upload action');
|
||||
fp_assert(($GLOBALS['front_pending_state']['upload_calls'][1]['overrides']['action'] ?? '') === 'wp_handle_sideload', 'chunk merge sideload should use the sideload action');
|
||||
$GLOBALS['front_pending_state']['failed_js_ids'][] = 2002;
|
||||
$un_normalized = $real_submit->persist_uploaded_file('resource', [
|
||||
'name' => 'unreadable.pdf', 'tmp_name' => $tmp, 'size' => filesize($tmp), 'error' => UPLOAD_ERR_OK,
|
||||
]);
|
||||
fp_assert(is_wp_error($un_normalized), 'upload normalization failure should return an error');
|
||||
$premature_marks = array_filter($GLOBALS['front_pending_state']['meta_writes'], static fn (array $row): bool => $row['id'] === 2002 && $row['key'] === PendingUploads::META);
|
||||
fp_assert($premature_marks === [], 'upload must not be marked pending before response normalization succeeds');
|
||||
@unlink($tmp);
|
||||
|
||||
// Draft save covers all persisted reference sources, including more than the
|
||||
// default twenty URLs in both saved pay metadata and post_content.
|
||||
fp_reset();
|
||||
$id_refs = [100, 101, 102, 103, 104, 105, 106, 107, 108];
|
||||
foreach ($id_refs as $id) {
|
||||
$mime = in_array($id, [101, 104], true) ? 'video/mp4' : ($id === 102 ? 'application/pdf' : 'image/jpeg');
|
||||
fp_attachment($id, 7, $mime);
|
||||
}
|
||||
$pay_url_ids = [];
|
||||
$pay_downloads = [
|
||||
['attachment_id' => 107, 'url' => ''],
|
||||
['attachment_id' => 108, 'url' => ''],
|
||||
];
|
||||
for ($i = 0; $i < 23; $i++) {
|
||||
$id = 200 + $i;
|
||||
$url = 'https://example.test/uploads/pay-url-' . $i . '.pdf';
|
||||
fp_attachment($id, 7, 'application/pdf', 0, 'inherit', true, null);
|
||||
$GLOBALS['front_pending_state']['url_map'][$url] = $id;
|
||||
$pay_downloads[] = ['attachment_id' => 0, 'url' => $url];
|
||||
$pay_url_ids[] = $id;
|
||||
}
|
||||
$body_url_ids = [];
|
||||
$body_urls = [];
|
||||
for ($i = 0; $i < 23; $i++) {
|
||||
$id = 300 + $i;
|
||||
$url = 'https://example.test/uploads/body-url-' . $i . '.jpg';
|
||||
fp_attachment($id, 7, 'image/jpeg', 0, 'inherit', true, null);
|
||||
$GLOBALS['front_pending_state']['url_map'][$url] = $id;
|
||||
$body_urls[] = $url;
|
||||
$body_url_ids[] = $id;
|
||||
}
|
||||
$unreferenced = 399;
|
||||
fp_attachment($unreferenced, 7, 'image/jpeg');
|
||||
$content = 'Draft body with enough text for a save. '
|
||||
. '<img data-edit-file-id="100" src="/image.jpg">'
|
||||
. '<video data-edit-file-id="101" data-video-url="https://video.example.test/movie.mp4"></video>'
|
||||
. '<a data-aksrt-file-id="102" href="/resource.pdf">resource</a>'
|
||||
. implode(' ', $body_urls);
|
||||
$result = $submit->persist(fp_article_data($content, [
|
||||
'fake_saved_pay_meta' => ['downloads' => $pay_downloads],
|
||||
'fake_featured_meta' => [
|
||||
'featured_video_attachment_id' => 104,
|
||||
'featured_slide' => '105,106',
|
||||
],
|
||||
'fake_thumbnail_id' => 103,
|
||||
]), 'posts_draft');
|
||||
fp_assert(!empty($result['success']), 'draft with persisted references should save');
|
||||
$draft_id = (int) ($result['post_id'] ?? 0);
|
||||
fp_assert(($GLOBALS['front_pending_state']['posts'][$draft_id]->post_status ?? '') === 'draft', 'draft status should be preserved');
|
||||
$claimed_ids = array_merge($id_refs, $pay_url_ids, $body_url_ids);
|
||||
foreach ($claimed_ids as $id) fp_assert(!fp_pending($id), 'saved draft reference should be claimed: ' . $id);
|
||||
foreach ([100, 101, 102, 103, 104, 105, 106, 107, 108] as $id) {
|
||||
fp_assert(($GLOBALS['front_pending_state']['posts'][$id]->post_parent ?? -1) === $draft_id, 'ID-backed reference should bind to the saved draft: ' . $id);
|
||||
}
|
||||
fp_assert(fp_pending($unreferenced), 'newly uploaded but unreferenced media should remain pending');
|
||||
|
||||
// Pending submissions also synchronize after a successful post write.
|
||||
fp_reset();
|
||||
fp_attachment(410, 7, 'image/jpeg');
|
||||
$pending = $submit->persist(fp_article_data('<p>Pending body has sufficient words.</p><img data-edit-file-id="410" src="/p.jpg">'));
|
||||
fp_assert(!empty($pending['success']), 'pending post should save');
|
||||
$pending_id = (int) $pending['post_id'];
|
||||
fp_assert(($GLOBALS['front_pending_state']['posts'][$pending_id]->post_status ?? '') === 'pending', 'normal submit should retain pending review status');
|
||||
fp_assert(!fp_pending(410), 'pending-review post body image should be claimed');
|
||||
|
||||
// Edit path reuses the production persist and synchronization implementation.
|
||||
fp_reset();
|
||||
$edit_post = fp_post(420, 'post', 7, 'publish', 'Old content');
|
||||
fp_attachment(421, 7, 'video/mp4');
|
||||
$edited = $submit->persist(fp_article_data('<p>Edited body has enough content.</p><video data-video-url="https://video.example.test/edit.mp4" data-edit-file-id="421"></video>', ['posts_id' => 420]));
|
||||
fp_assert(!empty($edited['success']) && (int) $edited['post_id'] === 420, 'owned post should edit successfully');
|
||||
fp_assert($edit_post->post_content !== 'Old content', 'edit should write the new body before synchronization');
|
||||
fp_assert(!fp_pending(421) && $GLOBALS['front_pending_state']['posts'][421]->post_parent === 420, 'edit body media should be claimed and attached');
|
||||
|
||||
// Existing media is reused without creating a new pending marker.
|
||||
fp_reset();
|
||||
$reuse_post = fp_post(430, 'post', 7, 'draft', '');
|
||||
fp_attachment(431, 7, 'image/jpeg', 430, 'inherit', false, 'image');
|
||||
$reuse_writes_before = count(array_filter($GLOBALS['front_pending_state']['meta_writes'], static fn (array $row): bool => $row['id'] === 431 && $row['key'] === PendingUploads::META));
|
||||
$reuse = $submit->persist(fp_article_data('<p>Reused attachment body text.</p><img data-edit-file-id="431" src="/reuse.jpg">', ['posts_id' => 430]), 'posts_draft');
|
||||
fp_assert(!empty($reuse['success']), 'existing media reuse should save');
|
||||
$reuse_writes_after = count(array_filter($GLOBALS['front_pending_state']['meta_writes'], static fn (array $row): bool => $row['id'] === 431 && $row['key'] === PendingUploads::META));
|
||||
fp_assert($reuse_writes_before === $reuse_writes_after && !fp_pending(431), 'reused media must not be marked pending again');
|
||||
fp_assert($GLOBALS['front_pending_state']['posts'][431]->post_parent === 430, 'reused media should remain attached to its post');
|
||||
|
||||
// Removing an edit reference unbinds only; it neither deletes nor re-pends.
|
||||
fp_reset();
|
||||
fp_post(440, 'post', 7, 'draft', '');
|
||||
fp_attachment(441, 7, 'image/jpeg', 440, 'inherit', false, 'image');
|
||||
$removed = $submit->persist(fp_article_data('<p>Reference removed on edit.</p>', ['posts_id' => 440]), 'posts_draft');
|
||||
fp_assert(!empty($removed['success']), 'edit removing a media reference should save');
|
||||
fp_assert(isset($GLOBALS['front_pending_state']['posts'][441]), 'unreferenced attached media must not be deleted');
|
||||
fp_assert($GLOBALS['front_pending_state']['posts'][441]->post_parent === 0, 'removed media should be detached from the edited post');
|
||||
fp_assert(get_post_meta(441, '_aksrt_front_submit_media', true) === '', 'removed media should lose only its submit relation marker');
|
||||
fp_assert(!fp_pending(441) && $GLOBALS['front_pending_state']['deleted'] === [], 'removed media must not be deleted or marked pending again');
|
||||
|
||||
// A payment validation error happens after the body write. The saved body is
|
||||
// protected, but the request's featured values were never persisted and must
|
||||
// therefore not be considered references.
|
||||
fp_reset();
|
||||
fp_attachment(450, 7, 'image/jpeg');
|
||||
fp_attachment(451, 7, 'image/jpeg');
|
||||
$pay_error = $submit->persist(fp_article_data('<p>Body remains persisted after pay error.</p><img data-edit-file-id="450" src="/saved.jpg">', [
|
||||
'fake_pay_failure' => true,
|
||||
'fake_featured_meta' => ['featured_video_attachment_id' => 0, 'featured_slide' => '451'],
|
||||
'fake_thumbnail_id' => 451,
|
||||
]));
|
||||
fp_assert(empty($pay_error['success']) && (int) ($pay_error['post_id'] ?? 0) > 0, 'pay validation should return its error after the post was written');
|
||||
fp_assert(!fp_pending(450), 'persisted body reference should be claimed even when payment validation fails');
|
||||
fp_assert(fp_pending(451), 'unsaved featured media from failed request must not be claimed');
|
||||
fp_assert($GLOBALS['front_pending_state']['featured_save_calls'] === 0, 'featured metadata must not be saved after payment failure');
|
||||
|
||||
// Failed insert/update writes cannot claim anything because no saved post is
|
||||
// synchronized. Test both the create and edit branches.
|
||||
fp_reset();
|
||||
fp_attachment(460, 7, 'image/jpeg');
|
||||
$GLOBALS['front_pending_state']['fail_insert'] = true;
|
||||
$insert_error = $submit->persist(fp_article_data('<p>Insert error body is never saved.</p><img data-edit-file-id="460">'));
|
||||
fp_assert(empty($insert_error['success']) && fp_pending(460), 'insert error must leave referenced media pending');
|
||||
|
||||
fp_reset();
|
||||
fp_post(470, 'post', 7, 'draft', 'Existing post');
|
||||
fp_attachment(471, 7, 'image/jpeg');
|
||||
$GLOBALS['front_pending_state']['fail_update'] = true;
|
||||
$update_error = $submit->persist(fp_article_data('<p>Update error body is never saved.</p><img data-edit-file-id="471">', ['posts_id' => 470]), 'posts_draft');
|
||||
fp_assert(empty($update_error['success']) && fp_pending(471), 'update error must leave referenced media pending');
|
||||
|
||||
// Invalid references (foreign owner, invalid MIME/status/type, conflicting
|
||||
// parent, missing ID) are rejected by the real media attachment checks.
|
||||
fp_reset();
|
||||
fp_attachment(480, 8, 'image/jpeg');
|
||||
fp_attachment(481, 7, 'application/pdf');
|
||||
fp_attachment(482, 7, 'image/jpeg', 0, 'trash');
|
||||
fp_attachment(483, 7, 'image/jpeg', 999, 'inherit');
|
||||
fp_attachment(484, 7, 'video/mp4', 0, 'inherit', true, null);
|
||||
fp_post(485, 'post', 7, 'inherit', '', 0, 'image/jpeg');
|
||||
PendingUploads::mark(485);
|
||||
$invalid_content = '<p>Invalid media references must not be claimed.</p>'
|
||||
. '<img data-edit-file-id="480"><img data-edit-file-id="481">'
|
||||
. '<img data-edit-file-id="482"><img data-edit-file-id="483">'
|
||||
. '<img data-edit-file-id="484"><img data-edit-file-id="485"><img data-edit-file-id="99999">';
|
||||
$invalid = $submit->persist(fp_article_data($invalid_content));
|
||||
fp_assert(!empty($invalid['success']), 'post containing invalid media markers should still save');
|
||||
foreach ([480, 481, 482, 483, 484, 485] as $id) fp_assert(fp_pending($id), 'invalid attachment must remain pending: ' . $id);
|
||||
fp_assert($GLOBALS['front_pending_state']['posts'][483]->post_parent === 999, 'attachment already parented elsewhere must not be reassigned');
|
||||
|
||||
// A relation update failure cannot expose already-saved references to TTL
|
||||
// cleanup: claiming still runs after association attempts.
|
||||
fp_reset();
|
||||
fp_attachment(490, 7, 'image/jpeg');
|
||||
$GLOBALS['front_pending_state']['fail_relation_updates'] = true;
|
||||
$association_error = $submit->persist(fp_article_data('<p>Saved content survives relation failure.</p><img data-edit-file-id="490">'));
|
||||
fp_assert(!empty($association_error['success']), 'database relation failure should not change the saved post result');
|
||||
fp_assert(!fp_pending(490), 'saved body attachment must be claimed even when parent association fails');
|
||||
fp_assert($GLOBALS['front_pending_state']['posts'][490]->post_parent === 0, 'injected relation failure should leave parent unchanged');
|
||||
|
||||
// The existing owner and post-type guards remain in force for direct syncs.
|
||||
fp_reset();
|
||||
fp_attachment(500, 7, 'image/jpeg');
|
||||
$foreign_post = new \WP_Post(['ID' => 501, 'post_type' => 'post', 'post_author' => 8, 'post_status' => 'draft', 'post_content' => '<img data-edit-file-id="500">']);
|
||||
$submit->sync_post_attachments($foreign_post);
|
||||
$submit->sync_post_attachments(new \WP_Post(['ID' => 502, 'post_type' => 'page', 'post_author' => 7, 'post_status' => 'draft', 'post_content' => '<img data-edit-file-id="500">']));
|
||||
fp_assert(fp_pending(500) && $GLOBALS['front_pending_state']['posts'][500]->post_parent === 0, 'foreign-author and non-post sync calls must not claim or attach media');
|
||||
|
||||
echo "Front-submit pending behavior harness passed\n";
|
||||
}
|
||||
@@ -2,9 +2,20 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8');
|
||||
let behavior;
|
||||
const harness = () => {
|
||||
if (!behavior) {
|
||||
const run = spawnSync('php', [path.join(__dirname, 'pending-upload-harness.php')], { encoding: 'utf8' });
|
||||
assert.equal(run.status, 0, `${run.stderr}${run.stdout}`);
|
||||
assert.equal(run.stderr, '');
|
||||
behavior = JSON.parse(run.stdout);
|
||||
}
|
||||
return behavior;
|
||||
};
|
||||
|
||||
test('upload marks pending and returns attachment id', () => {
|
||||
const controller = read('src/Admin/UserController.php');
|
||||
@@ -40,3 +51,60 @@ test('front-end remove buttons discard the pending upload', () => {
|
||||
assert.match(core, /data-aksrt-ticket-image-id/);
|
||||
assert.match(report, /'\/upload\/pending\/'/);
|
||||
});
|
||||
|
||||
test('front submission uploads are marked and saved references are claimed without close-time deletion', () => {
|
||||
const upload = read('src/Core/FrontSubmit.php');
|
||||
const media = read('src/Core/FrontSubmitMedia.php');
|
||||
const editor = read('assets/js/front-submit-editor.js');
|
||||
assert.match(upload, /PendingUploads::mark\(\$attachment_id\)/);
|
||||
assert.match(media, /PendingUploads::claim_ids\(array_keys\(\$desired\), \$user_id\)/);
|
||||
assert.match(media, /PendingUploads::claim_urls\(\$download_urls, \$user_id, null\)/);
|
||||
assert.match(media, /PendingUploads::claim_text\(\(string\) \$post->post_content, \$user_id, null\)/);
|
||||
assert.doesNotMatch(editor, /\/upload\/pending\//);
|
||||
});
|
||||
|
||||
test('cleanup is scheduled once hourly and discard requires a logged-in user', () => {
|
||||
const state = harness().registration;
|
||||
assert.ok(state.hooks.includes('aksrt_pending_upload_cleanup'));
|
||||
assert.ok(state.hooks.includes('init'));
|
||||
assert.equal(state.schedule_count, 1);
|
||||
assert.equal(state.recurrence, 'hourly');
|
||||
assert.equal(state.namespace, 'aksrt/v1');
|
||||
assert.equal(state.method, 'DELETE');
|
||||
assert.equal(state.member_allowed, true);
|
||||
assert.equal(state.guest_allowed, false);
|
||||
});
|
||||
|
||||
test('ID claims handle more than 20 attachments but only clear the owner’s pending marker', () => {
|
||||
assert.deepEqual(harness().claim_ids, { remaining: [26], marked_recently: true, invalid_marked: false });
|
||||
});
|
||||
|
||||
test('URL and text claims retain their default bound while saved content may claim all references', () => {
|
||||
assert.deepEqual(harness().claim_urls, { default_remaining: [21, 22, 23, 24, 25], unlimited_remaining: [] });
|
||||
assert.deepEqual(harness().claim_text, { default_remaining: [21, 22, 23, 24, 25, 26, 27], unlimited_remaining: [26, 27] });
|
||||
});
|
||||
|
||||
test('discard refuses foreign, claimed and parented media and permanently deletes only an owned pending upload', () => {
|
||||
assert.deepEqual(harness().discard, { denied: [false, false, false, false], success: true, repeat_status: 404, deleted: [[1, true]] });
|
||||
});
|
||||
|
||||
test('cleanup deletes only expired unattached pending uploads and uses prepared bounded SQL', () => {
|
||||
const state = harness().cleanup;
|
||||
assert.deepEqual(state.deleted, [[1, true]]);
|
||||
assert.deepEqual(state.remaining, [2, 3, 4]);
|
||||
assert.match(state.query.sql, /p\.post_type = 'attachment' AND p\.post_parent = 0/);
|
||||
assert.match(state.query.sql, /m\.meta_key = %s/);
|
||||
assert.match(state.query.sql, /LIMIT %d/);
|
||||
assert.equal(state.query.params[0], '_aksrt_pending_upload');
|
||||
assert.equal(state.query.params[2], 100);
|
||||
assert.ok(Math.abs(state.query.params[1] - (Math.floor(Date.now() / 1000) - 86400)) < 10);
|
||||
});
|
||||
|
||||
test('cleanup rechecks selected candidates after claim, attach, refresh or removal', () => {
|
||||
assert.deepEqual(harness().stale_selection.deleted, [[5, true]]);
|
||||
});
|
||||
|
||||
test('cleanup processes a bounded batch and retries unsuccessful deletions later', () => {
|
||||
assert.deepEqual(harness().batch, { first: 100, total: 105, remaining: 0 });
|
||||
assert.deepEqual(harness().retry, { retained: true, deleted_after_retry: true });
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
/** WordPress-free pending upload lifecycle and cleanup harness. */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
define('DAY_IN_SECONDS', 86400);
|
||||
define('HOUR_IN_SECONDS', 3600);
|
||||
|
||||
final class WP_REST_Request {
|
||||
public function __construct(private array $params) {}
|
||||
public function get_param(string $key) { return $this->params[$key] ?? null; }
|
||||
}
|
||||
final class WP_REST_Response {
|
||||
public function __construct(public array $data, public int $status = 200) {}
|
||||
}
|
||||
|
||||
function absint($value): int { return abs((int) $value); }
|
||||
function __($text, $domain = '') { return $text; }
|
||||
function get_current_user_id(): int { return $GLOBALS['current_user']; }
|
||||
function is_user_logged_in(): bool { return get_current_user_id() > 0; }
|
||||
function get_post(int $id) { return $GLOBALS['posts'][$id] ?? null; }
|
||||
function get_post_meta(int $id, string $key, bool $single = false) { return $GLOBALS['meta'][$id][$key] ?? ''; }
|
||||
function update_post_meta(int $id, string $key, $value): void { $GLOBALS['meta'][$id][$key] = $value; }
|
||||
function delete_post_meta(int $id, string $key): void { unset($GLOBALS['meta'][$id][$key]); }
|
||||
function wp_delete_attachment(int $id, bool $force) {
|
||||
$GLOBALS['deletions'][] = [$id, $force];
|
||||
if (!empty($GLOBALS['delete_failures'][$id])) return false;
|
||||
$post = get_post($id);
|
||||
unset($GLOBALS['posts'][$id], $GLOBALS['meta'][$id]);
|
||||
return $post;
|
||||
}
|
||||
function wp_get_upload_dir(): array { return ['baseurl' => 'https://example.test/wp-content/uploads']; }
|
||||
function wp_parse_url(string $url, int $component = -1) { return parse_url($url, $component); }
|
||||
function set_url_scheme(string $url): string { return preg_replace('#^https?:#', 'https:', $url); }
|
||||
function attachment_url_to_postid(string $url): int { return $GLOBALS['attachment_urls'][$url] ?? 0; }
|
||||
function add_action(string $hook, callable $callback, int $priority = 10, int $accepted = 1): void {
|
||||
$GLOBALS['hooks'][$hook] = [$callback, $accepted];
|
||||
}
|
||||
function register_rest_route(string $namespace, string $route, array $args): void {
|
||||
$GLOBALS['routes'][] = [$namespace, $route, $args];
|
||||
}
|
||||
function wp_next_scheduled(string $hook) { return $GLOBALS['scheduled'][$hook] ?? false; }
|
||||
function wp_schedule_event(int $at, string $recurrence, string $hook): void {
|
||||
$GLOBALS['schedule_calls'][] = [$at, $recurrence, $hook];
|
||||
$GLOBALS['scheduled'][$hook] = $at;
|
||||
}
|
||||
|
||||
final class PendingWpdb {
|
||||
public string $posts = 'wp_posts';
|
||||
public string $postmeta = 'wp_postmeta';
|
||||
public array $queries = [];
|
||||
public ?array $forced_ids = null;
|
||||
public $after_select = null;
|
||||
private array $params = [];
|
||||
|
||||
public function prepare(string $sql, ...$params): string {
|
||||
$this->params = $params;
|
||||
$this->queries[] = ['sql' => $sql, 'params' => $params];
|
||||
return $sql;
|
||||
}
|
||||
public function get_col(string $sql): array {
|
||||
if ($this->forced_ids !== null) {
|
||||
$ids = $this->forced_ids;
|
||||
} else {
|
||||
[$key, $cutoff, $limit] = $this->params;
|
||||
$posts = $GLOBALS['posts'];
|
||||
ksort($posts);
|
||||
$ids = [];
|
||||
foreach ($posts as $id => $post) {
|
||||
$pending = get_post_meta($id, $key, true);
|
||||
if ($post->post_type !== 'attachment' || $pending === '' || (int) $pending >= $cutoff) continue;
|
||||
if (str_contains($sql, 'p.post_parent = 0') && $post->post_parent !== 0) continue;
|
||||
$ids[] = $id;
|
||||
if (count($ids) >= $limit) break;
|
||||
}
|
||||
}
|
||||
if (is_callable($this->after_select)) ($this->after_select)();
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
|
||||
require dirname(__DIR__, 2) . '/src/Attachment/PendingUploads.php';
|
||||
use AKSRT\Attachment\PendingUploads;
|
||||
|
||||
function reset_fixture(): void {
|
||||
$GLOBALS['posts'] = [];
|
||||
$GLOBALS['meta'] = [];
|
||||
$GLOBALS['attachment_urls'] = [];
|
||||
$GLOBALS['deletions'] = [];
|
||||
$GLOBALS['delete_failures'] = [];
|
||||
$GLOBALS['current_user'] = 7;
|
||||
$GLOBALS['wpdb'] = new PendingWpdb();
|
||||
}
|
||||
function attachment(int $id, int $owner = 7, int $parent = 0, ?int $pending = null): string {
|
||||
$GLOBALS['posts'][$id] = (object) ['ID' => $id, 'post_type' => 'attachment', 'post_author' => $owner, 'post_parent' => $parent];
|
||||
$url = 'https://example.test/wp-content/uploads/file-' . $id . '.jpg';
|
||||
$GLOBALS['attachment_urls'][$url] = $id;
|
||||
if ($pending !== null) update_post_meta($id, PendingUploads::META, $pending);
|
||||
return $url;
|
||||
}
|
||||
function pending_ids(): array {
|
||||
return array_values(array_filter(array_keys($GLOBALS['posts']), static fn(int $id): bool => get_post_meta($id, PendingUploads::META, true) !== ''));
|
||||
}
|
||||
|
||||
$service = PendingUploads::get_instance();
|
||||
$results = [];
|
||||
reset_fixture();
|
||||
$GLOBALS['hooks'] = [];
|
||||
$GLOBALS['routes'] = [];
|
||||
$GLOBALS['scheduled'] = [];
|
||||
$GLOBALS['schedule_calls'] = [];
|
||||
$service->init();
|
||||
$service->maybe_schedule();
|
||||
$service->maybe_schedule();
|
||||
$service->register_routes();
|
||||
$route = $GLOBALS['routes'][0];
|
||||
$permission = $route[2]['permission_callback'];
|
||||
$member_allowed = $permission();
|
||||
$GLOBALS['current_user'] = 0;
|
||||
$results['registration'] = [
|
||||
'hooks' => array_keys($GLOBALS['hooks']),
|
||||
'schedule_count' => count($GLOBALS['schedule_calls']),
|
||||
'recurrence' => $GLOBALS['schedule_calls'][0][1],
|
||||
'namespace' => $route[0], 'route' => $route[1], 'method' => $route[2]['methods'],
|
||||
'member_allowed' => $member_allowed, 'guest_allowed' => $permission(),
|
||||
];
|
||||
|
||||
reset_fixture();
|
||||
for ($id = 1; $id <= 25; $id++) attachment($id, 7, 0, time() - 2 * DAY_IN_SECONDS);
|
||||
attachment(26, 8, 0, time() - 2 * DAY_IN_SECONDS);
|
||||
attachment(27);
|
||||
PendingUploads::mark(27);
|
||||
$marked_at = get_post_meta(27, PendingUploads::META, true);
|
||||
PendingUploads::mark(0);
|
||||
PendingUploads::claim_ids(array_merge(range(1, 27), [1, 0]), 7);
|
||||
$results['claim_ids'] = ['remaining' => pending_ids(), 'marked_recently' => abs(time() - $marked_at) <= 2, 'invalid_marked' => isset($GLOBALS['meta'][0])];
|
||||
|
||||
reset_fixture();
|
||||
$urls = [];
|
||||
for ($id = 1; $id <= 25; $id++) $urls[] = attachment($id, 7, 0, time() - 2 * DAY_IN_SECONDS);
|
||||
PendingUploads::claim_urls($urls, 7);
|
||||
$default_remaining = pending_ids();
|
||||
PendingUploads::claim_urls($urls, 7, null);
|
||||
$results['claim_urls'] = ['default_remaining' => $default_remaining, 'unlimited_remaining' => pending_ids()];
|
||||
|
||||
reset_fixture();
|
||||
$urls = [];
|
||||
for ($id = 1; $id <= 25; $id++) $urls[] = attachment($id, 7, 0, time() - 2 * DAY_IN_SECONDS);
|
||||
$other = attachment(26, 8, 0, time() - 2 * DAY_IN_SECONDS);
|
||||
$outside = 'https://cdn.other.test/file.jpg';
|
||||
attachment(27, 7, 0, time() - 2 * DAY_IN_SECONDS);
|
||||
$GLOBALS['attachment_urls'][$outside] = 27;
|
||||
$text = implode(' ', array_map(static fn(string $url): string => '<img src="' . $url . '">', array_merge($urls, [$other, $outside])));
|
||||
PendingUploads::claim_text($text, 7);
|
||||
$text_default = pending_ids();
|
||||
PendingUploads::claim_text($text, 7, null);
|
||||
$results['claim_text'] = ['default_remaining' => $text_default, 'unlimited_remaining' => pending_ids()];
|
||||
|
||||
reset_fixture();
|
||||
attachment(1, 7, 0, time());
|
||||
attachment(2, 8, 0, time());
|
||||
attachment(3);
|
||||
attachment(4, 7, 99, time());
|
||||
$denied = [PendingUploads::discard(2, 7), PendingUploads::discard(3, 7), PendingUploads::discard(4, 7), PendingUploads::discard(1, 0)];
|
||||
$allowed = $service->rest_discard(new WP_REST_Request(['id' => 1]));
|
||||
$repeat = $service->rest_discard(new WP_REST_Request(['id' => 1]));
|
||||
$results['discard'] = ['denied' => $denied, 'success' => $allowed->data['success'], 'repeat_status' => $repeat->status, 'deleted' => $GLOBALS['deletions']];
|
||||
|
||||
reset_fixture();
|
||||
$expired = time() - 2 * DAY_IN_SECONDS;
|
||||
attachment(1, 7, 0, $expired);
|
||||
attachment(2, 7, 0, time() - HOUR_IN_SECONDS);
|
||||
attachment(3);
|
||||
attachment(4, 7, 99, $expired);
|
||||
$service->cleanup();
|
||||
$results['cleanup'] = [
|
||||
'deleted' => $GLOBALS['deletions'], 'remaining' => array_keys($GLOBALS['posts']),
|
||||
'query' => $GLOBALS['wpdb']->queries[0],
|
||||
];
|
||||
|
||||
reset_fixture();
|
||||
for ($id = 1; $id <= 6; $id++) attachment($id, 7, 0, $expired);
|
||||
$GLOBALS['posts'][6]->post_type = 'post';
|
||||
$GLOBALS['wpdb']->forced_ids = range(1, 6);
|
||||
$GLOBALS['wpdb']->after_select = static function (): void {
|
||||
PendingUploads::claim_ids([1], 7);
|
||||
$GLOBALS['posts'][2]->post_parent = 99;
|
||||
PendingUploads::mark(3);
|
||||
unset($GLOBALS['posts'][4]);
|
||||
};
|
||||
$service->cleanup();
|
||||
$results['stale_selection'] = ['deleted' => $GLOBALS['deletions']];
|
||||
|
||||
reset_fixture();
|
||||
for ($id = 1; $id <= 105; $id++) attachment($id, 7, 0, $expired);
|
||||
$service->cleanup();
|
||||
$first_batch = count($GLOBALS['deletions']);
|
||||
$service->cleanup();
|
||||
$results['batch'] = ['first' => $first_batch, 'total' => count($GLOBALS['deletions']), 'remaining' => count($GLOBALS['posts'])];
|
||||
|
||||
reset_fixture();
|
||||
attachment(1, 7, 0, $expired);
|
||||
$GLOBALS['delete_failures'][1] = true;
|
||||
$service->cleanup();
|
||||
$retained_after_failure = isset($GLOBALS['posts'][1]) && get_post_meta(1, PendingUploads::META, true) !== '';
|
||||
$GLOBALS['delete_failures'][1] = false;
|
||||
$service->cleanup();
|
||||
$results['retry'] = ['retained' => $retained_after_failure, 'deleted_after_retry' => !isset($GLOBALS['posts'][1])];
|
||||
|
||||
echo json_encode($results, JSON_THROW_ON_ERROR);
|
||||
@@ -331,7 +331,7 @@ test('native ban page mirrors the reference theme layout', () => {
|
||||
const tickets = read('src/Admin/Native/UsersMenusTickets.php')
|
||||
|
||||
// 菜单与页面标题对齐参考主题(page title=处理举报及禁封申诉,menu title=举报&禁封申诉)
|
||||
assert.ok(usersMenus.includes(`__('处理举报及禁封申诉', 'aksrt'),\n __('举报&禁封申诉', 'aksrt')`), 'submenu titles should match the reference theme')
|
||||
assert.match(usersMenus, /__\('处理举报及禁封申诉', 'aksrt'\),\s*__\('举报&禁封申诉', 'aksrt'\)/, 'submenu titles should match the reference theme')
|
||||
assert.ok(usersMenus.includes('用户禁封申诉及用户举报管理'), 'page heading should match the reference theme')
|
||||
|
||||
// 类型筛选按钮组 + 状态 subsubsub + 搜索
|
||||
|
||||
Reference in new issue
Block a user