Files

368 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\library;
use ba\Random;
use ba\Filesystem;
use app\common\model\Attachment;
use app\common\library\upload\Driver;
use app\common\library\upload\WebmanUploadedFile;
use InvalidArgumentException;
use RuntimeException;
/**
* 上传Webman 迁移版,使用 WebmanUploadedFile
*/
class Upload
{
protected array $config = [];
protected ?WebmanUploadedFile $file = null;
protected bool $isImage = false;
protected array $fileInfo = [];
protected array $driver = [
'name' => 'local',
'handler' => [],
'namespace' => '\\app\\common\\library\\upload\\driver\\',
];
protected string $topic = 'default';
public function __construct(?WebmanUploadedFile $file = null, array $config = [])
{
$upload = config('upload', []);
$this->config = array_merge($upload, $config);
if ($file) {
$this->setFile($file);
}
}
public function setFile(?WebmanUploadedFile $file): self
{
if (empty($file)) {
throw new RuntimeException(__('No files were uploaded'));
}
$suffix = strtolower($file->extension());
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
$this->fileInfo = [
'suffix' => $suffix,
'type' => $file->getMime(),
'size' => $file->getSize(),
'name' => $file->getOriginalName(),
'sha1' => $file->sha1(),
];
$this->file = $file;
return $this;
}
public function setDriver(string $driver): self
{
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $driver)) {
throw new InvalidArgumentException(__('Driver %s not supported', [$driver]));
}
$allowed = $this->checkConfig($this->config['allowed_drivers'] ?? 'local');
if ($allowed && !in_array(strtolower($driver), $allowed)) {
throw new InvalidArgumentException(__('Driver %s not supported', [$driver]));
}
$this->driver['name'] = $driver;
return $this;
}
public function getDriver(?string $driver = null, bool $noDriveException = true): Driver|false
{
$driver = $driver ?? $this->driver['name'];
if (!isset($this->driver['handler'][$driver])) {
$class = $this->resolveDriverClass($driver);
if ($class) {
$this->driver['handler'][$driver] = new $class();
} elseif ($noDriveException) {
throw new InvalidArgumentException(__('Driver %s not supported', [$driver]));
}
}
return $this->driver['handler'][$driver] ?? false;
}
protected function resolveDriverClass(string $driver): string|false
{
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $driver)) {
return false;
}
$allowed = $this->checkConfig($this->config['allowed_drivers'] ?? 'local');
if ($allowed && !in_array(strtolower($driver), $allowed)) {
return false;
}
$class = $this->driver['namespace'] . $this->studly($driver);
if (class_exists($class)) {
return $class;
}
return false;
}
protected function studly(string $value): string
{
$value = ucwords(str_replace(['-', '_'], ' ', $value));
return str_replace(' ', '', $value);
}
public function setTopic(string $topic): self
{
$this->topic = $topic;
return $this;
}
protected function checkIsImage(): bool
{
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'])) {
$path = $this->file->getPathname();
$imgInfo = is_file($path) ? getimagesize($path) : false;
if (!$imgInfo || !isset($imgInfo[0]) || !isset($imgInfo[1])) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
$this->fileInfo['width'] = $imgInfo[0];
$this->fileInfo['height'] = $imgInfo[1];
$this->isImage = true;
return true;
}
return false;
}
public function isImage(): bool
{
return $this->isImage;
}
public function getSuffix(): string
{
return $this->fileInfo['suffix'] ?? 'file';
}
public function getSaveName(?string $saveName = null, ?string $filename = null, ?string $sha1 = null): string
{
if ($filename) {
$suffix = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
} else {
$suffix = $this->fileInfo['suffix'];
}
$filename = $filename ?? $this->fileInfo['name'];
$sha1 = $sha1 ?? $this->fileInfo['sha1'];
$replaceArr = [
'{topic}' => $this->topic,
'{year}' => date('Y'),
'{mon}' => date('m'),
'{day}' => date('d'),
'{hour}' => date('H'),
'{min}' => date('i'),
'{sec}' => date('s'),
'{random}' => Random::build(),
'{random32}' => Random::build('alnum', 32),
'{fileName}' => $this->getFileNameSubstr($filename, $suffix),
'{suffix}' => $suffix,
'{.suffix}' => $suffix ? '.' . $suffix : '',
'{fileSha1}' => $sha1,
];
$saveName = $saveName ?? $this->config['save_name'];
return Filesystem::fsFit(str_replace(array_keys($replaceArr), array_values($replaceArr), $saveName));
}
public function validates(): void
{
if (empty($this->file)) {
throw new RuntimeException(__('No files have been uploaded or the file size exceeds the upload limit of the server'));
}
$size = Filesystem::fileUnitToByte($this->config['max_size'] ?? '10M');
$mime = $this->checkConfig($this->config['allowed_mime_types'] ?? []);
$suffix = $this->checkConfig($this->config['allowed_suffixes'] ?? '');
if ($this->fileInfo['size'] > $size) {
throw new RuntimeException(__('The uploaded file is too large (%sMiB), Maximum file size:%sMiB', [
round($this->fileInfo['size'] / pow(1024, 2), 2),
round($size / pow(1024, 2), 2)
]));
}
if ($suffix && !in_array(strtolower($this->fileInfo['suffix']), $suffix)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
if ($mime && !in_array(strtolower($this->fileInfo['type']), $mime)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
$this->assertSafeFileName($this->fileInfo['name']);
$this->assertNoMaliciousContent();
$this->assertImageMime();
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $this->topic)) {
throw new RuntimeException(__('Topic format error'));
}
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $this->driver['name'])) {
throw new RuntimeException(__('Driver %s not supported', [$this->driver['name']]));
}
if ($this->checkIsImage()) {
$maxW = $this->config['image_max_width'] ?? 0;
$maxH = $this->config['image_max_height'] ?? 0;
if ($maxW && $this->fileInfo['width'] > $maxW) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
if ($maxH && $this->fileInfo['height'] > $maxH) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
}
}
public function upload(?string $saveName = null, int $adminId = 0, int $userId = 0): array
{
$this->validates();
$driver = $this->getDriver();
if (!$driver) {
throw new RuntimeException(__('Driver %s not supported', [$this->driver['name']]));
}
$saveName = $saveName ?: $this->getSaveName();
$params = [
'topic' => $this->topic,
'admin_id' => $adminId,
'user_id' => $userId,
'url' => $driver->url($saveName, false),
'width' => $this->fileInfo['width'] ?? 0,
'height' => $this->fileInfo['height'] ?? 0,
'name' => $this->getFileNameSubstr($this->fileInfo['name'], $this->fileInfo['suffix'], 100) . '.' . $this->fileInfo['suffix'],
'size' => $this->fileInfo['size'],
'mimetype' => $this->fileInfo['type'],
'storage' => $this->driver['name'],
'sha1' => $this->fileInfo['sha1'],
];
$attachment = Attachment::where('sha1', $params['sha1'])
->where('topic', $params['topic'])
->where('storage', $params['storage'])
->find();
if ($attachment && $driver->exists($attachment->url)) {
$attachment->quote++;
$attachment->last_upload_time = time();
} else {
$driver->save($this->file, $saveName);
$attachment = new Attachment();
$attachment->data(array_filter($params));
}
$attachment->save();
return $attachment->toArray();
}
public function getFileNameSubstr(string $fileName, string $suffix, int $length = 15): string
{
$pattern = "/[\s:@#?&\/=',+]+/u";
$fileName = str_replace(".$suffix", '', $fileName);
$fileName = preg_replace($pattern, '', $fileName);
return mb_substr(htmlspecialchars(strip_tags($fileName)), 0, $length);
}
protected function checkConfig(mixed $configItem): array
{
if (is_array($configItem)) {
return array_map('strtolower', $configItem);
}
return $configItem ? explode(',', strtolower((string)$configItem)) : [];
}
protected function getForbiddenSuffixes(): array
{
return $this->checkConfig($this->config['forbidden_suffixes'] ?? '');
}
protected function assertSafeFileName(string $fileName): void
{
$forbidden = $this->getForbiddenSuffixes();
if (!$forbidden) {
return;
}
$parts = explode('.', strtolower($fileName));
if (count($parts) < 2) {
return;
}
foreach ($parts as $index => $part) {
if ($part === '') {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
if ($index === count($parts) - 1) {
continue;
}
if (in_array($part, $forbidden)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
}
$suffix = $parts[count($parts) - 1];
if (in_array($suffix, $forbidden)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
}
protected function assertNoMaliciousContent(): void
{
$path = $this->file->getPathname();
if (!is_file($path) || !is_readable($path)) {
return;
}
$handle = fopen($path, 'rb');
if (!$handle) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
$chunk = fread($handle, 8192);
fclose($handle);
if ($chunk === false || $chunk === '') {
return;
}
$patterns = [
'/<\?php/i',
'/<\?=/i',
'/<\?(?!xml)/i',
'/<%[@=]?/i',
'/<script\b/i',
'/\b(eval|assert|shell_exec|system|passthru|proc_open|popen|base64_decode)\s*\(/i',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $chunk)) {
throw new RuntimeException(__('The uploaded file contains prohibited content'));
}
}
}
protected function assertImageMime(): void
{
$imageSuffixes = ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp'];
if (!in_array($this->fileInfo['suffix'], $imageSuffixes, true)) {
return;
}
$path = $this->file->getPathname();
if (!is_file($path)) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$detected = finfo_file($finfo, $path);
finfo_close($finfo);
if (!$detected || !str_starts_with(strtolower($detected), 'image/')) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
}
}
}
}