Upload.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. <?php
  2. namespace app\common\library;
  3. use Throwable;
  4. use ba\Random;
  5. use ba\Filesystem;
  6. use think\Exception;
  7. use think\helper\Str;
  8. use think\facade\Config;
  9. use think\facade\Validate;
  10. use think\file\UploadedFile;
  11. use InvalidArgumentException;
  12. use think\validate\ValidateRule;
  13. use app\common\model\Attachment;
  14. use app\common\library\upload\Driver;
  15. /**
  16. * 上传
  17. */
  18. class Upload
  19. {
  20. /**
  21. * 上传配置
  22. */
  23. protected array $config = [];
  24. /**
  25. * 被上传文件
  26. */
  27. protected ?UploadedFile $file = null;
  28. /**
  29. * 是否是图片
  30. */
  31. protected bool $isImage = false;
  32. /**
  33. * 文件信息
  34. */
  35. protected array $fileInfo;
  36. /**
  37. * 上传驱动
  38. */
  39. protected array $driver = [
  40. 'name' => 'local', // 默认驱动:local=本地
  41. 'handler' => [], // 驱动句柄
  42. 'namespace' => '\\app\\common\\library\\upload\\driver\\', // 驱动类的命名空间
  43. ];
  44. /**
  45. * 存储子目录
  46. */
  47. protected string $topic = 'default';
  48. /**
  49. * 构造方法
  50. * @param ?UploadedFile $file 上传的文件
  51. * @param array $config 配置
  52. * @throws Throwable
  53. */
  54. public function __construct(?UploadedFile $file = null, array $config = [])
  55. {
  56. $upload = Config::get('upload');
  57. $this->config = array_merge($upload, $config);
  58. if ($file) {
  59. $this->setFile($file);
  60. }
  61. }
  62. /**
  63. * 设置上传文件
  64. * @param ?UploadedFile $file
  65. * @return Upload
  66. * @throws Throwable
  67. */
  68. public function setFile(?UploadedFile $file): Upload
  69. {
  70. if (empty($file)) {
  71. throw new Exception(__('No files were uploaded'));
  72. }
  73. $suffix = strtolower($file->extension());
  74. $suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
  75. $fileInfo['suffix'] = $suffix;
  76. $fileInfo['type'] = $file->getMime();
  77. $fileInfo['size'] = $file->getSize();
  78. $fileInfo['name'] = $file->getOriginalName();
  79. $fileInfo['sha1'] = $file->sha1();
  80. $this->file = $file;
  81. $this->fileInfo = $fileInfo;
  82. return $this;
  83. }
  84. /**
  85. * 设置上传驱动
  86. */
  87. public function setDriver(string $driver): Upload
  88. {
  89. $this->driver['name'] = $driver;
  90. return $this;
  91. }
  92. /**
  93. * 获取上传驱动句柄
  94. * @param ?string $driver 驱动名称
  95. * @param bool $noDriveException 找不到驱动是否抛出异常
  96. * @return bool|Driver
  97. */
  98. public function getDriver(?string $driver = null, bool $noDriveException = true): bool|Driver
  99. {
  100. if (is_null($driver)) {
  101. $driver = $this->driver['name'];
  102. }
  103. if (!isset($this->driver['handler'][$driver])) {
  104. $class = $this->resolveDriverClass($driver);
  105. if ($class) {
  106. $this->driver['handler'][$driver] = new $class();
  107. } elseif ($noDriveException) {
  108. throw new InvalidArgumentException(__('Driver %s not supported', [$driver]));
  109. }
  110. }
  111. return $this->driver['handler'][$driver] ?? false;
  112. }
  113. /**
  114. * 获取驱动类
  115. */
  116. protected function resolveDriverClass(string $driver): bool|string
  117. {
  118. if ($this->driver['namespace'] || str_contains($driver, '\\')) {
  119. $class = str_contains($driver, '\\') ? $driver : $this->driver['namespace'] . Str::studly($driver);
  120. if (class_exists($class)) {
  121. return $class;
  122. }
  123. }
  124. return false;
  125. }
  126. /**
  127. * 设置存储子目录
  128. */
  129. public function setTopic(string $topic): Upload
  130. {
  131. $this->topic = $topic;
  132. return $this;
  133. }
  134. /**
  135. * 检查是否是图片并设置好相关属性
  136. * @return bool
  137. * @throws Throwable
  138. */
  139. protected function checkIsImage(): bool
  140. {
  141. if (in_array($this->fileInfo['type'], ['image/gif', 'image/jpg', 'image/jpeg', 'image/bmp', 'image/png', 'image/webp']) || in_array($this->fileInfo['suffix'], ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp'])) {
  142. $imgInfo = getimagesize($this->file->getPathname());
  143. if (!$imgInfo || !isset($imgInfo[0]) || !isset($imgInfo[1])) {
  144. throw new Exception(__('The uploaded image file is not a valid image'));
  145. }
  146. $this->fileInfo['width'] = $imgInfo[0];
  147. $this->fileInfo['height'] = $imgInfo[1];
  148. $this->isImage = true;
  149. return true;
  150. }
  151. return false;
  152. }
  153. /**
  154. * 上传的文件是否为图片
  155. * @return bool
  156. */
  157. public function isImage(): bool
  158. {
  159. return $this->isImage;
  160. }
  161. /**
  162. * 获取文件后缀
  163. * @return string
  164. */
  165. public function getSuffix(): string
  166. {
  167. return $this->fileInfo['suffix'] ?: 'file';
  168. }
  169. /**
  170. * 获取文件保存路径和名称
  171. * @param ?string $saveName
  172. * @param ?string $filename
  173. * @param ?string $sha1
  174. * @return string
  175. */
  176. public function getSaveName(?string $saveName = null, ?string $filename = null, ?string $sha1 = null): string
  177. {
  178. if ($filename) {
  179. $suffix = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
  180. $suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
  181. } else {
  182. $suffix = $this->fileInfo['suffix'];
  183. }
  184. $filename = $filename ?: $this->fileInfo['name'];
  185. $sha1 = $sha1 ?: $this->fileInfo['sha1'];
  186. $replaceArr = [
  187. '{topic}' => $this->topic,
  188. '{year}' => date("Y"),
  189. '{mon}' => date("m"),
  190. '{day}' => date("d"),
  191. '{hour}' => date("H"),
  192. '{min}' => date("i"),
  193. '{sec}' => date("s"),
  194. '{random}' => Random::build(),
  195. '{random32}' => Random::build('alnum', 32),
  196. '{fileName}' => $this->getFileNameSubstr($filename, $suffix),
  197. '{suffix}' => $suffix,
  198. '{.suffix}' => $suffix ? '.' . $suffix : '',
  199. '{fileSha1}' => $sha1,
  200. ];
  201. $saveName = $saveName ?: $this->config['save_name'];
  202. return Filesystem::fsFit(str_replace(array_keys($replaceArr), array_values($replaceArr), $saveName));
  203. }
  204. /**
  205. * 验证文件是否符合上传配置要求
  206. * @throws Throwable
  207. */
  208. public function validates(): void
  209. {
  210. if (empty($this->file)) {
  211. throw new Exception(__('No files have been uploaded or the file size exceeds the upload limit of the server'));
  212. }
  213. $size = Filesystem::fileUnitToByte($this->config['max_size']);
  214. $mime = $this->checkConfig($this->config['allowed_mime_types']);
  215. $suffix = $this->checkConfig($this->config['allowed_suffixes']);
  216. // 文件大小
  217. $fileValidateRule = ValidateRule::fileSize($size, __('The uploaded file is too large (%sMiB), Maximum file size:%sMiB', [
  218. round($this->fileInfo['size'] / pow(1024, 2), 2),
  219. round($size / pow(1024, 2), 2)
  220. ]));
  221. // 文件后缀
  222. if ($suffix) {
  223. $fileValidateRule->fileExt($suffix, __('The uploaded file format is not allowed'));
  224. }
  225. // 文件 MIME 类型
  226. if ($mime) {
  227. $fileValidateRule->fileMime($mime, __('The uploaded file format is not allowed'));
  228. }
  229. // 图片文件利用tp内置规则做一些额外检查
  230. if ($this->checkIsImage()) {
  231. $fileValidateRule->image("{$this->fileInfo['width']},{$this->fileInfo['height']}", __('The uploaded image file is not a valid image'));
  232. }
  233. Validate::failException()
  234. ->rule([
  235. 'file' => $fileValidateRule,
  236. 'topic' => ValidateRule::is('alphaDash', __('Topic format error')),
  237. 'driver' => ValidateRule::is('alphaDash', __('Driver %s not supported', [$this->driver['name']])),
  238. ])
  239. ->check([
  240. 'file' => $this->file,
  241. 'topic' => $this->topic,
  242. 'driver' => $this->driver['name'],
  243. ]);
  244. }
  245. /**
  246. * 上传文件
  247. * @param ?string $saveName
  248. * @param int $adminId
  249. * @param int $userId
  250. * @return array
  251. * @throws Throwable
  252. */
  253. public function upload(?string $saveName = null, int $adminId = 0, int $userId = 0): array
  254. {
  255. $this->validates();
  256. $driver = $this->getDriver();
  257. $saveName = $saveName ?: $this->getSaveName();
  258. $params = [
  259. 'topic' => $this->topic,
  260. 'admin_id' => $adminId,
  261. 'user_id' => $userId,
  262. 'url' => $driver->url($saveName, false),
  263. 'width' => $this->fileInfo['width'] ?? 0,
  264. 'height' => $this->fileInfo['height'] ?? 0,
  265. 'name' => $this->getFileNameSubstr($this->fileInfo['name'], $this->fileInfo['suffix'], 100) . ".{$this->fileInfo['suffix']}",
  266. 'size' => $this->fileInfo['size'],
  267. 'mimetype' => $this->fileInfo['type'],
  268. 'storage' => $this->driver['name'],
  269. 'sha1' => $this->fileInfo['sha1']
  270. ];
  271. // 附件数据入库 - 不依赖模型新增前事件,确保入库前文件已经移动完成
  272. $attachment = Attachment::where('sha1', $params['sha1'])
  273. ->where('topic', $params['topic'])
  274. ->where('storage', $params['storage'])
  275. ->find();
  276. if ($attachment && $driver->exists($attachment->url)) {
  277. $attachment->quote++;
  278. $attachment->last_upload_time = time();
  279. } else {
  280. $driver->save($this->file, $saveName);
  281. $attachment = new Attachment();
  282. $attachment->data(array_filter($params));
  283. }
  284. $attachment->save();
  285. return $attachment->toArray();
  286. }
  287. /**
  288. * 获取文件名称字符串的子串
  289. */
  290. public function getFileNameSubstr(string $fileName, string $suffix, int $length = 15): string
  291. {
  292. // 对 $fileName 中不利于传输的字符串进行过滤
  293. $pattern = "/[\s:@#?&\/=',+]+/u";
  294. $fileName = str_replace(".$suffix", '', $fileName);
  295. $fileName = preg_replace($pattern, '', $fileName);
  296. return mb_substr(htmlspecialchars(strip_tags($fileName)), 0, $length);
  297. }
  298. /**
  299. * 检查配置项,将 string 类型的配置转换为 array,并且将所有字母转换为小写
  300. */
  301. protected function checkConfig($configItem): array
  302. {
  303. if (is_array($configItem)) {
  304. return array_map('strtolower', $configItem);
  305. } else {
  306. return explode(',', strtolower($configItem));
  307. }
  308. }
  309. }