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

279 lines
9.9 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Core;
defined('ABSPATH') || exit;
/**
* 前台大文件分片上传(后台「扩展&增强 → 系统工具」的 split_upload_s / split_minimum_size)。
*
* 行为对齐参考主题 action/media.php 的 user_split_upload / user_split_upload_merge /
* user_split_uploaded_chunk 协议:2MB 一片、断点续传(已传分片按序跳过)、合并产物
* 经 sideload 动作重新走 persist_uploaded_file() 标准校验与落库管线。
*/
trait FrontSubmitChunk {
/** 每个分片的固定大小(参考主题 split_chunk_size = 2MB)。 */
const CHUNK_SIZE_BYTES = 2 * 1048576;
public function register_chunk_routes(): void {
$kind_arg = [
'validate_callback' => static function ($value): bool {
return in_array((string) $value, ['image', 'video', 'resource'], true);
},
];
register_rest_route('aksrt/v1', '/front-submit/upload-chunk/(?P<kind>image|video|resource)', [
'methods' => 'POST',
'callback' => [$this, 'rest_upload_chunk'],
'permission_callback' => [$this, 'can_upload_attachment'],
'args' => [
'kind' => $kind_arg,
'chunk' => ['default' => 0, 'sanitize_callback' => 'absint'],
'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'],
'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'],
'size' => ['default' => 0, 'sanitize_callback' => 'absint'],
],
]);
register_rest_route('aksrt/v1', '/front-submit/upload-merge/(?P<kind>image|video|resource)', [
'methods' => 'POST',
'callback' => [$this, 'rest_upload_chunk_merge'],
'permission_callback' => [$this, 'can_upload_attachment'],
'args' => [
'kind' => $kind_arg,
'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'],
'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'],
'size' => ['default' => 0, 'sanitize_callback' => 'absint'],
],
]);
register_rest_route('aksrt/v1', '/front-submit/upload-status/(?P<kind>image|video|resource)', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_upload_chunk_status'],
'permission_callback' => [$this, 'can_upload_attachment'],
'args' => [
'kind' => $kind_arg,
'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'],
'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'],
],
]);
}
/**
* 上传单个分片;index 为 0 基,同时累加校验总大小不超上限。
*/
public function rest_upload_chunk(\WP_REST_Request $request) {
$file = $request->get_file_params()['file'] ?? null;
if (!is_array($file) || empty($file['tmp_name']) || (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return new \WP_Error('aksrt_chunk_missing_file', __('上传信息错误,请重新选择文件', 'aksrt'), ['status' => 400]);
}
$params = $this->chunk_params($request, true);
if (is_wp_error($params)) {
return $params;
}
$max_size = self::upload_limit_bytes($params['kind']);
if ($params['size'] <= 0 || $params['size'] > $max_size) {
return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]);
}
$chunk_bytes = (int) @filesize((string) $file['tmp_name']);
if ($chunk_bytes <= 0 || $chunk_bytes > self::CHUNK_SIZE_BYTES) {
return new \WP_Error('aksrt_chunk_invalid_size', __('分片大小异常,请重新上传', 'aksrt'), ['status' => 400]);
}
$temp_dir = $this->chunk_temp_dir();
if ($params['chunk'] === 0) {
$this->chunk_temp_cleanup($temp_dir);
}
$existing = $this->chunk_part_paths($temp_dir, $params['key']);
$uploaded = 0;
foreach ($existing as $path) {
$uploaded += (int) @filesize($path);
}
if ($uploaded + $chunk_bytes > $max_size) {
return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]);
}
$target = $temp_dir . '/' . $this->chunk_part_name($params['key'], $params['chunk']);
$moved = @move_uploaded_file((string) $file['tmp_name'], $target);
if (!$moved) {
$moved = @copy((string) $file['tmp_name'], $target);
@unlink((string) $file['tmp_name']);
}
if (!$moved) {
return new \WP_Error('aksrt_chunk_save_failed', __('分片保存失败,请重新上传', 'aksrt'), ['status' => 500]);
}
return new \WP_REST_Response([
'success' => true,
'chunk' => $params['chunk'],
'chunks' => $params['chunks'],
'uploaded_size' => $uploaded + $chunk_bytes,
], 200);
}
/**
* 断点续传:返回已落盘的分片序号,前端按序跳过。
*/
public function rest_upload_chunk_status(\WP_REST_Request $request) {
$params = $this->chunk_params($request);
if (is_wp_error($params)) {
return $params;
}
$parts = $this->chunk_part_paths($this->chunk_temp_dir(), $params['key']);
$uploaded = [];
foreach ($parts as $path) {
$uploaded[] = $this->chunk_index_from_name((string) $path);
}
return new \WP_REST_Response([
'success' => true,
'uploaded' => $uploaded,
'uploaded_count' => count($uploaded),
], 200);
}
/**
* 合并分片并按标准管线落库;成功或失败后都清理分片。
*/
public function rest_upload_chunk_merge(\WP_REST_Request $request) {
$params = $this->chunk_params($request);
if (is_wp_error($params)) {
return $params;
}
$max_size = self::upload_limit_bytes($params['kind']);
$temp_dir = $this->chunk_temp_dir();
$merge_path = $temp_dir . '/' . $this->chunk_part_name($params['key'], 'merge');
$fp = @fopen($merge_path, 'wb');
if (!$fp) {
return new \WP_Error('aksrt_chunk_merge_failed', __('分片合并失败,请重新上传', 'aksrt'), ['status' => 500]);
}
$missing = 0;
for ($i = 0; $i < $params['chunks']; $i++) {
$part = $temp_dir . '/' . $this->chunk_part_name($params['key'], $i);
if (!is_file($part)) {
$missing++;
break;
}
fwrite($fp, (string) file_get_contents($part));
}
fclose($fp);
if ($missing) {
@unlink($merge_path);
return new \WP_Error('aksrt_chunk_missing', __('文件缺少分片,正在重新上传缺失分片', 'aksrt'), ['status' => 409]);
}
$merged_size = (int) @filesize($merge_path);
if ($merged_size <= 0 || ($params['size'] > 0 && $merged_size !== $params['size'])) {
@unlink($merge_path);
return new \WP_Error('aksrt_chunk_size_mismatch', __('合并后的文件大小校验失败,请重新上传', 'aksrt'), ['status' => 400]);
}
if ($merged_size > $max_size) {
@unlink($merge_path);
return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]);
}
$name = sanitize_file_name($params['name']);
$type = (string) (wp_check_filetype_and_ext($merge_path, $name, get_allowed_mime_types())['type'] ?? '');
$result = $this->persist_uploaded_file($params['kind'], [
'tmp_name' => $merge_path,
'name' => $name,
'size' => $merged_size,
'type' => $type,
'error' => UPLOAD_ERR_OK,
], true);
// 无论落库成败都清理本次分片(persist 成功时已 copy 走合并文件,失败时也要清场)。
foreach ($this->chunk_part_paths($temp_dir, $params['key']) as $path) {
@unlink($path);
}
@unlink($merge_path);
return $result;
}
/** 校验并归一分片参数(name/chunks/chunk/size/key)。 */
private function chunk_params(\WP_REST_Request $request, bool $with_index = false) {
$name = sanitize_file_name((string) $request->get_param('name'));
$chunks = (int) $request->get_param('chunks');
$chunk = (int) $request->get_param('chunk');
if ($name === '' || $chunks < 1 || $chunks > 20480) {
return new \WP_Error('aksrt_chunk_invalid_params', __('分片参数异常', 'aksrt'), ['status' => 400]);
}
if ($with_index && ($chunk < 0 || $chunk >= $chunks)) {
return new \WP_Error('aksrt_chunk_invalid_params', __('分片参数异常', 'aksrt'), ['status' => 400]);
}
return [
'kind' => (string) $request->get_param('kind'),
'name' => $name,
'chunks' => $chunks,
'chunk' => $chunk,
'size' => (int) $request->get_param('size'),
// 分片按用户隔离,避免同名文件互相覆盖、也防止探测他人分片。
'key' => md5(get_current_blog_id() . '|' . get_current_user_id() . '|' . $name),
];
}
/** 分片临时目录(参考主题为 wp-content/zib-temp,带 index.php 保护和 48h 清理)。 */
private function chunk_temp_dir(): string {
$temp_dir = (string) apply_filters('aksrt_temp_dir', WP_CONTENT_DIR . '/aksrt-temp');
if (!@is_dir($temp_dir)) {
wp_mkdir_p($temp_dir);
}
$index_file = $temp_dir . '/index.php';
if (!file_exists($index_file)) {
@file_put_contents($index_file, "<?php\n// Silence is golden.\n");
}
return $temp_dir;
}
/** 清理超过 48 小时的遗留分片。 */
private function chunk_temp_cleanup(string $temp_dir): void {
$expire = time() - 48 * HOUR_IN_SECONDS;
foreach ((array) glob($temp_dir . '/*.part') as $file) {
if (@filemtime((string) $file) < $expire) {
@unlink((string) $file);
}
}
}
/** 当前 key 的全部分片路径(不含 merge 产物)。 */
private function chunk_part_paths(string $temp_dir, string $key): array {
$paths = [];
foreach ((array) glob($temp_dir . '/' . $this->chunk_part_name($key, '*')) as $path) {
$path = (string) $path;
if (substr($path, -10) === 'merge.part') {
continue;
}
$paths[] = $path;
}
sort($paths, SORT_NATURAL);
return $paths;
}
private function chunk_part_name(string $key, $index): string {
return sprintf('%d-%s-%s.part', get_current_blog_id(), $key, (string) $index);
}
private function chunk_index_from_name(string $path): int {
$base = basename($path, '.part');
$dash = strrpos($base, '-');
return $dash === false ? -1 : (int) substr($base, $dash + 1);
}
}