date('Y-m-d H:i:s'), 'message' => storageShortError($message), 'driver' => currentStorageDriver(), ]); } function storageLastError() { return getSettingJson('storage_last_error', []); } function storageClearError() { deleteSetting('storage_last_error'); } // ---------- 写入 ---------- /** * 上传一个本地文件到当前存储。云端失败时自动回退到本地保存。 * @return bool 是否成功(含回退成功的场景) */ function storagePutFile($srcPath, $key, $mime = null) { $key = ltrim((string)$key, '/'); if ($key === '' || !is_file($srcPath)) { storageRecordError('上传失败:源文件不存在或目标键为空'); return false; } $mime = $mime ?: storageMimeOfFile($srcPath); $driver = storage(); if ($driver->id() === 'local') { $r = $driver->putFile($srcPath, $key, $mime); if (empty($r['ok'])) storageRecordError($r['error']); return !empty($r['ok']); } $r = $driver->putFile($srcPath, $key, $mime); if (!empty($r['ok'])) return true; // 云端失败 → 回退本地,避免丢图 $fallback = new StorageLocal(); $l = $fallback->putFile($srcPath, $key, $mime); storageRecordError('云端上传失败,已自动回退为本地保存。原因:' . $r['error']); return !empty($l['ok']); } // 上传一段二进制内容(用于 base64 裁剪结果) function storagePutContents($binary, $key, $mime = null) { $key = ltrim((string)$key, '/'); if ($key === '' || $binary === '' || $binary === null) { storageRecordError('上传失败:内容为空或目标键为空'); return false; } $mime = $mime ?: 'application/octet-stream'; $driver = storage(); if ($driver->id() === 'local') { $r = $driver->putContents($binary, $key, $mime); if (empty($r['ok'])) storageRecordError($r['error']); return !empty($r['ok']); } $r = $driver->putContents($binary, $key, $mime); if (!empty($r['ok'])) return true; $fallback = new StorageLocal(); $l = $fallback->putContents($binary, $key, $mime); storageRecordError('云端上传失败,已自动回退为本地保存。原因:' . $r['error']); return !empty($l['ok']); } // ---------- 删除 ---------- /** * 删除一个对象:本地有就删本地;当前是远端存储时同时尝试删除云端对象。 */ function storageDelete($key) { if ($key === null || trim((string)$key) === '') return false; $key = ltrim((string)$key, '/'); if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $key)) return false; // 绝对 URL 无法定位对象 $ok = false; $local = new StorageLocal(); if ($local->exists($key)) { $ok = (bool)$local->delete($key); } if (storageIsRemote()) { $r = storage()->delete($key); if (empty($r['ok'])) { storageRecordError($r['error']); } else { $ok = true; } } return $ok; } // ---------- 读取 / URL ---------- // 是否为绝对地址(http/https/协议相对) function storageIsAbsoluteUrl($path) { return (bool)preg_match('#^(https?:)?//#i', (string)$path); } // 本地是否已存在该文件 function storageHasLocalFile($key) { $key = ltrim((string)$key, '/'); return $key !== '' && is_file(ROOT_DIR . '/' . $key); } // ---------- 图片访问方式 ---------- // direct:图片 URL 直接指向存储(要求桶可被匿名读取,或由防盗链/签名兜底) // proxy :图片 URL 指向本站 media.php,由服务端凭实例角色/密钥读取后回吐 // —— 桶可以设为「私有」,且因同源交付,Canvas 读像素(裁剪)也不再受限 function storageAccessMode() { return ((string)getSetting('storage_access_mode', 'direct')) === 'proxy' ? 'proxy' : 'direct'; } // 指向本站代理的图片地址。 // 逐段编码、保留斜杠(与 storageEncodeKey 同样的策略):URL 可读、nginx 日志好看。 // 目录穿越由 media.php 侧的白名单负责,不依赖这里的编码。 function storageProxyUrl($key) { $parts = array_map('rawurlencode', explode('/', ltrim((string)$key, '/'))); return '/media.php?k=' . implode('/', $parts); } /** * 生成可访问的 URL。规则: * 1. 已经是绝对地址 → 原样返回 * 2. 本地存在同名文件 → 走本地路径(同源,无需绕代理) * 3. 代理模式 → 指向本站 media.php * 4. 否则交给当前驱动生成 */ function storageUrl($key) { if ($key === null || trim((string)$key) === '') return null; $key = (string)$key; if (storageIsAbsoluteUrl($key)) return $key; $trimmed = ltrim($key, '/'); if (storageHasLocalFile($trimmed)) return '/' . $trimmed; if (storageAccessMode() === 'proxy') return storageProxyUrl($trimmed); return storage()->url($trimmed); } /** * 服务端读取对象内容(供 media.php 回吐)。 * 本地优先:本地有文件就直接读盘,省一次外网往返;否则交给驱动。 * $conditional 为透传给上游的条件头(如 ['If-None-Match: "abc"'])。 */ function storageRead($key, array $conditional = []) { $key = ltrim((string)$key, '/'); if ($key === '') { return ['ok' => false, 'status' => 400, 'headers' => [], 'body' => '', 'error' => '缺少对象键']; } if (storageHasLocalFile($key)) { $abs = ROOT_DIR . '/' . $key; $body = @file_get_contents($abs); if ($body !== false) { return [ 'ok' => true, 'status' => 200, 'headers' => [ 'content-type' => storageMimeOfFile($abs), 'content-length' => (string)strlen($body), 'etag' => '"' . md5($body) . '"', 'last-modified' => gmdate('D, d M Y H:i:s \G\M\T', (int)@filemtime($abs)), ], 'body' => $body, 'error' => '', ]; } } $drv = storage(); if (!method_exists($drv, 'get')) { return [ 'ok' => false, 'status' => 501, 'headers' => [], 'body' => '', 'error' => '当前存储驱动(' . $drv->label() . ')暂不支持服务端读取', ]; } return $drv->get($key, $conditional); } /** * 判断图片是否"应当被视为存在"。 * 本地:真实检查文件;云端:键非空即视为存在(远端不做逐次 HEAD 探测)。 */ function storageExists($key) { if ($key === null || trim((string)$key) === '') return false; if (storageIsAbsoluteUrl($key)) return true; if (storageHasLocalFile($key)) return true; return storageIsRemote(); } // ---------- MIME ---------- function storageMimeOfFile($path) { if (function_exists('finfo_open') && is_file($path)) { $finfo = finfo_open(FILEINFO_MIME_TYPE); if ($finfo) { $mime = finfo_file($finfo, $path); finfo_close($finfo); if ($mime) return $mime; } } return storageMimeFromName($path); } function storageMimeFromContents($binary) { if (function_exists('finfo_open')) { $finfo = finfo_open(FILEINFO_MIME_TYPE); if ($finfo) { $mime = finfo_buffer($finfo, $binary); finfo_close($finfo); if ($mime) return $mime; } } return 'application/octet-stream'; } function storageMimeFromName($name) { $map = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'txt' => 'text/plain', ]; $ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION)); return $map[$ext] ?? 'application/octet-stream'; } // ---------- 诊断(设置页用)---------- // 探测当前服务器是否位于某朵云上(仅做可达性判断,不拉取凭据) function storageDetectCloudHost() { $hosts = ['aliyun' => 'ECS(阿里云)', 'tencent' => 'CVM(腾讯云)']; $found = []; foreach ($hosts as $key => $label) { $cred = $key === 'aliyun' ? aliyunEcsRamCredentials('', true) : tencentCvmCamCredentials('', true); if (!empty($cred['ok'])) { $found[$key] = ['label' => $label, 'role' => $cred['role'] ?? '', 'expires' => $cred['expires'] ?? 0]; } } return $found; } // ---------- 迁移改名:改写数据库里的图片引用 ---------- /** * 数据库里所有存放「图片相对键」的字段(表, 字段)。 * * 迁移时若把文件按指纹改名,必须一并改写这些引用, * 否则库里仍指向旧名,图片会裂开——特别是勾选了"迁移后删除本地文件"时无法回退。 */ function storageImageRefFields() { return [ ['movies', 'poster_path'], ['movies', 'ticket_path'], ['movies', 'photo_path'], // 遗留字段,仍可能有历史数据 ['movie_photos', 'photo_path'], ['persons', 'avatar'], ['admins', 'avatar'], ]; } /** * 把所有等于 $oldKey 的图片引用改写为 $newKey。 * 同一条旧键被多行引用时会一次全部改掉。 * * @return int 受影响行数合计 */ function storageRewriteImageRef($oldKey, $newKey) { $oldKey = (string)$oldKey; $newKey = (string)$newKey; if ($oldKey === '' || $newKey === '' || $oldKey === $newKey) return 0; try { $db = getDB(); } catch (Throwable $e) { return 0; } $affected = 0; foreach (storageImageRefFields() as $pair) { list($table, $column) = $pair; // 表名/字段名来自上面的硬编码清单,非用户输入 try { $stmt = $db->prepare("UPDATE {$table} SET {$column} = ? WHERE {$column} = ?"); $stmt->execute([$newKey, $oldKey]); $affected += $stmt->rowCount(); } catch (PDOException $e) { // 表或字段不存在(旧库)时跳过,不阻断迁移 } } return $affected; }