rootinc / laravel-s3-file-model
S3 File Model
Installs: 4 901
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 4
Forks: 1
Open Issues: 0
Requires
- php: >=8.0.2
- aws/aws-sdk-php-laravel: ^3
- laravel/framework: >=9
- league/flysystem-aws-s3-v3: ^3
- maltyxx/images-generator: ^1
README
Provides a File Model that supports direct uploads / downloads from S3 for a Laravel App.
Installation
composer require rootinc/laravel-s3-file-model
- Run
php artisan vendor:publish --provider="RootInc\LaravelS3FileModel\FileModelServiceProvider"
to createFile
model inapp
,FileTest
intests\Unit
,FileFactory
indatabase\factories
,2020_03_12_152841_create_files_table
indatabase\migrations
, andFileController
inapp\Http\Controllers
- Run
php artisan vendor:publish --provider="Aws\Laravel\AwsServiceProvider
which addsaws.php
in theconfig
folder - In the
aws.php
file, change'region' => env('AWS_REGION', 'us-east-1'),
to useAWS_DEFAULT_REGION
- In
config\filesystems.php
, add key'directory' => '', // root dir
topublic
and add key'directory' => env('AWS_UPLOAD_FOLDER'),
tos3
- In
tests\TestCase
, add this function:
protected function get1x1RedPixelImage()
{
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
}
- Update routing. We can use this as an example:
Route::apiResource('files', 'FileController')->only(['index', 'store', 'update', 'destroy']);
- 🎉
Update from 0.1.* to 0.2.*
If we are using API versioning paradigm, 0.2.* lets us abstract the file model so that in a child class, we can import a different version of the File.
For example, our FileController
would look something like this:
<?php
namespace App\Http\Controllers\Api\v3;
use RootInc\LaravelS3FileModel\FileBaseController;
use ReflectionClass;
use App\Models\v3\File;
class FileController extends FileBaseController
{
protected static function getFileModel()
{
$rc = new ReflectionClass(File::class);
return $rc->newInstance();
}
...
Now when running the parent's functionality, it will make use of the right model.
Note -- the FileController's update
and delete
methods have been switched from public function method(Request $request, File $file)
to public function method(Request $request, $file_id)
The same has been updated for tests. That way, if we want to make use of Laravel 8's updated factory classes, we can do so like below:
<?php
namespace Tests\Unit\v2;
use RootInc\LaravelS3FileModel\FileModelTest;
use Tests\DatabaseMigrationsUpTo;
use ReflectionClass;
use App\Models\v2\File;
class FileTest extends FileModelTest
{
use DatabaseMigrationsUpTo;
protected static function getFileModel()
{
$rc = new ReflectionClass(File::class);
return $rc->newInstance();
}
protected function getFileFactory($count=1, $create=true, $properties=[])
{
$files;
$factory = File::factory()->count($count);
if ($create)
{
$files = $factory->create($properties);
}
else
{
$files = $factory->make($properties);
}
$len = count($files);
if ($len === 1)
{
return $files[0];
}
else if ($len === 0)
{
return null;
}
else
{
return $files;
}
}
...
Example React FileUploader
import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import api from '../../helpers/api';
const propTypes = {
afterSuccess: PropTypes.func,
file: PropTypes.object,
cloudUpload: PropTypes.bool,
style: PropTypes.object,
public: PropTypes.bool,
};
const defaultProps = {
afterSuccess: () => {},
cloudUpload: false,
style: {},
public: false,
};
function FileUploader(props){
const elInput = useRef(null);
const [file, setFile] = useState(null);
const [draggingState, setDraggingState] = useState(false);
const [percentCompleted, setPercentCompleted] = useState(null);
// Use dependency on props.file for when we load an existing file
useEffect(() => {
setFile(props.file)
}, [props.file]);
const dragOver = () => {
if (percentCompleted === null)
{
setDraggingState(true)
}
};
const dragEnd = () => {
setDraggingState(false)
};
const nullImportValue = () => {
ReactDOM.findDOMNode(elInput.current).value = null;
};
const handleChange = (blob) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
if (props.cloudUpload)
{
pingUpload({
file_name: blob.name,
file_type: blob.type,
public: props.public,
}, blob); //XMLHttpRequest can take a raw file blob, which works better for streaming the file
}
else
{
upload({
file_name: blob.name,
file_type: blob.type,
file_data: reader.result,
public: props.public,
});
}
}, false);
reader.readAsDataURL(blob);
};
const pingUpload = async (data, blob) => {
const response = file
? await api.putFile(file.id, data)
: await api.postFile(data)
response.ok
? cloudUpload(response, blob)
: error(response)
}
const cloudUpload = async (response, blob) => {
const putCloudObject = () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", response.data.payload.upload_url);
xhr.setRequestHeader("Content-Type", response.data.payload.file.file_type);
xhr.setRequestHeader("Cache-Control", `max-age=${60*60*7}`); //cache for a week (in case a developer uploads with disable cache checked)
xhr.onload = () => {
resolve(xhr);
};
xhr.onerror = () => {
reject(new Error(xhr.statusText));
};
xhr.upload.onprogress = (e) => {
const percentCompleted = Math.round( (e.loaded / e.total) * 100 );
setPercentCompleted(percentCompleted);
};
//thankfully blobs can be sent up, and this works better https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/send
xhr.send(blob);
});
}
const cloudResponse = await putCloudObject();
cloudResponse.status === 200
? success(response)
: error(cloudResponse.response)
}
const upload = async (data) => {
const config = {
onUploadProgress: (e) => {
const percentCompleted = Math.round( (e.loaded / e.total) * 100 );
setPercentCompleted(percentCompleted);
}
};
const response = file
? await api.putFile(file.id, data, config)
: await api.postFile(data, config)
response.ok
? success(response)
: error(response)
};
const success = (response) => {
nullImportValue();
if (response.data.status === "success")
{
setFile(response.data.payload.file)
}
else
{
alert(response.data.payload.errors[0]);
}
setPercentCompleted(null)
props.afterSuccess(response.data.payload.file)
};
const error = (error) => {
nullImportValue();
setPercentCompleted(null)
alert(window._genericErrorMessage);
};
const renderInstructions = () => {
if (percentCompleted === null)
{
return (
<p
style={{
cursor: "pointer"
}}
onClick={() => {
ReactDOM.findDOMNode(elInput.current).click();
}}
>
<strong>{file ? "Replace" : "Choose"} File</strong> or drag it here.
</p>
);
}
else if (percentCompleted < 100)
{
return (
<progress
value={percentCompleted}
max="100"
>
{percentCompleted}%
</progress>
);
}
else
{
return <i className="fa fa-cog fa-spin fa-3x fa-fw" aria-hidden="true" />;
}
};
const renderFileInfo = () => {
if (file)
{
return (
<div>
<p
style={{
marginBottom: 0
}}
>
Current File:
<a
href={file.fullUrl}
target="_blank"
style={{
wordBreak: "break-all"
}}
>
{file.title}
</a>
<button
style={{
marginLeft: "10px",
backgroundColor: "gray",
padding: ".45rem .5rem .3rem .5rem"
}}
onClick={async () => {
const result = prompt("New Title?", file.title);
if (result)
{
const response = await api.putFile(file.id, {title: result});
response.ok
? success(response)
: error(response)
}
}}
>
Rename
</button>
</p>
<p
style={{
marginTop: 0,
wordBreak: "break-all"
}}
>
Original Name: {file.file_name}
</p>
</div>
);
}
else
{
return null;
}
}
const style = Object.assign({
border: "2px dashed black",
borderRadius: "10px",
backgroundColor: draggingState ? "white" : "lightgray",
height: "250px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}, props.style);
return (
<div
style={style}
onClick={(e) => {e.stopPropagation();}}
onDrag={(e) => {e.preventDefault();}}
onDragStart={(e) => {e.preventDefault();}}
onDragEnd={(e) => {e.preventDefault(); dragEnd();}}
onDragOver={(e) => {e.preventDefault(); dragOver();}}
onDragEnter={(e) => {e.preventDefault(); dragOver();}}
onDragLeave={(e) => {e.preventDefault(); dragEnd();}}
onDrop={(e) => {
e.preventDefault();
dragEnd();
if (percentCompleted === null)
{
const droppedFiles = e.dataTransfer.files;
handleChange(droppedFiles[0]);
}
}}
>
<i className="fa fa-upload" aria-hidden="true" />
{
renderInstructions()
}
{
renderFileInfo()
}
<input
ref={elInput}
className="file-uploader"
type="file"
style={{
position: "fixed",
top: "-100em"
}}
onChange={(e) => {
handleChange(e.target.files[0]);
}}
/>
</div>
);
}
FileUploader.propTypes = propTypes;
FileUploader.defaultProps = defaultProps;
export default FileUploader;
Contributing
Thank you for considering contributing to the Laravel S3 File Model! To encourage active collaboration, we encourage pull requests, not just issues.
If you file an issue, the issue should contain a title and a clear description of the issue. You should also include as much relevant information as possible and a code sample that demonstrates the issue. The goal of a issue is to make it easy for yourself - and others - to replicate the bug and develop a fix.
License
The Laravel S3 File Model is open-sourced software licensed under the MIT license.