Make storage system compatible with remote storages - closes #5603

This commit is contained in:
FreeScout
2026-09-23 21:53:38 -07:00
parent e3e8b521f1
commit 12adf2470c
11 changed files with 105 additions and 53 deletions

View File

@@ -19,8 +19,6 @@ class Attachment extends Model
const DIRECTORY = 'attachment';
const DISK = 'private';
const MIME_TYPE_MAX_LENGTH = 127;
// This token type was used for backward compatibility for some time
@@ -135,7 +133,7 @@ class Attachment extends Model
$file_info = self::saveFileToDisk($attachment, $file_name, $content, $uploaded_file);
$attachment->file_dir = $file_info['file_dir'];
$attachment->size = Storage::disk(self::DISK)->size($file_info['file_path']);
$attachment->size = Storage::size($file_info['file_path']);
$attachment->save();
return $attachment;
@@ -143,6 +141,7 @@ class Attachment extends Model
/**
* Save file to the disk and return file_dir.
* $content may be a resource pointing to the file in remote storage.
*/
public static function saveFileToDisk($attachment, $file_name, $content, $uploaded_file)
{
@@ -155,21 +154,24 @@ class Attachment extends Model
do {
$i++;
$file_path = self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir.$i.DIRECTORY_SEPARATOR.$file_name;
} while (Storage::disk(self::DISK)->exists($file_path));
} while (Storage::exists($file_path));
$file_dir .= $i.DIRECTORY_SEPARATOR;
try {
if ($uploaded_file) {
$uploaded_file->storeAs(self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir, $file_name, ['disk' => self::DISK]);
$uploaded_file->storeAs(self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir, $file_name);
} else {
Storage::disk(self::DISK)->put($file_path, $content);
Storage::put($file_path, $content);
}
} catch (\Exception $e) {
\Helper::logException($e, '[Attachment::saveFileToDisk()]');
}
\Helper::sanitizeUploadedFileData($file_path, \Helper::getPrivateStorage(), $content);
// $content may be a stream resource rather than a string; the SVG sanitizer needs
// actual string content, so let it re-read the just-written file in that case instead
// of treating the resource as file content.
\Helper::sanitizeUploadedFileData($file_path, is_resource($content) ? null : $content);
return [
'file_dir' => $file_dir,
@@ -266,7 +268,7 @@ class Attachment extends Model
// URL must contain only forward slashes.
$file_path = str_replace(DIRECTORY_SEPARATOR, '/', $file_path);
$file_url = Storage::url($file_path);
$file_url = Storage::disk('local')->url($file_path);
// Fix percents.
// https://github.com/freescout-helpdesk/freescout/issues/3530
@@ -306,12 +308,10 @@ class Attachment extends Model
$file_name = $file_name.'.eml';
}
return $this->getDisk()->download($this->getStorageFilePath(), $file_name, $headers);
}
// Cache attachments in browser - 1 month.
$headers['Cache-Control'] = 'max-age=2592000';
private function getDisk()
{
return Storage::disk(self::DISK);
return Storage::download($this->getStorageFilePath(), $file_name, $headers);
}
/**
@@ -338,7 +338,7 @@ class Attachment extends Model
public function getLocalFilePath($full = true)
{
if ($full) {
return $this->getDisk()->path(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name);
return Storage::path(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name);
} else {
return DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name;
}
@@ -349,7 +349,7 @@ class Attachment extends Model
*/
public function fileExists()
{
return $this->getDisk()->exists(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name);
return Storage::exists(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name);
}
public static function formatBytes($size, $precision = 0)
@@ -400,7 +400,7 @@ class Attachment extends Model
{
// Delete from disk
foreach ($attachments as $attachment) {
$attachment->getDisk()->delete($attachment->getStorageFilePath());
Storage::delete($attachment->getStorageFilePath());
}
// Delete from DB
@@ -448,12 +448,20 @@ class Attachment extends Model
$new_attachment->save();
try {
$attachment_file = new \Illuminate\Http\UploadedFile(
$this->getLocalFilePath(), $this->file_name,
null, null, true
);
$content_stream = '';
$attachment_file = null;
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, '', $attachment_file);
if (\Helper::isLocalStorage()) {
$attachment_file = new \Illuminate\Http\UploadedFile(
$this->getLocalFilePath(), $this->file_name,
null, null, true
);
} else {
// File in remote storage.
$content_stream = $this->getFileStream();
}
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, $content_stream, $attachment_file);
if (!empty($file_info['file_dir'])) {
$new_attachment->file_dir = $file_info['file_dir'];
@@ -468,6 +476,16 @@ class Attachment extends Model
public function getFileContents()
{
return $this->getDisk()->get($this->getStorageFilePath());
return Storage::get($this->getStorageFilePath());
}
/**
* Same as getFileContents(), but as a stream instead of loading the whole
* file into memory - use this when the bytes are only being copied
* elsewhere (e.g. duplicating/forwarding an attachment).
*/
public function getFileStream()
{
return Storage::readStream($this->getStorageFilePath());
}
}

View File

@@ -2301,12 +2301,20 @@ class Conversation extends Model
$new_attachment->save();
try {
$attachment_file = new \Illuminate\Http\UploadedFile(
$attachment->getLocalFilePath(), $attachment->file_name,
null, null, true
);
$content_stream = '';
$attachment_file = null;
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, '', $attachment_file);
if (\Helper::isLocalStorage()) {
$attachment_file = new \Illuminate\Http\UploadedFile(
$attachment->getLocalFilePath(), $attachment->file_name,
null, null, true
);
} else {
// File in remote storage.
$content_stream = $this->getFileStream();
}
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, $content_stream, $attachment_file);
if (!empty($file_info['file_dir'])) {
$new_attachment->file_dir = $file_info['file_dir'];

View File

@@ -1404,7 +1404,8 @@ class Customer extends Model
}
$file_name = md5(Hash::make($this->id)).'.jpg';
$dest_path = Storage::path(self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name);
$storage_path = self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name;
$dest_path = Storage::disk('local')->path($storage_path);
$dest_dir = pathinfo($dest_path, PATHINFO_DIRNAME);
if (!file_exists($dest_dir)) {
@@ -1418,6 +1419,8 @@ class Customer extends Model
imagejpeg($resized_image, $dest_path, self::PHOTO_QUALITY);
$file_name = \Eventy::filter('customer.save_photo', $file_name, $this, $dest_path, $storage_path);
return $file_name;
}

View File

@@ -1813,12 +1813,14 @@ class ConversationsController extends Controller
$attachment_copy = $attachment;
}
$attachments[] = [
'id' => encrypt($attachment_copy->id),
'name' => $attachment_copy->file_name,
'size' => $attachment_copy->size,
'url' => $attachment_copy->url(),
];
if ($attachment_copy) {
$attachments[] = [
'id' => encrypt($attachment_copy->id),
'name' => $attachment_copy->file_name,
'size' => $attachment_copy->size,
'url' => $attachment_copy->url(),
];
}
}
}
}

View File

@@ -258,7 +258,7 @@ class OpenController extends Controller
}
}
// CSP header for exatra security.
// CSP header for extra security.
$csp_header_value = "script-src 'none'; frame-src 'none'; object-src 'none'; font-src 'none'; connect-src 'none'; media-src 'self'; form-action 'none'; base-uri 'none'; sandbox";
// https://github.com/freescout-help-desk/freescout/issues/5281
@@ -275,6 +275,7 @@ class OpenController extends Controller
$csp_header_value .= ' allow-same-origin';
}
// Send file.
if (config('app.download_attachments_via') == 'apache') {
// Send using Apache mod_xsendfile.
$response = response(null)
@@ -303,8 +304,8 @@ class OpenController extends Controller
}
} else {
// Send via PHP.
// In this case downloading or showing the attachment
// is controlled via web server config or /storage/app/public/.htaccess.
// In this case whether the file is viewed or downloaded is controlled
// via the web server config or /storage/app/public/.htaccess.
$headers = [
'Content-Type' => $attachment->mime_type,
// Laravel adds 'nosniff' by itself.
@@ -318,7 +319,7 @@ class OpenController extends Controller
// Browsers disable seeking without it, so audio and video attachments
// can not be rewound or fast forwarded. BinaryFileResponse handles
// Range requests, so use it whenever the file is shown in the browser.
if ($view_attachment && $attachment->fileExists()) {
if ($view_attachment && \Helper::isLocalStorage() && $attachment->fileExists()) {
$response = response()->file(
$attachment->getLocalFilePath(),
$headers

View File

@@ -160,10 +160,14 @@ class ReplyToCustomer extends Mailable
if ($thread->has_attachments) {
foreach ($thread->attachments as $attachment) {
if ($attachment->fileExists()) {
$message->attach($attachment->getLocalFilePath());
if (\Helper::isLocalStorage()) {
if ($attachment->fileExists()) {
$message->attach($attachment->getLocalFilePath());
} else {
\Log::error('[ReplyToCustomer] Thread: '.$thread->id.'. Attachment file not find on disk: '.$attachment->getLocalFilePath());
}
} else {
\Log::error('[ReplyToCustomer] Thread: '.$thread->id.'. Attachment file not find on disk: '.$attachment->getLocalFilePath());
\Eventy::filter('email.reply_to_customer.attach', false, $message, $attachment, $thread->id);
}
}
}

View File

@@ -949,12 +949,12 @@ class Helper
public static function getPrivateStorage()
{
return \Storage::disk('private');
return \Storage::disk('local');
}
public static function getPublicStorage()
{
return \Storage::disk('public');
return \Storage::disk('local');
}
public static function formatException($e)
@@ -1843,22 +1843,22 @@ class Helper
$file->storeAs('uploads', $file_name);
self::sanitizeUploadedFileData('uploads'.DIRECTORY_SEPARATOR.$file_name, self::getPublicStorage());
self::sanitizeUploadedFileData('uploads'.DIRECTORY_SEPARATOR.$file_name);
return self::uploadedFilePath($file_name);
}
public static function sanitizeUploadedFileData($file_path, $storage, $content = null)
public static function sanitizeUploadedFileData($file_path, $content = null)
{
// Remove <script>, href="", iframe, etc from SVG files.
// Any image can be interpreted as SVG by browser,
// so checking extension is not enough.
if ($storage->exists($file_path)
&& ($storage->mimeType($file_path) == 'image/svg+xml'
if (Storage::exists($file_path)
&& (Storage::mimeType($file_path) == 'image/svg+xml'
|| strtolower(pathinfo($file_path, PATHINFO_EXTENSION)) == 'svg')
) {
if (!$content) {
$content = $storage->get($file_path);
$content = Storage::get($file_path);
}
if ($content) {
// Remove comments from SVG content.
@@ -1871,7 +1871,7 @@ class Helper
if (!$clean_content) {
$clean_content = preg_replace('#<script(.*?)>(.*?)</script>#is', '', $content);
}
$storage->put($file_path, $clean_content);
Storage::put($file_path, $clean_content);
}
}
}
@@ -3265,4 +3265,13 @@ class Helper
}
array_set(self::$memory_cache, $key, $value);
}
public static function isLocalStorage($disk = '')
{
if ($disk) {
return \Storage::disk($disk)->getDriver()->getAdapter() instanceof \League\Flysystem\Adapter\Local;
} else {
return config('filesystems.default') == 'local';
}
}
}

View File

@@ -929,7 +929,8 @@ class User extends Authenticatable
}
$file_name = md5(Hash::make($this->id)).'.jpg';
$dest_path = Storage::path(self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name);
$storage_path = self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name;
$dest_path = Storage::disk('local')->path($storage_path);
$dest_dir = pathinfo($dest_path, PATHINFO_DIRNAME);
if (!file_exists($dest_dir)) {
@@ -946,6 +947,8 @@ class User extends Authenticatable
// User::PHOTO_DIRECTORY, !Hash::make($user->id).'.jpg'
// );
$file_name = \Eventy::filter('user.save_photo', $file_name, $this, $dest_path, $storage_path);
return $file_name;
}

View File

@@ -50,7 +50,7 @@ return [
'visibility' => 'public',
],
'public' => [
/*'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
@@ -63,7 +63,7 @@ return [
'root' => storage_path('app'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
],
],*/
's3' => [
'driver' => 's3',
@@ -71,6 +71,10 @@ return [
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
// URL is used to form file URLs.
'url' => env('AWS_URL'),
],
],

View File

@@ -1,5 +1,4 @@
# Force /storage/* files to be downloaded instead of showing them in the browser.
# The list should be in sync with /config/app.php and nginx config.
<FilesMatch "\.((?!(jpg|jpeg|jfif|pjpeg|pjp|apng|bmp|gif|ico|cur|png|tif|tiff|webp|pdf|txt|diff|patch|json|mp3|wav|ogg|wma)).)*$">
ForceType application/octet-stream

View File

@@ -175,6 +175,7 @@ sudo echo 'server {
# For non-static content application adds nosniff by itself
add_header X-Content-Type-Options "nosniff";
}
# Force /storage/* files to be downloaded instead of showing them in the browser.
# The list should be in sync with /storage/app/public/uploads/.htaccess and /config/app.php
location ~* ^/storage/.*\.((?!(jpg|jpeg|jfif|pjpeg|pjp|apng|bmp|gif|ico|cur|png|tif|tiff|webp|pdf|txt|diff|patch|json|mp3|wav|ogg|wma)).)*$ {
add_header Content-disposition "attachment; filename=$2";