OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
458
app/Core/Files/FileManager.php
Normal file
458
app/Core/Files/FileManager.php
Normal file
@@ -0,0 +1,458 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Files;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Files\Contracts\FileManagerInterface;
|
||||
use Leantime\Core\Files\Exceptions\FileValidationException;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* FileManager - Service for handling file operations using Laravel's filesystem
|
||||
*/
|
||||
class FileManager implements FileManagerInterface
|
||||
{
|
||||
private FilesystemManager $filesystemManager;
|
||||
|
||||
private Environment $config;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct(
|
||||
FilesystemManager $filesystemManager,
|
||||
Environment $config,
|
||||
) {
|
||||
$this->filesystemManager = $filesystemManager;
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename to prevent path traversal and other security issues
|
||||
* Source: https://stackoverflow.com/questions/2021624/string-sanitizer-for-filename
|
||||
*
|
||||
* @param string $filename The filename to sanitize
|
||||
* @return string Sanitized filename
|
||||
*/
|
||||
private function sanitizeFilename(string $filename): string
|
||||
{
|
||||
// Remove any directory paths
|
||||
$filename = basename($filename);
|
||||
|
||||
// sanitize filename
|
||||
$filename = preg_replace(
|
||||
'~
|
||||
[<>:"/\\\|?*]| # file system reserved https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
|
||||
[\x00-\x1F]| # control characters http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx
|
||||
[\x7F\xA0\xAD]| # non-printing characters DEL, NO-BREAK SPACE, SOFT HYPHEN
|
||||
[#\[\]@!$&\'()+,;=]| # URI reserved https://www.rfc-editor.org/rfc/rfc3986#section-2.2
|
||||
[{}^\~`] # URL unsafe characters https://www.ietf.org/rfc/rfc1738.txt
|
||||
~x',
|
||||
'-', $filename);
|
||||
// avoids ".", ".." or ".hiddenFiles"
|
||||
$filename = ltrim($filename, '.-');
|
||||
|
||||
// maximize filename length to 255 bytes http://serverfault.com/a/9548/44086
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
|
||||
return mb_strcut(pathinfo($filename, PATHINFO_FILENAME), 0, 255 - ($ext ? strlen($ext) + 1 : 0), mb_detect_encoding($filename)).($ext ? '.'.$ext : '');
|
||||
}
|
||||
|
||||
public function filter_filename($filename, $beautify = true) {}
|
||||
|
||||
/**
|
||||
* File extensions that are never allowed to be uploaded.
|
||||
* These can be executed server-side or used to override server configuration.
|
||||
*/
|
||||
private const DENIED_EXTENSIONS = [
|
||||
'php',
|
||||
'phtml',
|
||||
'php3',
|
||||
'php4',
|
||||
'php5',
|
||||
'phar',
|
||||
'htaccess',
|
||||
'shtml',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates a file before upload
|
||||
*
|
||||
* @param UploadedFile $file The file to validate
|
||||
* @param string $module The module name
|
||||
*
|
||||
* @throws FileValidationException If the file is invalid
|
||||
*/
|
||||
private function validateFile(UploadedFile $file, string $module = 'default'): void
|
||||
{
|
||||
// Check if file is valid
|
||||
if (! $file->isValid() || $file->getError()) {
|
||||
Log::error('Invalid file upload attempt: '.$file->getErrorMessage());
|
||||
throw new FileValidationException('Invalid file upload attempt: '.$file->getErrorMessage(), FileValidationException::INVALID_FILE);
|
||||
}
|
||||
|
||||
// Check file size (10MB max by default, can be configured)
|
||||
$maxSize = self::getMaximumFileUploadSize();
|
||||
if ($file->getSize() > $maxSize) {
|
||||
throw new FileValidationException('File size exceeds the maximum allowed size of '.format($maxSize)->formatBytes(), FileValidationException::FILE_TOO_LARGE);
|
||||
}
|
||||
|
||||
// Reject dangerous file extensions that could be executed server-side
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
if (in_array($extension, self::DENIED_EXTENSIONS, true)) {
|
||||
Log::warning('Blocked upload of dangerous file type', [
|
||||
'extension' => $extension,
|
||||
'originalName' => $file->getClientOriginalName(),
|
||||
'userId' => session('userdata.id'),
|
||||
]);
|
||||
throw new FileValidationException(
|
||||
'File type .'.$extension.' is not allowed',
|
||||
FileValidationException::INVALID_MIME_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
// Also check for double extensions like file.php.jpg that could bypass
|
||||
// some server configurations
|
||||
$originalName = strtolower($file->getClientOriginalName());
|
||||
foreach (self::DENIED_EXTENSIONS as $deniedExt) {
|
||||
if (str_contains($originalName, '.'.$deniedExt.'.')) {
|
||||
Log::warning('Blocked upload with dangerous double extension', [
|
||||
'originalName' => $file->getClientOriginalName(),
|
||||
'userId' => session('userdata.id'),
|
||||
]);
|
||||
throw new FileValidationException(
|
||||
'File contains a disallowed extension in its name',
|
||||
FileValidationException::INVALID_MIME_TYPE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// SVG files can contain embedded scripts and event handlers.
|
||||
// Reject them outright to prevent stored XSS attacks.
|
||||
if ($extension === 'svg' || $extension === 'svgz') {
|
||||
Log::info('Blocked SVG upload for security', [
|
||||
'originalName' => $file->getClientOriginalName(),
|
||||
'userId' => session('userdata.id'),
|
||||
]);
|
||||
throw new FileValidationException(
|
||||
'SVG file uploads are not allowed for security reasons',
|
||||
FileValidationException::INVALID_MIME_TYPE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file
|
||||
*
|
||||
* @param UploadedFile $file The file to upload
|
||||
* @param string $disk The disk to use for storage
|
||||
* @return array|false Array with file info or false on failure
|
||||
*/
|
||||
public function upload(UploadedFile $file, $disk = 'default'): array|false
|
||||
{
|
||||
try {
|
||||
// Validate file before proceeding
|
||||
$this->validateFile($file, $disk);
|
||||
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$realName = $this->sanitizeFilename($file->getClientOriginalName());
|
||||
$fileName = $realName;
|
||||
|
||||
if ($disk === 'default') {
|
||||
$disk = $this->filesystemManager->getDefaultDriver();
|
||||
}
|
||||
|
||||
$visibility = null;
|
||||
if ($disk === 'public' && $this->config->useS3) {
|
||||
$disk = 's3';
|
||||
$visibility = 'public';
|
||||
}
|
||||
|
||||
$storage = $this->filesystemManager->disk($disk);
|
||||
|
||||
$newName = pathinfo($fileName, PATHINFO_FILENAME);
|
||||
if (config('filesystems.disks.'.$disk.'.renameFiles')) {
|
||||
$newName = md5(session('userdata.id').time());
|
||||
$fileName = $newName.'.'.$extension;
|
||||
}
|
||||
|
||||
// Store the file
|
||||
$stream = fopen($file->getRealPath(), 'r');
|
||||
$result = $storage->put($fileName, $stream, $visibility);
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
return [
|
||||
'encName' => $newName,
|
||||
'realName' => $realName,
|
||||
'extension' => $extension,
|
||||
'fileName' => $fileName,
|
||||
'newPath' => $newName.'.'.$extension,
|
||||
'path' => $fileName,
|
||||
'moduleId' => '',
|
||||
'module' => '',
|
||||
'userId' => session('userdata.id'),
|
||||
'fileId' => '',
|
||||
'uploadTime' => time(),
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Enhanced error logging
|
||||
Log::error('File upload failed: '.$e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'file' => $file->getClientOriginalName(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file
|
||||
*
|
||||
* @param string $fileName The file name (with extension)
|
||||
* @param string $realName The original file name (for Content-Disposition)
|
||||
* @param string $disk The disk to use for storage
|
||||
* @return Response|false Response object or false on failure
|
||||
*/
|
||||
public function getFile(string $fileName, string $realName, string $disk = 'default'): Response|false
|
||||
{
|
||||
try {
|
||||
// Determine the disk to use
|
||||
if ($disk === 'default') {
|
||||
$disk = $this->filesystemManager->getDefaultDriver();
|
||||
}
|
||||
|
||||
// Public disk but using s3 means we are getting files from S3 but they have public visibility
|
||||
if ($disk === 'public' && $this->config->useS3) {
|
||||
$disk = 's3';
|
||||
}
|
||||
|
||||
$storage = $this->filesystemManager->disk($disk);
|
||||
|
||||
// Check if file exists
|
||||
if (! $storage->exists($fileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get file mime type
|
||||
$mimeType = $storage->mimeType($fileName) ?: 'application/octet-stream';
|
||||
|
||||
// Read file contents directly instead of using download() which relies
|
||||
// on fpassthru() — a function disabled on many shared hosting environments.
|
||||
$content = $storage->get($fileName);
|
||||
|
||||
$response = new Response($content);
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
$response->headers->set('Content-Length', (string) $storage->size($fileName));
|
||||
$response->headers->set('Content-Disposition', 'inline; filename="'.$realName.'"');
|
||||
|
||||
// Sandbox all user-uploaded files to prevent script execution
|
||||
$response->headers->set('Content-Security-Policy', 'sandbox');
|
||||
|
||||
// Force download for content types that can execute scripts (HTML, SVG, XML)
|
||||
// to prevent inline rendering of potentially malicious content
|
||||
$dangerousMimeTypes = [
|
||||
'text/html',
|
||||
'application/xhtml+xml',
|
||||
'image/svg+xml',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
];
|
||||
|
||||
if (in_array(strtolower($mimeType), $dangerousMimeTypes, true)) {
|
||||
$response->headers->set('Content-Disposition', 'attachment; filename="'.$realName.'"');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
if (! $this->config->debug) {
|
||||
$response->headers->set('Pragma', 'public');
|
||||
$response->headers->set('Cache-Control', 'max-age=86400');
|
||||
$response->headers->set('Last-Modified', gmdate('D, d M Y H:i:s', $storage->lastModified($fileName)).' GMT');
|
||||
}
|
||||
|
||||
return $response;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error getting file: '.$e->getMessage(), [
|
||||
'fileName' => $fileName,
|
||||
'disk' => $disk,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file URL
|
||||
*
|
||||
* @param string $fileName The file name (with extension)
|
||||
* @param string $disk The disk to use for storage
|
||||
* @param int $expires Number of minutes before URL expires (for S3)
|
||||
* @return string|false File URL or false on failure
|
||||
*/
|
||||
public function getFileUrl(string $fileName, string $disk = 'default', $expires = 0): string|false
|
||||
{
|
||||
try {
|
||||
// Determine the disk to use
|
||||
if ($disk === 'default') {
|
||||
$disk = $this->filesystemManager->getDefaultDriver();
|
||||
}
|
||||
|
||||
if ($disk === 'public' && $this->config->useS3) {
|
||||
$disk = 's3';
|
||||
}
|
||||
|
||||
$storage = $this->filesystemManager->disk($disk);
|
||||
|
||||
// Check if file exists
|
||||
if (! $storage->exists($fileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get file mime type
|
||||
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
|
||||
$mimeType = $this->filesystemManager->mimeType($fileName);
|
||||
|
||||
if ($disk === 's3') {
|
||||
try {
|
||||
// Generate a signed URL with proper expiration
|
||||
// Use configured expiration time if not specified
|
||||
if ($expires <= 0) {
|
||||
$expires = (int) $this->config->get('filesystems.url_expiration', 60);
|
||||
}
|
||||
|
||||
// Add some randomness to prevent cache stampede
|
||||
$jitter = random_int(0, min(5, $expires / 10));
|
||||
$expiration = dtHelper()->now()->addMinutes($expires + $jitter);
|
||||
|
||||
return $storage->temporaryUrl($fileName, $expiration);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to generate S3 temporary URL: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Use caching for non-S3 files
|
||||
$cacheEnabled = $this->config->get('filesystems.cache.enabled', true);
|
||||
$cacheDuration = $this->config->get('filesystems.cache.duration', 60);
|
||||
$cacheKey = "file_url_{$disk}_{$fileName}";
|
||||
|
||||
if ($cacheEnabled && $disk !== 's3') {
|
||||
return Cache::remember($cacheKey, $cacheDuration * 60, static function () use ($storage, $fileName) {
|
||||
return $storage->url($fileName);
|
||||
});
|
||||
}
|
||||
|
||||
return $storage->url($fileName);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error getting file URL: '.$e->getMessage(), [
|
||||
'fileName' => $fileName,
|
||||
'disk' => $disk,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file
|
||||
*
|
||||
* @param string $fileName The file name (with extension)
|
||||
* @param string $disk The disk to use for storage
|
||||
* @return bool True on success, false on failure
|
||||
*/
|
||||
public function deleteFile(string $fileName, string $disk = 'default'): bool
|
||||
{
|
||||
if (empty($fileName)) {
|
||||
Log::warning('Attempted to delete a file with empty filename');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine the disk to use
|
||||
if ($disk === 'default') {
|
||||
$disk = $this->filesystemManager->getDefaultDriver();
|
||||
}
|
||||
|
||||
$storage = $this->filesystemManager->disk($disk);
|
||||
|
||||
// Check if file exists before attempting deletion
|
||||
if (! $storage->exists($fileName)) {
|
||||
Log::info("File not found for deletion: {$fileName} on disk {$disk}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
return $storage->delete($fileName);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting file: '.$e->getMessage(), [
|
||||
'fileName' => $fileName,
|
||||
'disk' => $disk,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function returns the maximum files size that can be uploaded in PHP
|
||||
*
|
||||
* @return int The filesize allowed by php.ini config in bytes
|
||||
*/
|
||||
public static function getMaximumFileUploadSize(): int
|
||||
{
|
||||
return min(self::convertPHPSizeToBytes(ini_get('post_max_size')), self::convertPHPSizeToBytes(ini_get('upload_max_filesize')));
|
||||
}
|
||||
|
||||
/**
|
||||
* This function transforms the php.ini notation for numbers (like '2M') to an integer (2*1024*1024 in this case)
|
||||
*
|
||||
* @return int The value in bytes
|
||||
*/
|
||||
private static function convertPHPSizeToBytes(string $sSize): int
|
||||
{
|
||||
$sSuffix = strtoupper(substr($sSize, -1));
|
||||
if (! in_array($sSuffix, ['P', 'T', 'G', 'M', 'K'])) {
|
||||
return (int) $sSize;
|
||||
}
|
||||
$iValue = (int) substr($sSize, 0, -1);
|
||||
switch ($sSuffix) {
|
||||
case 'P':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
// no break
|
||||
case 'T':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
// no break
|
||||
case 'G':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
// no break
|
||||
case 'M':
|
||||
$iValue *= 1024;
|
||||
// Fallthrough intended
|
||||
// no break
|
||||
case 'K':
|
||||
$iValue *= 1024;
|
||||
break;
|
||||
}
|
||||
|
||||
return (int) $iValue;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user