-<?php namespace BookStack\Uploads;
+<?php
+
+namespace BookStack\Uploads;
use BookStack\Exceptions\FileUploadException;
use Exception;
+use Illuminate\Contracts\Filesystem\Filesystem as Storage;
+use Illuminate\Filesystem\FilesystemManager;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
+use League\Flysystem\WhitespacePathNormalizer;
use Symfony\Component\HttpFoundation\File\UploadedFile;
-class AttachmentService extends UploadService
+class AttachmentService
{
+ protected FilesystemManager $fileSystem;
+
+ /**
+ * AttachmentService constructor.
+ */
+ public function __construct(FilesystemManager $fileSystem)
+ {
+ $this->fileSystem = $fileSystem;
+ }
/**
* Get the storage that will be used for storing files.
- * @return \Illuminate\Contracts\Filesystem\Filesystem
*/
- protected function getStorage()
+ protected function getStorageDisk(): Storage
+ {
+ return $this->fileSystem->disk($this->getStorageDiskName());
+ }
+
+ /**
+ * Get the name of the storage disk to use.
+ */
+ protected function getStorageDiskName(): string
{
$storageType = config('filesystems.attachments');
- // Override default location if set to local public to ensure not visible.
- if ($storageType === 'local') {
- $storageType = 'local_secure';
+ // Change to our secure-attachment disk if any of the local options
+ // are used to prevent escaping that location.
+ if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
+ $storageType = 'local_secure_attachments';
}
- return $this->fileSystem->disk($storageType);
+ return $storageType;
}
/**
- * Get an attachment from storage.
- * @param Attachment $attachment
- * @return string
- * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
+ * Change the originally provided path to fit any disk-specific requirements.
+ * This also ensures the path is kept to the expected root folders.
*/
- public function getAttachmentFromStorage(Attachment $attachment)
+ protected function adjustPathForStorageDisk(string $path): string
{
- return $this->getStorage()->get($attachment->path);
+ $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
+
+ if ($this->getStorageDiskName() === 'local_secure_attachments') {
+ return $path;
+ }
+
+ return 'uploads/files/' . $path;
+ }
+
+ /**
+ * Stream an attachment from storage.
+ *
+ * @return resource|null
+ */
+ public function streamAttachmentFromStorage(Attachment $attachment)
+ {
+ return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path));
+ }
+
+ /**
+ * Read the file size of an attachment from storage, in bytes.
+ */
+ public function getAttachmentFileSize(Attachment $attachment): int
+ {
+ return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path));
}
/**
* Store a new attachment upon user upload.
- * @param UploadedFile $uploadedFile
- * @param int $page_id
- * @return Attachment
+ *
* @throws FileUploadException
*/
- public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
+ public function saveNewUpload(UploadedFile $uploadedFile, int $pageId): Attachment
{
$attachmentName = $uploadedFile->getClientOriginalName();
$attachmentPath = $this->putFileInStorage($uploadedFile);
- $largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
-
- $attachment = Attachment::forceCreate([
- 'name' => $attachmentName,
- 'path' => $attachmentPath,
- 'extension' => $uploadedFile->getClientOriginalExtension(),
- 'uploaded_to' => $page_id,
- 'created_by' => user()->id,
- 'updated_by' => user()->id,
- 'order' => $largestExistingOrder + 1
+ $largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $pageId)->max('order');
+
+ /** @var Attachment $attachment */
+ $attachment = Attachment::query()->forceCreate([
+ 'name' => $attachmentName,
+ 'path' => $attachmentPath,
+ 'extension' => $uploadedFile->getClientOriginalExtension(),
+ 'uploaded_to' => $pageId,
+ 'created_by' => user()->id,
+ 'updated_by' => user()->id,
+ 'order' => $largestExistingOrder + 1,
]);
return $attachment;
}
/**
- * Store a upload, saving to a file and deleting any existing uploads
+ * Store an upload, saving to a file and deleting any existing uploads
* attached to that file.
- * @param UploadedFile $uploadedFile
- * @param Attachment $attachment
- * @return Attachment
+ *
* @throws FileUploadException
*/
- public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment)
+ public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment): Attachment
{
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
$attachment->external = false;
$attachment->extension = $uploadedFile->getClientOriginalExtension();
$attachment->save();
+
return $attachment;
}
public function saveNewFromLink(string $name, string $link, int $page_id): Attachment
{
$largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
+
return Attachment::forceCreate([
- 'name' => $name,
- 'path' => $link,
- 'external' => true,
- 'extension' => '',
+ 'name' => $name,
+ 'path' => $link,
+ 'external' => true,
+ 'extension' => '',
'uploaded_to' => $page_id,
- 'created_by' => user()->id,
- 'updated_by' => user()->id,
- 'order' => $largestExistingOrder + 1
+ 'created_by' => user()->id,
+ 'updated_by' => user()->id,
+ 'order' => $largestExistingOrder + 1,
]);
}
}
}
-
/**
* Update the details of a file.
*/
public function updateFile(Attachment $attachment, array $requestData): Attachment
{
$attachment->name = $requestData['name'];
+ $link = trim($requestData['link'] ?? '');
- if (isset($requestData['link']) && trim($requestData['link']) !== '') {
- $attachment->path = $requestData['link'];
+ if (!empty($link)) {
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
$attachment->external = true;
+ $attachment->extension = '';
}
+ $attachment->path = $requestData['link'];
}
$attachment->save();
- return $attachment;
+
+ return $attachment->refresh();
}
/**
* Delete a File from the database and storage.
- * @param Attachment $attachment
+ *
* @throws Exception
*/
public function deleteFile(Attachment $attachment)
{
- if ($attachment->external) {
- $attachment->delete();
- return;
+ if (!$attachment->external) {
+ $this->deleteFileInStorage($attachment);
}
-
- $this->deleteFileInStorage($attachment);
+
$attachment->delete();
}
/**
* Delete a file from the filesystem it sits on.
* Cleans any empty leftover folders.
- * @param Attachment $attachment
*/
protected function deleteFileInStorage(Attachment $attachment)
{
- $storage = $this->getStorage();
- $dirPath = dirname($attachment->path);
+ $storage = $this->getStorageDisk();
+ $dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
- $storage->delete($attachment->path);
+ $storage->delete($this->adjustPathForStorageDisk($attachment->path));
if (count($storage->allFiles($dirPath)) === 0) {
$storage->deleteDirectory($dirPath);
}
}
/**
- * Store a file in storage with the given filename
- * @param UploadedFile $uploadedFile
- * @return string
+ * Store a file in storage with the given filename.
+ *
* @throws FileUploadException
*/
- protected function putFileInStorage(UploadedFile $uploadedFile)
+ protected function putFileInStorage(UploadedFile $uploadedFile): string
{
- $attachmentData = file_get_contents($uploadedFile->getRealPath());
+ $storage = $this->getStorageDisk();
+ $basePath = 'uploads/files/' . date('Y-m-M') . '/';
- $storage = $this->getStorage();
- $basePath = 'uploads/files/' . Date('Y-m-M') . '/';
-
- $uploadFileName = Str::random(16) . '.' . $uploadedFile->getClientOriginalExtension();
- while ($storage->exists($basePath . $uploadFileName)) {
+ $uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension();
+ while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
$uploadFileName = Str::random(3) . $uploadFileName;
}
+ $attachmentStream = fopen($uploadedFile->getRealPath(), 'r');
$attachmentPath = $basePath . $uploadFileName;
+
try {
- $storage->put($attachmentPath, $attachmentData);
+ $storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream);
} catch (Exception $e) {
+ Log::error('Error when attempting file upload:' . $e->getMessage());
+
throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath]));
}
return $attachmentPath;
}
+
+ /**
+ * Get the file validation rules for attachments.
+ */
+ public function getFileValidationRules(): array
+ {
+ return ['file', 'max:' . (config('app.upload_limit') * 1000)];
+ }
}