feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -10,7 +10,10 @@ namespace ywxapp\service;
|
||||
|
||||
use think\Exception;
|
||||
use think\facade\Config;
|
||||
use think\facade\Event;
|
||||
use think\facade\Log;
|
||||
use think\file\UploadedFile;
|
||||
use ywxapp\model\CommonAttachment;
|
||||
use ywxapp\utils\storage\StorageInterface;
|
||||
|
||||
/**
|
||||
@@ -63,37 +66,109 @@ class FileStorageService
|
||||
|
||||
/**
|
||||
* 上传文件(对外调用的方法)
|
||||
*
|
||||
* 提供两个事件扩展点:
|
||||
* - FileUpload 前置分发事件:插件监听并返回结果数组即接管上传(OSS / FTP / 自定义存储)
|
||||
* - FileUploaded 后置通知事件:上传成功后触发,供缩略图、水印、审计等扩展
|
||||
*
|
||||
* @param UploadedFile $file 上传的文件对象
|
||||
* @param string $path 存储路径,留空自动生成
|
||||
* @param array $options ['module' => 模块标识, 'rules' => 自定义校验规则(空数组=不校验)]
|
||||
* @return array
|
||||
*/
|
||||
public function upload(UploadedFile $file, string $path = '', array $options = []): array
|
||||
{
|
||||
|
||||
// 1. 基础验证(例如文件大小、类型)
|
||||
// 1. 基础验证(文件大小、类型,可用 options.rules 自定义)
|
||||
$this->validateFile($file, $options);
|
||||
// 2. 如果未指定路径,生成默认路径
|
||||
// 2. 未指定路径时生成默认路径
|
||||
if (empty($path)) {
|
||||
$path = $this->generatePath($file);
|
||||
}
|
||||
// throw new \think\Exception(json_encode( [
|
||||
// $file->getoriginalName(), //获取文件原始名称带后缀
|
||||
// $file->getSize(), //获取文件大小
|
||||
// $file->extension(), //获取文件后缀
|
||||
// ])
|
||||
// , 403);
|
||||
// 3. 调用具体驱动的上传方法
|
||||
|
||||
$data = [
|
||||
// 3. 前置事件 FileUpload:插件可接管上传,返回结果数组即接管成功
|
||||
$handled = Event::trigger('FileUpload', [
|
||||
'file' => $file,
|
||||
'path' => $path,
|
||||
'options' => $options,
|
||||
], true);
|
||||
if ($handled) {
|
||||
$data = $this->buildMeta($file, $handled, $options);
|
||||
return $this->finalize($data, $options);
|
||||
}
|
||||
|
||||
// 4. 调用内置存储驱动上传
|
||||
list($name, $path, $url) = $this->storage->upload($file, $path, $options);
|
||||
$data = $this->buildMeta($file, [
|
||||
'name' => $name,
|
||||
'path' => $path,
|
||||
'url' => $url,
|
||||
'storage' => $this->config['default'] ?? self::DRIVER_LOCAL,
|
||||
], $options);
|
||||
|
||||
// 5. 写入附件记录 + 后置事件 FileUploaded
|
||||
return $this->finalize($data, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传收尾:写入附件表记录、触发 FileUploaded 事件、返回带 id 的数据
|
||||
*/
|
||||
protected function finalize(array $data, array $options): array
|
||||
{
|
||||
$data = $this->recordAttachment($data, $options);
|
||||
Event::trigger('FileUploaded', $data);
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入附件表记录(落库失败不影响上传主流程)
|
||||
*/
|
||||
protected function recordAttachment(array $data, array $options): array
|
||||
{
|
||||
try {
|
||||
$row = CommonAttachment::create([
|
||||
'uid' => (int) ($options['uid'] ?? 0),
|
||||
'module' => (string) ($data['module'] ?? 'index'),
|
||||
'path' => (string) ($data['path'] ?? ''),
|
||||
'url' => (string) ($data['url'] ?? ''),
|
||||
'original' => (string) ($data['original'] ?? ''),
|
||||
'name' => (string) ($data['name'] ?? ''),
|
||||
'size' => (int) ($data['size'] ?? 0),
|
||||
'ext' => (string) ($data['ext'] ?? ''),
|
||||
'mime' => (string) ($data['mime'] ?? ''),
|
||||
'storage' => (string) ($data['storage'] ?? self::DRIVER_LOCAL),
|
||||
'driver_info' => $data['driver_info'] ?? [],
|
||||
'is_image' => (int) ($data['is_image'] ?? 0),
|
||||
'width' => (int) ($data['width'] ?? 0),
|
||||
'height' => (int) ($data['height'] ?? 0),
|
||||
'upload_ip' => (string) ($data['upload_ip'] ?? ''),
|
||||
]);
|
||||
$data['id'] = (int) $row->id;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('附件落库失败: ' . $e->getMessage());
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装上传元数据
|
||||
*
|
||||
* @param UploadedFile $file 上传的文件对象
|
||||
* @param array $extra 附加数据(name / path / url / storage 等),覆盖默认元数据
|
||||
* @param array $options 上传选项
|
||||
* @return array
|
||||
*/
|
||||
protected function buildMeta(UploadedFile $file, array $extra, array $options): array
|
||||
{
|
||||
$data = [
|
||||
'module' => $options['module'] ?? 'index', // 模块标识
|
||||
'original' => $file->getoriginalName(),
|
||||
'original' => $file->getOriginalName(),
|
||||
'size' => $file->getSize(),
|
||||
'ext' => $file->extension(),
|
||||
'mime' => $file->getOriginalMime(),
|
||||
'mime' => $file->getMime(),
|
||||
'storage' => $this->config['default'] ?? self::DRIVER_LOCAL,
|
||||
'is_image' => (int) str_starts_with($file->getMime(), 'image/'),
|
||||
'upload_ip' => request()->ip(),
|
||||
];
|
||||
|
||||
// 如果是图片且能获取到尺寸
|
||||
// 图片时尝试获取尺寸
|
||||
if ($data['is_image'] && function_exists('getimagesize')) {
|
||||
// 注意:如果是云存储,这里可能需要从临时文件获取尺寸
|
||||
$tempPath = $file->getRealPath();
|
||||
@@ -103,21 +178,30 @@ class FileStorageService
|
||||
$data['height'] = $imgInfo[1] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
list($name, $path, $url) = $this->storage->upload($file, $path, $options);
|
||||
$data['name'] = $name;
|
||||
$data['path'] = $path;
|
||||
$data['url'] = $url;
|
||||
|
||||
return $data;
|
||||
return array_merge($data, $extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
* 前置事件 FileDelete:插件监听并返回 true 即接管物理删除(OSS / FTP 删远端文件);
|
||||
* 无监听时走内置存储驱动。删除成功触发 FileDeleted 事件。
|
||||
*/
|
||||
public function delete(string $path): bool
|
||||
public function delete(string $path, array $options = []): bool
|
||||
{
|
||||
return $this->storage->delete($path);
|
||||
$handled = Event::trigger('FileDelete', [
|
||||
'path' => $path,
|
||||
'options' => $options,
|
||||
], true);
|
||||
if ($handled) {
|
||||
Event::trigger('FileDeleted', ['path' => $path, 'handled' => true]);
|
||||
return true;
|
||||
}
|
||||
$ok = $this->storage->delete($path);
|
||||
if ($ok) {
|
||||
Event::trigger('FileDeleted', ['path' => $path, 'handled' => false]);
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,31 +213,26 @@ class FileStorageService
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础文件验证
|
||||
* 基础文件验证,可用 options.rules 自定义规则,空数组表示不校验
|
||||
*/
|
||||
protected function validateFile(UploadedFile $file, array $options): void
|
||||
{
|
||||
|
||||
// 使用验证器验证上传的文件
|
||||
$rules = $options['rules'] ?? [
|
||||
// 限制文件大小(单位b),这里限制为4M
|
||||
'fileSize' => 4 * 1024 * 1024,
|
||||
// 限制文件后缀,多个后缀以英文逗号分割
|
||||
'fileExt' => 'gif,jpg,png',
|
||||
];
|
||||
if (empty($rules)) {
|
||||
return;
|
||||
}
|
||||
validate(
|
||||
[
|
||||
'file' => [
|
||||
// 限制文件大小(单位b),这里限制为4M
|
||||
'fileSize' => 4 * 1024 * 1024,
|
||||
// 限制文件后缀,多个后缀以英文逗号分割
|
||||
'fileExt' => 'gif,jpg,png',
|
||||
// fileSize 上传文件的最大字节;
|
||||
// fileExt 文件后缀,多个用逗号分割或者数组;
|
||||
// fileMime 文件MIME类型,多个用逗号分割或者数组;
|
||||
// image 验证图像文件的尺寸和类型,
|
||||
],
|
||||
],
|
||||
['file' => $rules],
|
||||
[
|
||||
'file.fileSize' => '文件太大',
|
||||
'file.fileExt' => '不支持的文件后缀',
|
||||
]
|
||||
)->check(['file' => $file]);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user