176 lines
5.9 KiB
PHP
176 lines
5.9 KiB
PHP
<?php
|
||||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
namespace App;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 存储门面:把「存哪里」这件事收在一处,业务代码不再关心本地还是 OSS。
|
|||
|
|
*
|
|||
|
|
* 键的约定:**对象键不含存储后端信息**,两种后端共用同一套命名
|
|||
|
|
* 本地: uploads/{key}
|
|||
|
|
* OSS : {settings.oss_prefix}{key}
|
|||
|
|
* 例:key = "attachments/20260922_ab12cd34.jpg"
|
|||
|
|
*
|
|||
|
|
* 页面里一律不直接用这里的键拼 URL —— 取文件走 media/show 代理
|
|||
|
|
* (桶是私有的,直链既取不到也不该暴露)。
|
|||
|
|
*/
|
|||
|
|
final class Storage
|
|||
|
|
{
|
|||
|
|
public const DRIVER_LOCAL = 'local';
|
|||
|
|
public const DRIVER_OSS = 'oss';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 当前生效的驱动。OSS 配置不全时**静默回退本地**,
|
|||
|
|
* 这样即使管理员填错了配置,上传也不会整个不可用。
|
|||
|
|
*/
|
|||
|
|
public static function driver(): string
|
|||
|
|
{
|
|||
|
|
if (Settings::get('storage_driver') === self::DRIVER_OSS && Settings::ossReady()) {
|
|||
|
|
return self::DRIVER_OSS;
|
|||
|
|
}
|
|||
|
|
return self::DRIVER_LOCAL;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 是否处于「选了 OSS 但配置不全,已回退本地」的状态(用于在界面上给出提示) */
|
|||
|
|
public static function ossMisconfigured(): bool
|
|||
|
|
{
|
|||
|
|
return Settings::get('storage_driver') === self::DRIVER_OSS && !Settings::ossReady();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static function label(?string $driver = null): string
|
|||
|
|
{
|
|||
|
|
return ($driver ?? self::driver()) === self::DRIVER_OSS ? '阿里云 OSS' : '本站服务器';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 生成一个不会撞的新对象键 */
|
|||
|
|
public static function keyFor(string $ext, string $dir = 'attachments'): string
|
|||
|
|
{
|
|||
|
|
$ext = ltrim(strtolower($ext), '.');
|
|||
|
|
$ext = preg_replace('/[^a-z0-9]/', '', $ext) ?: 'bin';
|
|||
|
|
return $dir . '/' . date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 写入一份内容。
|
|||
|
|
* @return array{storage:string, key:string, size:int}
|
|||
|
|
*/
|
|||
|
|
public static function put(string $key, string $body, string $mime): array
|
|||
|
|
{
|
|||
|
|
$driver = self::driver();
|
|||
|
|
|
|||
|
|
if ($driver === self::DRIVER_OSS) {
|
|||
|
|
$oss = Settings::ossClient();
|
|||
|
|
if ($oss === null) {
|
|||
|
|
throw new \RuntimeException('OSS 未配置完整');
|
|||
|
|
}
|
|||
|
|
$oss->put($key, $body, $mime);
|
|||
|
|
return ['storage' => self::DRIVER_OSS, 'key' => $key, 'size' => strlen($body)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$path = self::localPath($key);
|
|||
|
|
$dir = dirname($path);
|
|||
|
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
|
|||
|
|
throw new \RuntimeException('无法创建上传目录:' . $dir);
|
|||
|
|
}
|
|||
|
|
if (@file_put_contents($path, $body) === false) {
|
|||
|
|
throw new \RuntimeException('写入文件失败:' . $key);
|
|||
|
|
}
|
|||
|
|
return ['storage' => self::DRIVER_LOCAL, 'key' => $key, 'size' => strlen($body)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 读取内容;找不到返回 null */
|
|||
|
|
public static function get(string $storage, string $key, string $fileUrl = ''): ?string
|
|||
|
|
{
|
|||
|
|
if ($storage === self::DRIVER_OSS) {
|
|||
|
|
try {
|
|||
|
|
$oss = Settings::ossClient();
|
|||
|
|
return $oss === null ? null : $oss->get($key);
|
|||
|
|
} catch (\Throwable $e) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$path = self::resolveLocal($key, $fileUrl);
|
|||
|
|
if ($path === null || !is_file($path)) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
$data = @file_get_contents($path);
|
|||
|
|
return $data === false ? null : $data;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static function exists(string $storage, string $key, string $fileUrl = ''): bool
|
|||
|
|
{
|
|||
|
|
if ($storage === self::DRIVER_OSS) {
|
|||
|
|
$oss = Settings::ossClient();
|
|||
|
|
if ($oss === null) return false;
|
|||
|
|
try {
|
|||
|
|
return $oss->head($key) !== null;
|
|||
|
|
} catch (\Throwable $e) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
$path = self::resolveLocal($key, $fileUrl);
|
|||
|
|
return $path !== null && is_file($path);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 删除;返回是否确实删掉了(不存在也算成功,保持幂等) */
|
|||
|
|
public static function delete(string $storage, string $key, string $fileUrl = ''): bool
|
|||
|
|
{
|
|||
|
|
if ($storage === self::DRIVER_OSS) {
|
|||
|
|
try {
|
|||
|
|
$oss = Settings::ossClient();
|
|||
|
|
return $oss === null ? false : $oss->delete($key);
|
|||
|
|
} catch (\Throwable $e) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$path = self::resolveLocal($key, $fileUrl);
|
|||
|
|
if ($path === null) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
return !is_file($path) || @unlink($path);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 本地文件的绝对路径。只接受 uploads/ 下的相对键,挡住 `../` 之类的路径穿越。
|
|||
|
|
* $fileUrl 是历史遗留的 `/uploads/attachments/xxx.jpg` 形式,作为兼容兜底。
|
|||
|
|
*/
|
|||
|
|
public static function localPath(string $key): string
|
|||
|
|
{
|
|||
|
|
$key = ltrim(str_replace('\\', '/', $key), '/');
|
|||
|
|
if (strpos($key, '..') !== false) {
|
|||
|
|
throw new \RuntimeException('非法的对象键:' . $key);
|
|||
|
|
}
|
|||
|
|
return self::uploadsDir() . '/' . $key;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 解析本地文件的绝对路径(找不到/键非法返回 null)。
|
|||
|
|
* public 是给「展示层算体积」这类只读场景用的,仍会挡住路径穿越。
|
|||
|
|
*/
|
|||
|
|
public static function resolveLocal(string $key, string $fileUrl = ''): ?string
|
|||
|
|
{
|
|||
|
|
$key = trim($key) !== '' ? $key : '';
|
|||
|
|
if ($key !== '') {
|
|||
|
|
try {
|
|||
|
|
return self::localPath($key);
|
|||
|
|
} catch (\Throwable $e) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 老数据只有 file_url(/uploads/...)
|
|||
|
|
$url = trim($fileUrl);
|
|||
|
|
if ($url === '' || strpos($url, '/uploads/') !== 0 || strpos($url, '..') !== false) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return dirname(__DIR__) . $url;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static function uploadsDir(): string
|
|||
|
|
{
|
|||
|
|
return dirname(__DIR__) . '/uploads';
|
|||
|
|
}
|
|||
|
|
}
|