Files
wordpress-theme-aksrt/src/Core/FrontSubmit.php
T

551 lines
21 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\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;
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_aksrt_submit_post', [$this, 'ajax_submit_post']);
add_action('wp_ajax_aksrt_save_draft', [$this, 'ajax_save_draft']);
add_action('wp_ajax_aksrt_update_post', [$this, 'ajax_update_post']);
add_action('wp_ajax_aksrt_delete_draft', [$this, 'ajax_delete_draft']);
add_action('wp_ajax_aksrt_delete_post', [$this, 'ajax_delete_post']);
// Guest path (when enabled).
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']);
add_filter('aksrt_floating_buttons_enabled', [$this, 'disable_chrome_on_submit']);
add_filter('aksrt_footer_tabbar_enabled', [$this, 'disable_chrome_on_submit']);
}
public function register_rest_routes(): void {
register_rest_route('aksrt/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('aksrt/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',
],
],
]);
}
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 {
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
$configured_mb = match ($kind) {
'image' => max(1, min(20, (int) ($settings['submit_image_max_size'] ?? 5))),
'video' => max(1, min(200, (int) ($settings['submit_video_max_size'] ?? 30))),
'resource' => max(1, min(200, (int) ($settings['resource_max_size'] ?? 50))),
default => 0,
};
if ($configured_mb === 0) {
return 0;
}
return min($configured_mb * MB_IN_BYTES, (int) wp_max_upload_size());
}
public function rest_upload_attachment(\WP_REST_Request $request) {
$kind = (string) $request->get_param('kind');
$user_id = get_current_user_id();
$files = $request->get_file_params();
$file = $files['file'] ?? null;
if (!is_array($file)) {
return new \WP_Error('aksrt_upload_missing_file', __('请选择要上传的文件。', 'aksrt'), ['status' => 400]);
}
$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(
'aksrt_upload_too_large',
sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)),
['status' => 413]
);
}
if ($upload_error === UPLOAD_ERR_PARTIAL) {
return new \WP_Error('aksrt_upload_partial', __('文件未完整上传,请重新选择。', 'aksrt'), ['status' => 400]);
}
if ($upload_error === UPLOAD_ERR_NO_FILE || empty($file['tmp_name'])) {
return new \WP_Error('aksrt_upload_missing_file', __('请选择要上传的文件。', 'aksrt'), ['status' => 400]);
}
if ($upload_error !== UPLOAD_ERR_OK) {
return new \WP_Error('aksrt_upload_failed', __('服务器无法保存上传文件。', 'aksrt'), ['status' => 500]);
}
$size = (int) ($file['size'] ?? 0);
if ($size <= 0 || $size > $max_size) {
return new \WP_Error(
'aksrt_upload_too_large',
sprintf(__('文件大小不能超过 %s。', 'aksrt'), 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('aksrt_upload_invalid_type', __('不支持的文件类型。', 'aksrt'), ['status' => 400]);
}
if ($kind === 'image') {
$image_info = @getimagesize((string) $file['tmp_name']);
if ($image_info === false || strpos($mime, 'image/') !== 0) {
return new \WP_Error('aksrt_upload_invalid_image', __('请上传有效的图片文件。', 'aksrt'), ['status' => 400]);
}
if (((int) $image_info[0] * (int) $image_info[1]) > 40000000) {
return new \WP_Error('aksrt_upload_image_too_large', __('图片尺寸过大。', 'aksrt'), ['status' => 400]);
}
if (class_exists('\AKSRT\Extensions\ContentAudit')) {
$audit = \AKSRT\Extensions\ContentAudit::get_instance()->audit_image((string) $file['tmp_name']);
if (empty($audit['passed'])) {
return new \WP_Error('aksrt_upload_image_rejected', __('图片未通过内容审核。', 'aksrt'), ['status' => 422]);
}
}
} elseif ($kind === 'video' && strpos($mime, 'video/') !== 0) {
return new \WP_Error('aksrt_upload_invalid_video', __('请上传有效的视频文件。', 'aksrt'), ['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';
}
$_FILES['aksrt_front_submit_file'] = $file;
$attachment_id = media_handle_upload('aksrt_front_submit_file', 0, [], ['test_form' => false]);
unset($_FILES['aksrt_front_submit_file']);
if (is_wp_error($attachment_id)) {
return new \WP_Error('aksrt_upload_failed', $attachment_id->get_error_message(), ['status' => 400]);
}
$attachment_id = (int) $attachment_id;
wp_update_post(['ID' => $attachment_id, 'post_author' => $user_id]);
update_post_meta($attachment_id, '_aksrt_front_submit_media', $kind);
$attachment = $this->prepare_attachment_response($attachment_id);
if ($attachment === null) {
wp_delete_attachment($attachment_id, true);
return new \WP_Error('aksrt_upload_failed', __('上传后无法读取文件。', 'aksrt'), ['status' => 500]);
}
$attachment['success'] = true;
return new \WP_REST_Response($attachment, 201);
}
/**
* @return array<string, mixed>
*/
public function settings(): array {
if (class_exists('\AKSRT\Settings\Settings')) {
return \AKSRT\Settings\Settings::get_submit_settings();
}
return [
'enabled' => true,
'title_min' => 5,
'title_max' => 30,
'allowed_cat_ids' => [],
'default_user_id' => 0,
'guest_enabled' => false,
'allow_category' => true,
'allow_tags' => true,
'allow_cover' => true,
'custom_filter_s' => true,
'review_mode' => 'pending',
'pay_free_s' => true,
'pay_price_min' => 0,
'pay_price_max' => 100,
'pay_vip_price_s' => true,
'pay_vip_1_enabled' => false,
'pay_vip_2_enabled' => false,
'pay_vip_1_discount' => 80,
'pay_vip_2_discount' => 50,
];
}
/**
* Whether the user (or guest) may open / create a submission.
*/
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();
}
if ($user_id <= 0) {
return !empty($settings['guest_enabled']) && (int) ($settings['default_user_id'] ?? 0) > 0;
}
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('aksrt_user_can')) {
return aksrt_user_can($cap, $user_id, $context);
}
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'user_can')) {
return \AKSRT\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 === 'aksrt_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' => __('请先登录', 'aksrt')]);
}
$data = wp_unslash($_POST);
$result = $this->persist(is_array($data) ? $data : [], 'posts_draft');
$this->send_result($result);
}
public function ajax_update_post(): void {
$this->verify_nonce();
$data = wp_unslash($_POST);
if (!is_array($data)) {
$data = [];
}
$post_id = absint($data['posts_id'] ?? $data['post_id'] ?? $data['edit_id'] ?? 0);
if ($post_id <= 0) {
wp_send_json_error(['message' => __('文章 ID 无效', 'aksrt')]);
}
$data['posts_id'] = $post_id;
$action = (($data['status'] ?? '') === 'draft') ? 'posts_draft' : 'posts_save';
if ($action !== 'posts_draft') {
$this->verify_captcha();
}
$result = $this->persist($data, $action);
$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' => __('请先登录', 'aksrt')]);
}
$post_id = absint($_POST['draft_id'] ?? $_POST['post_id'] ?? $_POST['id'] ?? 0);
if ($post_id <= 0) {
wp_send_json_error(['message' => __('草稿 ID 无效', 'aksrt')]);
}
$post = get_post($post_id);
if (!$post || (int) $post->post_author !== $user_id) {
wp_send_json_error(['message' => __('没有删除权限', 'aksrt')]);
}
if ($post->post_status !== 'draft' && !$this->can_delete_post($post_id, $user_id)) {
wp_send_json_error(['message' => __('只能删除草稿', 'aksrt')]);
}
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' => __('已删除', 'aksrt')]);
}
wp_send_json_error(['message' => __('删除失败', 'aksrt')]);
}
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' => __('您没有删除此内容的权限', 'aksrt')]);
}
$post = get_post($post_id);
if (!$post || $post->post_status === 'trash') {
wp_send_json_success(['message' => __('文章已经删除', 'aksrt'), 'reload' => true]);
}
wp_trash_post($post_id);
wp_send_json_success([
'message' => __('文章已经删除', 'aksrt'),
'reload' => true,
'goto' => home_url('/'),
]);
}
private function verify_nonce(): void {
$nonce = (string) ($_REQUEST['_nonce'] ?? $_REQUEST['nonce'] ?? $_REQUEST['_wpnonce'] ?? '');
if ($nonce === '' || (
!wp_verify_nonce($nonce, 'aksrt_submit')
&& !wp_verify_nonce($nonce, 'aksrt_submit_nonce')
)) {
wp_send_json_error(['message' => __('安全验证失败,请刷新页面重试', 'aksrt')], 403);
}
}
private function verify_captcha(): void {
$verify = aksrt_require_captcha('front_submit', $_POST);
if ($verify !== true) {
$captcha_error = is_wp_error($verify) ? $verify : null;
$message = $captcha_error
? $captcha_error->get_error_message()
: __('人机验证失败,请重试', 'aksrt');
wp_send_json_error([
'code' => $captcha_error ? ($captcha_error->get_error_code() ?: 'aksrt_captcha_failed') : 'aksrt_captcha_failed',
'message' => $message !== '' ? $message : __('人机验证失败,请重试', 'aksrt'),
], 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[] = 'aksrt-page-submit';
$classes[] = 'aksrt-hide-float';
$classes[] = 'aksrt-hide-tabbar';
}
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;
}
/**
* @param mixed $enabled
* @return mixed
*/
public function disable_chrome_on_submit($enabled) {
if ($this->is_submit_template()) {
return false;
}
return $enabled;
}
public function is_submit_template(): bool {
return is_page_template('templates/pages/page-submit.php');
}
}