Files

274 lines
10 KiB
PHP
Raw Permalink Normal View History

<?php
// ============================================
// 文件: storage_api.php
// 说明: 存储相关的 AJAX 接口:连通性测试、云主机身份探测、历史图片迁移
// 全部需要登录;返回 JSON。
// ============================================
require_once 'config.php';
// 本文件是 JSON 接口:任何 PHP 诊断都不得写进响应体。
// 否则前端的 res.json() 会直接失败,真正的错误反而被 SyntaxError 掩盖
// (实际踩过:lib/storage/http.php 探测站外 CA 路径触发 open_basedir 警告,
// "<br /><b>Warning</b>" 被 display_errors 打在 JSON 前面)。
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ob_start();
// 兜底:万一发生致命错误,丢弃半截输出,改回一个可读的 JSON 错误
register_shutdown_function(function () {
$e = error_get_last();
$fatal = $e && in_array($e['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true);
if (!$fatal) return;
while (ob_get_level() > 0) { ob_end_clean(); }
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
}
echo json_encode(['ok' => false, 'error' => '服务器内部错误:' . $e['message']], JSON_UNESCAPED_UNICODE);
});
checkLogin();
header('Content-Type: application/json; charset=utf-8');
$action = $_POST['action'] ?? ($_GET['action'] ?? '');
// 把表单提交的配置合并到已保存配置之上(留空字段沿用旧值)
function mergePostedConfig($driver, array $posted) {
$saved = storageConfigFor($driver);
foreach ($saved as $k => $old) {
if (!array_key_exists($k, $posted)) continue;
if (is_array($posted[$k])) continue;
$val = trim((string)$posted[$k]);
if ($val === '') {
continue; // 留空:沿用已保存(与保存逻辑一致)
}
$saved[$k] = $val;
}
$saved['https'] = ($saved['https'] ?? '1') === '0' ? '0' : '1';
return $saved;
}
function jsonOut($data) {
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
// 扫描 uploads/ 下的全部文件(返回相对 ROOT_DIR 的键,已排序)
function scanLocalUploadFiles() {
$out = [];
if (!is_dir(UPLOAD_DIR)) return $out;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(UPLOAD_DIR, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($it as $f) {
if (!$f->isFile()) continue;
$name = $f->getFilename();
if ($name === '.htaccess' || $name[0] === '.') continue;
$rel = relPathOfDir($f->getPath()) . $name;
$out[] = $rel;
}
sort($out, SORT_STRING);
return $out;
}
function localBytesOf(array $files) {
$b = 0;
foreach ($files as $f) {
$p = ROOT_DIR . '/' . $f;
if (is_file($p)) $b += filesize($p);
}
return $b;
}
switch ($action) {
// ---------- 测试连接 ----------
case 'test': {
$driver = $_POST['driver'] ?? '';
if (!in_array($driver, ['oss', 'cos', 'qiniu'], true)) {
jsonOut(['ok' => false, 'error' => '请选择云端存储方式后再测试']);
}
$posted = isset($_POST[$driver]) && is_array($_POST[$driver]) ? $_POST[$driver] : [];
$cfg = mergePostedConfig($driver, $posted);
try {
$obj = storageDriverInstance($driver, $cfg);
$r = $obj->test();
} catch (Throwable $e) {
jsonOut(['ok' => false, 'error' => '测试异常:' . $e->getMessage()]);
}
if (!empty($r['ok'])) {
// 测试通过,顺手清掉历史错误提示
if (currentStorageDriver() === $driver) {
storageClearError();
}
jsonOut(['ok' => true, 'message' => $r['message'] ?? '连接成功']);
}
jsonOut(['ok' => false, 'error' => $r['error'] ?? '测试失败']);
}
// ---------- 探测云主机身份 ----------
case 'detect_cloud': {
clearMetaCredentialCache();
$found = storageDetectCloudHost();
if (empty($found)) {
jsonOut([
'ok' => false,
'message' => '未检测到云主机实例元数据。说明当前服务器不在阿里云 ECS / 腾讯云 CVM 上,'
. '或实例未绑定角色——请改用密钥方式填写。',
]);
}
$parts = [];
foreach ($found as $k => $info) {
$parts[] = $info['label'] . ',角色:' . ($info['role'] !== '' ? $info['role'] : '(未知)');
}
jsonOut(['ok' => true, 'message' => '✔ 检测到:' . implode(';', $parts) . ',可使用免密钥方式。']);
}
// ---------- 统计本地图片 ----------
case 'stat': {
$files = scanLocalUploadFiles();
$bytes = localBytesOf($files);
jsonOut([
'ok' => true,
'local_total' => count($files),
'local_bytes' => $bytes,
'local_bytes_h' => formatBytes($bytes),
'remote_ready' => storageIsRemote(),
]);
}
// ---------- 分批迁移 ----------
case 'migrate': {
if (!storageIsRemote()) {
jsonOut(['ok' => false, 'error' => '当前未启用对象存储(或配置不完整),无法迁移']);
}
@set_time_limit(120);
$driver = storage();
if ($driver->id() === 'local') {
jsonOut(['ok' => false, 'error' => '当前驱动为本地存储,无法迁移']);
}
// 游标形如 "<本次运行令牌>|<已处理条数>"。
// 之所以不用「文件名游标」:迁移会把本地文件按内容指纹改名,
// 改名后的新名字可能排到游标之后,于是同一个文件会被再处理一遍
// (线上出现过「进度 880/447」这种超过总数的数字)。
// 这里把「本次运行要处理的文件清单」一次性冻结存档,之后按**下标**推进,
// 改名与否都不再影响进度;令牌用来识别并丢弃上一次遗留的清单。
$cursor = (string)($_POST['cursor'] ?? '');
$limit = (int)($_POST['limit'] ?? 10);
$limit = max(1, min(50, $limit));
$deleteLocal = !empty($_POST['delete_local']);
$token = '';
$start = 0;
if ($cursor !== '' && strpos($cursor, '|') !== false) {
list($token, $idx) = explode('|', $cursor, 2);
$start = max(0, (int)$idx);
}
$queue = null;
if ($token !== '') {
$saved = json_decode((string)getSetting('storage_migrate_queue', ''), true);
if (is_array($saved) && ($saved['run'] ?? '') === $token && is_array($saved['files'] ?? null)) {
$queue = $saved['files'];
}
}
if ($queue === null) { // 新一轮:重新冻结清单
$queue = scanLocalUploadFiles();
$token = bin2hex(random_bytes(6));
$start = 0;
setSetting('storage_migrate_queue',
json_encode(['run' => $token, 'files' => $queue], JSON_UNESCAPED_UNICODE));
}
$total = count($queue);
$batch = array_slice($queue, $start, $limit);
$migrated = 0;
$failed = 0;
$renamed = 0;
$refs = 0;
$errors = [];
foreach ($batch as $rel) {
$abs = ROOT_DIR . '/' . $rel;
if (!is_file($abs)) { continue; }
$binary = @file_get_contents($abs);
if ($binary === false || $binary === '') {
$failed++;
if (count($errors) < 20) $errors[] = $rel . ' → 读取源文件失败';
continue;
}
// 目标键:已经是本内容的指纹名就沿用(保证重复迁移幂等),否则按内容指纹改名
if (isFingerprintedName($rel, $binary)) {
$newKey = $rel;
} else {
$ext = strtolower(pathinfo($rel, PATHINFO_EXTENSION));
// 头像目录沿用 avatar 角色段,与正常上传的命名保持一致(便于人工排查)
$role = (basename(dirname($abs)) === 'avatars') ? 'avatar' : '';
$newKey = relPathOfDir(dirname($abs)) . fingerprintedFilename($binary, $ext, $role);
}
$up = $driver->putFile($abs, $newKey, storageMimeOfFile($abs));
if (empty($up['ok'])) {
$failed++;
if (count($errors) < 20) $errors[] = $rel . ' → ' . ($up['error'] ?? '上传失败');
continue;
}
// 顺序不可颠倒:先上传成功 → 再改写数据库引用 → 再同步本地文件名 → 最后才删。
// 任一步失败就停在这里,本地文件仍在,"本地优先" 渲染保证页面不会裂图。
if ($newKey !== $rel) {
$refs += storageRewriteImageRef($rel, $newKey);
$renamed += 1;
// 本地文件同步改成指纹名:一是本地 / 云端 / 数据库三者保持一致,
// 二是——即便不删除本地文件——下次迁移看到的已是指纹名,可直接跳过,
// 从而让「重复迁移」真正幂等(不会每次又换一个新的随机后缀)。
$newAbs = ROOT_DIR . '/' . $newKey;
if ($abs !== $newAbs && @rename($abs, $newAbs)) {
$abs = $newAbs;
}
}
$migrated++;
if ($deleteLocal) {
@unlink($abs);
}
}
// 按「下标」推进:本批就是清单里的 [start, start+count(batch)) 段
$processed = count($batch);
$next = $start + $processed;
$remaining = max(0, $total - $next);
if ($remaining === 0) {
// 本次跑完,清掉清单;下次点迁移会重新冻结一份(此时本地已是指纹名,直接复用键)
deleteSetting('storage_migrate_queue');
}
jsonOut([
'ok' => true,
'processed' => $processed,
'migrated' => $migrated,
'renamed' => $renamed,
'refs' => $refs,
'failed' => $failed,
'errors' => $errors,
'cursor' => $token . '|' . $next,
'has_more' => $remaining > 0,
'total' => $total,
'remaining' => $remaining,
]);
}
default:
jsonOut(['ok' => false, 'error' => '未知操作']);
}