Files
wordpress-theme-aksrt/src/Core/FrontSubmit.php
T
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00

527 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package Jio
*/
namespace Jio\Core;
defined('ABSPATH') || exit;
/**
* Front-end article submit (写文章 / 投稿).
*
* AJAX authority for create / draft / update / delete; capability-gated.
*/
class FrontSubmit {
use FrontSubmitSave;
use FrontSubmitPay;
use FrontSubmitFeatured;
use FrontSubmitMedia;
use FrontSubmitChunk;
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('wp_ajax_jio_submit_post', [$this, 'ajax_submit_post']);
add_action('wp_ajax_jio_save_draft', [$this, 'ajax_save_draft']);
add_action('wp_ajax_jio_delete_draft', [$this, 'ajax_delete_draft']);
add_action('wp_ajax_jio_delete_post', [$this, 'ajax_delete_post']);
// Guest path (when enabled).
add_action('wp_ajax_nopriv_jio_submit_post', [$this, 'ajax_submit_post']);
add_action('rest_api_init', [$this, 'register_rest_routes']);
add_filter('body_class', [$this, 'body_class']);
add_filter('wp_robots', [$this, 'robots_noindex']);
}
public function register_rest_routes(): void {
register_rest_route('jio/v1', '/front-submit/media/(?P<type>image|video|file)', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_list_attachments'],
'permission_callback' => [$this, 'can_list_attachments'],
'args' => [
'type' => [
'validate_callback' => static function ($value): bool {
return in_array((string) $value, ['image', 'video', 'file'], true);
},
],
'post_id' => [
'default' => 0,
'sanitize_callback' => 'absint',
],
'paged' => [
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => static function ($value): bool {
return absint($value) > 0;
},
],
'search' => [
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
],
'exclude' => [
'default' => [],
'sanitize_callback' => [$this, 'sanitize_attachment_ids'],
],
],
]);
register_rest_route('jio/v1', '/front-submit/upload/(?P<kind>image|video|resource)', [
'methods' => 'POST',
'callback' => [$this, 'rest_upload_attachment'],
'permission_callback' => [$this, 'can_upload_attachment'],
'args' => [
'kind' => [
'validate_callback' => static function ($value): bool {
return in_array((string) $value, ['image', 'video', 'resource'], true);
},
],
'post_id' => [
'default' => 0,
'sanitize_callback' => 'absint',
],
],
]);
$this->register_chunk_routes();
}
public function can_upload_attachment(\WP_REST_Request $request): bool {
$user_id = get_current_user_id();
if ($user_id <= 0) {
return false;
}
if (!$this->can_use_media($request, $user_id)) {
return false;
}
$kind = (string) $request->get_param('kind');
$caps = [
'image' => 'upload_submit_image',
'video' => 'new_post_upload_video',
'resource' => 'upload_resource_attachment',
];
$cap = $caps[$kind] ?? '';
if ($cap === '') {
return false;
}
return $this->user_can_cap($cap, $user_id);
}
public static function upload_limit_bytes(string $kind): int {
// Kind names follow the REST route; the config rows use the reference
// theme's naming (image / video / file).
$config_kind = $kind === 'resource' ? 'file' : $kind;
if (class_exists('\Jio\Attachment\UploadLimits')) {
return \Jio\Attachment\UploadLimits::limit_bytes($config_kind);
}
return (int) wp_max_upload_size();
}
public function rest_upload_attachment(\WP_REST_Request $request) {
$kind = (string) $request->get_param('kind');
$files = $request->get_file_params();
$file = $files['file'] ?? null;
if (!is_array($file)) {
return new \WP_Error('jio_upload_missing_file', __('请选择要上传的文件。', 'jio'), ['status' => 400]);
}
return $this->persist_uploaded_file($kind, $file, false);
}
/**
* 校验并把一个上传文件落库为附件;整传与分片合并共用同一条管线。
*
* @param string $kind image|video|resource
* @param array $file $_FILES 风格数组(name/tmp_name/size/error)
* @param bool $sideload true 时文件不是 HTTP 直传(分片合并产物),用 sideload 动作保存
* @return \WP_REST_Response|\WP_Error
*/
public function persist_uploaded_file(string $kind, array $file, bool $sideload = false) {
$max_size = self::upload_limit_bytes($kind);
$upload_error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($upload_error === UPLOAD_ERR_INI_SIZE || $upload_error === UPLOAD_ERR_FORM_SIZE) {
return new \WP_Error(
'jio_upload_too_large',
sprintf(__('文件大小不能超过 %s。', 'jio'), size_format($max_size)),
['status' => 413]
);
}
if ($upload_error === UPLOAD_ERR_PARTIAL) {
return new \WP_Error('jio_upload_partial', __('文件未完整上传,请重新选择。', 'jio'), ['status' => 400]);
}
if ($upload_error === UPLOAD_ERR_NO_FILE || empty($file['tmp_name'])) {
return new \WP_Error('jio_upload_missing_file', __('请选择要上传的文件。', 'jio'), ['status' => 400]);
}
if ($upload_error !== UPLOAD_ERR_OK) {
return new \WP_Error('jio_upload_failed', __('服务器无法保存上传文件。', 'jio'), ['status' => 500]);
}
$size = (int) ($file['size'] ?? 0);
if ($size <= 0 || $size > $max_size) {
return new \WP_Error(
'jio_upload_too_large',
sprintf(__('文件大小不能超过 %s。', 'jio'), size_format($max_size)),
['status' => 413]
);
}
$filename = sanitize_file_name((string) ($file['name'] ?? ''));
$checked = wp_check_filetype_and_ext((string) $file['tmp_name'], $filename, get_allowed_mime_types());
$mime = (string) ($checked['type'] ?? '');
if ($filename === '' || $mime === '') {
return new \WP_Error('jio_upload_invalid_type', __('不支持的文件类型。', 'jio'), ['status' => 400]);
}
if ($kind === 'image') {
$image_info = @getimagesize((string) $file['tmp_name']);
if ($image_info === false || strpos($mime, 'image/') !== 0) {
return new \WP_Error('jio_upload_invalid_image', __('请上传有效的图片文件。', 'jio'), ['status' => 400]);
}
if (((int) $image_info[0] * (int) $image_info[1]) > 40000000) {
return new \WP_Error('jio_upload_image_too_large', __('图片尺寸过大。', 'jio'), ['status' => 400]);
}
if (class_exists('\Jio\Extensions\ContentAudit')) {
$audit = \Jio\Extensions\ContentAudit::get_instance()->audit_image((string) $file['tmp_name']);
if (empty($audit['passed'])) {
return new \WP_Error('jio_upload_image_rejected', __('图片未通过内容审核。', 'jio'), ['status' => 422]);
}
}
} elseif ($kind === 'video' && strpos($mime, 'video/') !== 0) {
return new \WP_Error('jio_upload_invalid_video', __('请上传有效的视频文件。', 'jio'), ['status' => 400]);
}
if (!function_exists('media_handle_upload')) {
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
}
// 合并产物不是 HTTP 直传文件,走 sideload 动作(copy 而非 move_uploaded_file)。
$overrides = $sideload ? ['test_form' => false, 'action' => 'wp_handle_sideload'] : ['test_form' => false];
$_FILES['jio_front_submit_file'] = $file;
$attachment_id = media_handle_upload('jio_front_submit_file', 0, [], $overrides);
unset($_FILES['jio_front_submit_file']);
if (is_wp_error($attachment_id)) {
return new \WP_Error('jio_upload_failed', $attachment_id->get_error_message(), ['status' => 400]);
}
$attachment_id = (int) $attachment_id;
wp_update_post(['ID' => $attachment_id, 'post_author' => get_current_user_id()]);
update_post_meta($attachment_id, '_jio_front_submit_media', $kind);
$attachment = $this->prepare_attachment_response($attachment_id);
if ($attachment === null) {
wp_delete_attachment($attachment_id, true);
return new \WP_Error('jio_upload_failed', __('上传后无法读取文件。', 'jio'), ['status' => 500]);
}
\Jio\Attachment\PendingUploads::mark($attachment_id);
$attachment['success'] = true;
return new \WP_REST_Response($attachment, 201);
}
/**
* @return array<string, mixed>
*/
public function settings(): array {
if (class_exists('\Jio\Settings\Settings')) {
return \Jio\Settings\Settings::get_submit_settings();
}
return [
'enabled' => true,
'btn_text' => '发布文章',
'title_min' => 5,
'title_max' => 30,
'title_placeholder' => '请输入标题',
'content_placeholder' => '请输入内容',
'allowed_cat_ids' => [],
'default_user_id' => 1,
'allow_tags' => true,
'custom_filter_s' => true,
'pay_free_s' => true,
'pay_price_min' => 0,
'pay_price_max' => 0,
'pay_points_min' => 0,
'pay_points_max' => 0,
'pay_vip_price_s' => true,
'pay_vip_1_enabled' => false,
'pay_vip_2_enabled' => false,
'pay_vip_1_discount' => 100,
'pay_vip_2_discount' => 100,
];
}
/**
* Whether the user (or guest) may open / create a submission.
*
* Guest submission is granted through the capability matrix ("all" column
* of new_post_add), mirroring the reference theme.
*/
public function can_submit(?int $user_id = null): bool {
$settings = $this->settings();
if (empty($settings['enabled'])) {
return false;
}
if ($user_id === null) {
$user_id = get_current_user_id();
}
return $this->user_can_cap('new_post_add', $user_id);
}
public function can_edit_post(int $post_id, ?int $user_id = null): bool {
if ($post_id <= 0) {
return false;
}
if ($user_id === null) {
$user_id = get_current_user_id();
}
if ($user_id <= 0) {
return false;
}
if (user_can($user_id, 'manage_options') || user_can($user_id, 'edit_others_posts')) {
return true;
}
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') {
return false;
}
if ((int) $post->post_author !== $user_id) {
return false;
}
// Own drafts always editable when user can add posts.
if ($post->post_status === 'draft' && $this->user_can_cap('new_post_add', $user_id)) {
return true;
}
return $this->user_can_cap('new_post_edit', $user_id, $post_id);
}
public function can_delete_post(int $post_id, ?int $user_id = null): bool {
if ($user_id === null) {
$user_id = get_current_user_id();
}
if ($user_id <= 0) {
return false;
}
if (user_can($user_id, 'manage_options')) {
return true;
}
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') {
return false;
}
if ((int) $post->post_author !== $user_id) {
return false;
}
if ($post->post_status === 'draft' && $this->user_can_cap('new_post_add', $user_id)) {
return true;
}
return $this->user_can_cap('new_post_delete', $user_id, $post_id);
}
public function user_can_cap(string $cap, int $user_id = 0, ?int $context = null): bool {
if ($user_id > 0 && user_can($user_id, 'manage_options')) {
return true;
}
if (function_exists('jio_user_can')) {
return jio_user_can($cap, $user_id, $context);
}
if (class_exists('\Jio\Settings\Settings') && method_exists('\Jio\Settings\Settings', 'user_can')) {
return \Jio\Settings\Settings::user_can($cap, $user_id, $context);
}
return $user_id > 0;
}
/**
* @param array<string, mixed> $data
* @return array{success:bool,message:string,post_id?:int}
*/
public function submit(array $data): array {
$action = sanitize_key((string) ($data['action_type'] ?? $data['submit_action'] ?? 'posts_save'));
if ($action === 'jio_save_draft' || ($data['status'] ?? '') === 'draft') {
$action = 'posts_draft';
} else {
$action = 'posts_save';
}
return $this->persist($data, $action);
}
/**
* @param array<string, mixed> $data
* @return array{success:bool,message:string,post_id?:int}
*/
public function update(int $post_id, array $data): array {
$data['posts_id'] = $post_id;
$action = (($data['status'] ?? '') === 'draft') ? 'posts_draft' : 'posts_save';
return $this->persist($data, $action);
}
public function ajax_submit_post(): void {
$this->verify_nonce();
$this->verify_captcha();
$data = wp_unslash($_POST);
$result = $this->persist(is_array($data) ? $data : [], 'posts_save');
$this->send_result($result);
}
public function ajax_save_draft(): void {
$this->verify_nonce();
if (!is_user_logged_in()) {
wp_send_json_error(['message' => __('请先登录', 'jio')]);
}
$data = wp_unslash($_POST);
$result = $this->persist(is_array($data) ? $data : [], 'posts_draft');
$this->send_result($result);
}
public function ajax_delete_draft(): void {
$this->verify_nonce();
$user_id = get_current_user_id();
if ($user_id <= 0) {
wp_send_json_error(['message' => __('请先登录', 'jio')]);
}
$post_id = absint($_POST['draft_id'] ?? $_POST['post_id'] ?? $_POST['id'] ?? 0);
if ($post_id <= 0) {
wp_send_json_error(['message' => __('草稿 ID 无效', 'jio')]);
}
$post = get_post($post_id);
if (!$post || (int) $post->post_author !== $user_id) {
wp_send_json_error(['message' => __('没有删除权限', 'jio')]);
}
if ($post->post_status !== 'draft' && !$this->can_delete_post($post_id, $user_id)) {
wp_send_json_error(['message' => __('只能删除草稿', 'jio')]);
}
if ($post->post_status === 'draft') {
$ok = wp_delete_post($post_id, true);
} else {
$ok = wp_trash_post($post_id);
}
if ($ok) {
wp_send_json_success(['message' => __('已删除', 'jio')]);
}
wp_send_json_error(['message' => __('删除失败', 'jio')]);
}
public function ajax_delete_post(): void {
$this->verify_nonce();
$user_id = get_current_user_id();
$post_id = absint($_POST['id'] ?? $_POST['post_id'] ?? 0);
if (!$this->can_delete_post($post_id, $user_id)) {
wp_send_json_error(['message' => __('您没有删除此内容的权限', 'jio')]);
}
$post = get_post($post_id);
if (!$post || $post->post_status === 'trash') {
wp_send_json_success(['message' => __('文章已经删除', 'jio'), 'reload' => true]);
}
wp_trash_post($post_id);
wp_send_json_success([
'message' => __('文章已经删除', 'jio'),
'reload' => true,
'goto' => home_url('/'),
]);
}
private function verify_nonce(): void {
$nonce = (string) ($_REQUEST['_nonce'] ?? $_REQUEST['nonce'] ?? $_REQUEST['_wpnonce'] ?? '');
if ($nonce === '' || (
!wp_verify_nonce($nonce, 'jio_submit')
&& !wp_verify_nonce($nonce, 'jio_submit_nonce')
)) {
wp_send_json_error(['message' => __('安全验证失败,请刷新页面重试', 'jio')], 403);
}
}
private function verify_captcha(): void {
$verify = jio_require_captcha('front_submit', $_POST);
if ($verify !== true) {
$captcha_error = is_wp_error($verify) ? $verify : null;
$message = $captcha_error
? $captcha_error->get_error_message()
: __('人机验证失败,请重试', 'jio');
wp_send_json_error([
'code' => $captcha_error ? ($captcha_error->get_error_code() ?: 'jio_captcha_failed') : 'jio_captcha_failed',
'message' => $message !== '' ? $message : __('人机验证失败,请重试', 'jio'),
], 400);
}
}
/**
* @param array{success:bool,message?:string} $result
*/
private function send_result(array $result): void {
if (!empty($result['success'])) {
wp_send_json_success($result);
}
wp_send_json_error($result);
}
/**
* Downloads image URL into media library as featured image.
*/
private function set_thumbnail_from_url(int $post_id, string $url): void {
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
return;
}
if (!function_exists('media_sideload_image')) {
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
}
$ext = strtolower(pathinfo((string) (parse_url($url, PHP_URL_PATH) ?? ''), PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if ($ext !== '' && !in_array($ext, $allowed, true)) {
return;
}
$attachment_id = media_sideload_image($url, $post_id, '', 'id');
if (!is_wp_error($attachment_id)) {
set_post_thumbnail($post_id, (int) $attachment_id);
}
}
/**
* @param string[] $classes
* @return string[]
*/
public function body_class(array $classes): array {
if ($this->is_submit_template()) {
$classes[] = 'jio-page-submit';
}
return $classes;
}
/**
* @param array<string,bool> $robots
* @return array<string,bool>
*/
public function robots_noindex(array $robots): array {
if ($this->is_submit_template()) {
$robots['noindex'] = true;
$robots['nofollow'] = true;
}
return $robots;
}
public function is_submit_template(): bool {
return is_page_template('templates/pages/page-submit.php');
}
}