Laravel-Backpack / CRUD

Build custom admin panels. Fast!
https://backpackforlaravel.com
MIT License
3.15k stars 891 forks source link

[Feature Request] Out-of-the-box Laravel Vapor support #5189

Open tabacitu opened 1 year ago

tabacitu commented 1 year ago

Feature Request

What's the feature you think Backpack should have?

Backpack should be able to work out-of-the-box on Laravel Vapor and/or provide clear steps on how to deploy it there.

Have you already implemented a prototype solution, for your own project?

No but @malek77z has 👀

Do you see this as a core feature or an add-on?

Core, most likely.

tabacitu commented 1 year ago

What we need to do here is create a list of things/problems/quirks that happen when using Backpack v6 on Laravel Forge... then see what we can do about each one.

Problem 1. Basset needs to use the S3 bucket on Vapor. And for cache maps to be disabled. Thanks to @malek77z we've documented that process in the Basset docs.

Problem 2. Uploads need to happen on the S3 bucket as well. This is pretty standard I think, but we do need to

Anything else I've missed @malek77z ? Note that I haven't deployed v6 on Vapor yet so I'm working blind here 😅 We're hard at work to finish up the v6 launch so the priority is that, at the moment. But as soon as we're done I'd like us to take a closer look at this.

malek77z commented 1 year ago

@tabacitu

Very happy t help out an retrace my steps. I now have 3 projects on Backpack (now 6!) and Vapor, so confirmed it works well. Also Basset came just in time as the tweaks i made before to upload so many files stopped working just as basset was released!

I'm going to assume that a Vapor project from https://vapor.laravel.com/ has been set up and there is an environment ready. Also to not, im working on windows, and not deploying directly, but instead though github actions.

So once the Vapor AWS is set up and connected to vapor, domain if you want one, database (im using db.t3.micros for now), you will need to log into Vapor on you CLI to connect the project.

composer require laravel/vapor-core
composer global require laravel/vapor-cli

then

vapor login

You should end up with a yml file like the following in root called vapor.yml

id: VAPORPROJECTID
name: PROJECTNAME
environments:
    production:
        timeout: 30
        domain: PROJECTDOMAIN
        memory: 1024
        cli-memory: 512
        runtime: 'php-8.2:al2'
        database: DATABASENAME
        queues:
            - QUEUENAME-production  # only if needed    
        queue-timeout: 300
        storage: S3BUCKETNAME # Vapor will make this for you
        build:
            - 'COMPOSER_MIRROR_PATH_REPOS=1 composer install --no-dev'
            - 'php artisan event:cache'
            - 'npm ci && npm run prod && rm -rf node_modules'
        deploy:
            - 'php artisan migrate --force'
            - 'php artisan queue:restart'
   staging:
       ...

This is my github action yml file in .github/workflows/deply.yml

name: Deploy

on:
  push:
    branches: [ production, staging ]

jobs:
  deploy:
    runs-on: ubuntu-22.04
    steps:
      - name: Checkout Code
        uses: actions/checkout@v3

      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: 16

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: 8.2
          tools: composer:v2
          coverage: none

      - name: Require Vapor CLI
        run: composer global require laravel/vapor-cli

      - name: Configure Backpack authentication
        run: composer config http-basic.backpackforlaravel.com IDHERE KEYHERE

      - name: Install Project Dependencies
        run: composer install --no-interaction --prefer-dist --optimize-autoloader

      - name: Set Environment Variables for Production
        if: github.ref == 'refs/heads/production'
        run: |
             echo "MIX_PUSHER_APP_KEY=${{ secrets.MIX_PUSHER_APP_KEY_PROD }}" >> $GITHUB_ENV
             echo "MIX_PUSHER_APP_CLUSTER=${{ secrets.MIX_PUSHER_APP_CLUSTER_PROD }}" >> $GITHUB_ENV

      - name: Set Environment Variables for Staging
        if: github.ref == 'refs/heads/staging'
        run: | 
             echo "MIX_PUSHER_APP_KEY=${{ secrets.MIX_PUSHER_APP_KEY_STAGE }}" >> $GITHUB_ENV
             echo "MIX_PUSHER_APP_CLUSTER=${{ secrets.MIX_PUSHER_APP_CLUSTER_STAGE }}" >> $GITHUB_ENV

      - name: Deploy Environment
        run: vapor deploy ${{ github.ref_name }} --commit="${{ github.event.head_commit.id }}" --message="${{ github.event.head_commit.message }}"
        env:
          VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }}
          MIX_PUSHER_APP_KEY: ${{ env.MIX_PUSHER_APP_KEY }}
          MIX_PUSHER_APP_CLUSTER: ${{ env.MIX_PUSHER_APP_CLUSTER }}

Note the secrets set. If you are using pusher or similar, important to set here as they will get written into the js asset file, I might have gone a bit overkill with the env as Vapor basically writes its own, but this got me to a working action.

So this should get git hub to put to Vapor now, and mostly work, but there are a few more things you have to be aware of. As none of the assets are stored in on the server, but S3, you need to make sure you wrap all the assets in public with asset(), this will pit up the ASSET_URL variable in env.

Next, for logos and so on in the ui.php config, when you want to pass them in you will also need to make sure your pointing to the correct place, this is an example I used in my config to make sure it was using the s3 bucket...

 'project_logo'   => '<img src="'.env('ASSET_URL').'/assets/log.svg" class="logo_main" />',

as you cant use asset() in the config, this works the same way

Next is image uploading... This comes in 2 parts, 1 being able to write the image to S3 bucket, however you also need to think about the tmp upload folder, as although this may work uploading ot the tmp folder of a lambda, its not guarantied to work as you could be dealing with a different machine.

this is my modified image.blade.php I use to do just that:

@php

    // Image with tmp disk option. Set to s3_vapor if you need to store the uploads in s3
    // This will then return the s3 temp url to save, which will then be moved to the real location in the s3 bucket
    // There is potetnially an issue if the form fails to submit, due to error, and we are left with the location of the file
    // being in the temp s3 bucket. This would mean we can read it in... This will need to be cleaned up at some point.

    $field['prefix'] = $field['prefix'] ?? '';
    $field['disk'] = $field['disk'] ?? null;
    $field['tmp_disk'] = $field['tmp_disk'] ?? null;
    $value = old_empty_or_null($field['name'], '') ??  $field['value'] ?? $field['default'] ?? '';
    $imageDisplayUrl = '';

    if (! function_exists('getDiskUrl')) {
        function getDiskUrl($disk, $path) {
            try {
                // make sure the value don't have disk base path on it, this is the same as `Storage::disk($disk)->url($prefix);`,
                // we need this solution to deal with `S3` not supporting getting empty urls
                // that could happen when there is no $prefix set.
                $origin = substr(Storage::disk($disk)->url('/'), 0, -1);
                $path = str_replace($origin, '', $path);

                return Storage::disk($disk)->url($path);
            }
            catch (Exception $e) {
                // the driver does not support retrieving URLs (eg. SFTP)
                return url($path);
            }
        }
    }

    if (! function_exists('maximumServerUploadSizeInBytes')) {
        function maximumServerUploadSizeInBytes() {

            $val = trim(ini_get('upload_max_filesize'));
            $last = strtolower($val[strlen($val)-1]);

            switch($last) {
                // The 'G' modifier is available since PHP 5.1.0
                case 'g':
                    $val = (int)$val * 1073741824;
                    break;
                case 'm':
                    $val = (int)$val * 1048576;
                    break;
                case 'k':
                    $val = (int)$val * 1024;
                    break;
            }

            return $val;
        }
    }

    // if value isn't a base 64 image, generate URL
    if($value && !preg_match('/^data\:image\//', $value)) {
        // make sure to append prefix once to value
        $imageDisplayUrl = Str::start($value, $field['prefix']);

        // generate URL
        $imageDisplayUrl = $field['disk']
            ? getDiskUrl($field['disk'], $imageDisplayUrl)
            : url($imageDisplayUrl);
    }

    $max_image_size_in_bytes = $field['max_file_size'] ?? (int)maximumServerUploadSizeInBytes();

    $field['wrapper'] = $field['wrapper'] ?? $field['wrapperAttributes'] ?? [];
    $field['wrapper']['class'] = $field['wrapper']['class'] ?? "form-group col-sm-12";
    $field['wrapper']['class'] = $field['wrapper']['class'].' cropperImage';
    $field['wrapper']['data-aspectRatio'] = $field['aspect_ratio'] ?? 0;
    $field['wrapper']['data-crop'] = $field['crop'] ?? false;
    $field['wrapper']['data-field-name'] = $field['wrapper']['data-field-name'] ?? $field['name'];
    $field['wrapper']['data-init-function'] = $field['wrapper']['data-init-function'] ?? 'bpFieldInitCropperImageElement';
@endphp

@include('crud::fields.inc.wrapper_start')

    <label>{!! $field['label'] !!}</label>
    @include('crud::fields.inc.translatable_icon')

    {{-- Wrap the image or canvas element with a block element (container) --}}
    <div class="row">
        <div class="col-sm-6" data-handle="previewArea" style="margin-bottom: 20px;">
            <img data-handle="mainImage" src="">
        </div>
        @if(isset($field['crop']) && $field['crop'])
        <div class="col-sm-3" data-handle="previewArea">
            <div class="docs-preview clearfix">
                <div class="img-preview preview-lg">
                    <img src="" style="display: block; min-width: 0px !important; min-height: 0px !important; max-width: none !important; max-height: none !important; margin-left: -32.875px; margin-top: -18.4922px; transform: none;">
                </div>
            </div>
        </div>
        @endif
    </div>
    <div class="btn-group">
        <div class="btn btn-light btn-sm btn-file">
            {{ trans('backpack::crud.choose_file') }} <input type="file" accept="image/*" data-handle="uploadImage" @include('crud::fields.inc.attributes')>
            <input type="hidden" data-handle="hiddenImage" name="{{ $field['name'] }}" 
            @if($field['tmp_disk'] && Auth::check())
            @can('uploadFiles', App\Models\User::class)
            tmp_disk="{{ $field['tmp_disk'] }}"
            @endcan
            @endif
            data-value-prefix="{{ $field['prefix'] }}" data-value-url="{{$imageDisplayUrl}}" value="{{ $value }}">
        </div>
        @if(isset($field['crop']) && $field['crop'])
        <button class="btn btn-light btn-sm" data-handle="rotateLeft" type="button" style="display: none;"><i class="la la-rotate-left"></i></button>
        <button class="btn btn-light btn-sm" data-handle="rotateRight" type="button" style="display: none;"><i class="la la-rotate-right"></i></button>
        <button class="btn btn-light btn-sm" data-handle="zoomIn" type="button" style="display: none;"><i class="la la-search-plus"></i></button>
        <button class="btn btn-light btn-sm" data-handle="zoomOut" type="button" style="display: none;"><i class="la la-search-minus"></i></button>
        <button class="btn btn-light btn-sm" data-handle="reset" type="button" style="display: none;"><i class="la la-times"></i></button>
        @endif
        <button class="btn btn-light btn-sm" data-handle="remove" type="button"><i class="la la-trash"></i></button>

    </div>
        @if ($field['tmp_disk'])
        @if (Auth::check())
            @can('uploadFiles', App\Models\User::class)
            <span class="badge badge-pill bg-success" data-upload-status><span class="la la-check"></span></span></span>
            @else
            <span class="badge badge-pill bg-warning"><span class="la la-warning"></span></span></span>
            @endcan
        @else
        <span class="badge badge-pill bg-danger"><span class="la la-warning"></span></span></span>
        @endif
        @endif

    {{-- HINT --}}
    @if (isset($field['hint']))
        <p class="help-block">{!! $field['hint'] !!}</p>
    @endif
@include('crud::fields.inc.wrapper_end')

{{-- ########################################## --}}
{{-- Extra CSS and JS for this particular field --}}
{{-- If a field type is shown multiple times on a form, the CSS and JS will only be loaded once --}}

{{-- FIELD CSS - will be loaded in the after_styles section --}}
@push('crud_fields_styles')
    @loadOnce('packages/cropperjs/dist/cropper.min.css')
    @loadOnce('image_field_style')
    <style>
        .image .btn-group {
            margin-top: 10px;
        }
        img {
            max-width: 100%; /* This rule is very important, please do not ignore this! */
        }
        .img-container, .img-preview {
            width: 100%;
            text-align: center;
        }
        .img-preview {
            float: left;
            margin-right: 10px;
            margin-bottom: 10px;
            overflow: hidden;
        }
        .preview-lg {
            width: 263px;
            height: 148px;
        }

        .btn-file {
            position: relative;
            overflow: hidden;
        }
        .btn-file input[type=file] {
            position: absolute;
            top: 0;
            right: 0;
            min-width: 100%;
            min-height: 100%;
            font-size: 100px;
            text-align: right;
            filter: alpha(opacity=0);
            opacity: 0;
            outline: none;
            background: white;
            cursor: inherit;
            display: block;
        }
    </style>
    @endLoadOnce
@endpush

{{-- FIELD JS - will be loaded in the after_scripts section --}}
@push('crud_fields_scripts')
    @loadOnce('packages/cropperjs/dist/cropper.min.js')
    @loadOnce('packages/jquery-cropper/dist/jquery-cropper.min.js')
    @loadOnce('bpFieldInitCropperImageElement')
    <script>
        function bpFieldInitCropperImageElement(element) {
                // Find DOM elements under this form-group element
                var $mainImage = element.find('[data-handle=mainImage]');
                var $uploadImage = element.find("[data-handle=uploadImage]");
                var $hiddenImage = element.find("[data-handle=hiddenImage]");
                var $rotateLeft = element.find("[data-handle=rotateLeft]");
                var $rotateRight = element.find("[data-handle=rotateRight]");
                var $zoomIn = element.find("[data-handle=zoomIn]");
                var $zoomOut = element.find("[data-handle=zoomOut]");
                var $reset = element.find("[data-handle=reset]");
                var $remove = element.find("[data-handle=remove]");
                var $previews = element.find("[data-handle=previewArea]");
                // Options either global for all image type fields, or use 'data-*' elements for options passed in via the CRUD controller
                var options = {
                    viewMode: 2,
                    checkOrientation: false,
                    autoCropArea: 1,
                    responsive: true,
                    preview : element.find('.img-preview'),
                    aspectRatio : element.attr('data-aspectRatio')
                };
                var crop = element.attr('data-crop');

                // Hide 'Remove' button if there is no image saved
                if (!$hiddenImage.val()){
                    $previews.hide();
                    $remove.hide();
                }
                // Make the main image show the image in the hidden input url (image loaded from database) or show the preview data
                $mainImage.attr('src', $hiddenImage.data('value-url').length > 0 ? $hiddenImage.data('value-url') : $hiddenImage.val());

                // Only initialize cropper plugin if crop is set to true
                if(crop){
                    $remove.click(function() {
                        $mainImage.cropper("destroy");
                        $mainImage.attr('src','');
                        $hiddenImage.val('');
                        $rotateLeft.hide();
                        $rotateRight.hide();
                        $zoomIn.hide();
                        $zoomOut.hide();
                        $reset.hide();
                        $remove.hide();
                        $previews.hide();
                    });
                } else {
                    $remove.click(function() {
                        $mainImage.attr('src','');
                        $hiddenImage.val('');
                        $remove.hide();
                        $previews.hide();
                    });
                }
                $uploadImage.change(function() {

                    var fileReader = new FileReader(),
                            files = this.files,
                            file;

                    if (!files.length) {
                        return;
                    }
                    file = files[0];

                    const maxImageSize = {{ $max_image_size_in_bytes }};
                    if(maxImageSize > 0 && file.size > maxImageSize) {
                        alert('Please pick an image smaller than '+maxImageSize+'  bytes.');
                    } else if (/^image\/\w+$/.test(file.type)) {
                        fileReader.readAsDataURL(file);
                        fileReader.onload = function () {
                            $uploadImage.val("");
                            $previews.show();

                            if(crop){
                                $mainImage.cropper(options).cropper("reset", true).cropper("replace", this.result);

                                // update the hidden input after selecting a new item or cropping
                                $mainImage.on('ready cropstart cropend', function() {
                                    var imageURL = $mainImage.cropper('getCroppedCanvas').toDataURL(file.type);
                                    $hiddenImage.val(imageURL);
                                    $hiddenImage.attr('data-update',1);
                                    return true;
                                });

                                $rotateLeft.show();
                                $rotateRight.show();
                                $zoomIn.show();
                                $zoomOut.show();
                                $reset.show();
                                $remove.show();

                            } else {
                                $mainImage.attr('src',this.result);
                                $hiddenImage.val(this.result);
                                $hiddenImage.attr('data-update',1);
                                $remove.show();
                            }
                        };
                    } else {
                        new Noty({
                            type: "error",
                            text: "<strong>Please choose an image file</strong><br>The file you've chosen does not look like an image."
                        }).show();
                    }
                });                

                //moved the click binds outside change event, or we would register as many click events for the same amout of times
                //we triggered the image change
                if(crop) {
                    $rotateLeft.click(function() {
                        $mainImage.cropper("rotate", 90);
                        $mainImage.trigger('cropend');
                    });

                    $rotateRight.click(function() {
                        $mainImage.cropper("rotate", -90);
                        $mainImage.trigger('cropend');
                    });

                    $zoomIn.click(function() {
                        $mainImage.cropper("zoom", 0.1);
                        $mainImage.trigger('cropend');
                    });

                    $zoomOut.click(function() {
                        $mainImage.cropper("zoom", -0.1);
                        $mainImage.trigger('cropend');
                    });

                    $reset.click(function() {
                        $mainImage.cropper("reset");
                        $mainImage.trigger('cropend');
                    });
                }

                element.on('CrudField:disable', function(e) {
                    element.children('.btn-group').children('button[data-handle=remove]').attr('disabled','disabled');
                    element.children('.btn-group').children('.btn-file').children('input[data-handle=uploadImage]').attr('disabled','disabled');
                });

                element.on('CrudField:enable', function(e) {
                    element.children('.btn-group').children('button[data-handle=remove]').removeAttr('disabled');
                    element.children('.btn-group').children('.btn-file').children('input[data-handle=uploadImage]').removeAttr('disabled');
                });
        }
    </script>
    <script>
        // Check if any forms on the page contain an input with tmp_disk="s3_vapor"
        var inputs = document.querySelectorAll('input[type=hidden][tmp_disk=s3_vapor]');

        if (inputs.length > 0) {  // Only run the code if such inputs exist
            var forms = document.querySelectorAll('form'); // select all forms on the page

            for (var i = 0; i < forms.length; i++) {
                forms[i].addEventListener('submit', function(event) {
                    var form = event.target; // the form that triggered the submit event

                    // check if the form contains an input with tmp_disk="s3_vapor"
                    var hiddenImage = form.querySelector('input[type=hidden][tmp_disk=s3_vapor]');
                    // also check if attr data-update is set to 1
                    if (!hiddenImage || hiddenImage.getAttribute('data-update') != 1) {
                        // this form doesn't contain an input with tmp_disk="s3_vapor", so we don't need to do anything
                        return;
                    }

                    event.preventDefault(); // prevent the form from submitting immediately

                    // Find DOM elements under this form-group element for the hiddenImage we have
                    // ie the a parent will have the class form group lets get that element and work from there
                    var element = $(hiddenImage).closest('.form-group');

                    var mainImage = element.find('[data-handle=mainImage]');
                    var uploadImage = element.find("[data-handle=uploadImage]");
                    var uploadBadgeStatus = element.find("[data-upload-status]");

                    // Get the base64 data from the hidden image input
                    var fileData = hiddenImage.value;
                    // Convert the base64 data to a Blob
                    var blob = dataURLToBlob(fileData);
                    // Now use the Vapor's store method to upload the blob to S3
                    var file = new File([blob], "filename.png", {type: blob.type});

                    Vapor.store(file, {
                        visibility: 'public-read',
                        progress: progress => {
                            console.log('Upload progress:', Math.round(progress * 100), '%');
                            // update the data-upload-status to have the progress status
                            uploadBadgeStatus.html(Math.round(progress * 100)+'%');
                        }
                    }).then(response => {
                        var uploadedFileUrl = response.url.split('?')[0];
                        var uploadedFileKey = response.key; 
                        mainImage.src = uploadedFileUrl;
                        hiddenImage.value = uploadedFileKey;
                        hiddenImage.attributes.tmp_disk.value = 'done'; // its uploaded, no need to do again just in case

                        // now, submit the form
                        form.submit();
                    }).catch(error => {
                        console.error('Upload failed:', error);
                        // fall back to normal upload
                        // remove the tmp_disk attribute so we don't get into a loop
                        hiddenImage.removeAttribute('tmp_disk');
                        // remove the data-update attribute so we don't get into a loop
                        hiddenImage.removeAttribute('data-update');
                        // submit the form again, this should now upload the normal way
                        form.submit();
                    });
                });
            }
        }

        // This function converts a base64 string to a Blob
        function dataURLToBlob(dataURL) {
            var BASE64_MARKER = ';base64,';
            if (dataURL.indexOf(BASE64_MARKER) == -1) {
                var parts = dataURL.split(',');
                var contentType = parts[0].split(':')[1];
                var raw = parts[1];

                return new Blob([raw], {type: contentType});
            }

            var parts = dataURL.split(BASE64_MARKER);
            var contentType = parts[0].split(':')[1];
            var raw = window.atob(parts[1]);
            var rawLength = raw.length;
            var uInt8Array = new Uint8Array(rawLength);

            for (var i = 0; i < rawLength; ++i) {
                uInt8Array[i] = raw.charCodeAt(i);
            }

            return new Blob([uInt8Array], {type: contentType});
        }
    </script>

    @endLoadOnce

@endpush

{{-- End of Extra CSS and JS --}}
{{-- ########################################## --}}

And this is me calling it

CRUD::addField([
            'name' => 'image_url',
            'label'=> 'Image',
            'type' => 'plus.image',
            'crop' => true, 
            'tmp_disk' => 's3_vapor',
        ]);

(note the tmp_disk attribute)

We are still not there yet as we need to use laravel-vapor to do the client side uploading... this is my app.js in rescources/app.js which gets created with npm magic

import './bootstrap';
window.Vapor = require('laravel-vapor');

for NPM you will make sure you have

"laravel-vapor": "^0.6.0"

also in the ui.php make sure your loading whereever the script is i.e.

'scripts' => [
        'js/app.js',
    ],

screenshot-localhost_8018-2023 07 06-22_21_07

This is an example, not the green checkmark to show its connected as expected.

This is the other side, its my image trait I made to upload to s3 and so on

<?php

namespace App\Traits;

use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\ImageManagerStatic as Image;

trait ImageTrait
{
    public function setImageUrlAttribute($value, $attributeName="image_url")
    {
        $attribute_name = $attributeName;
        $disk = config('features.use_s3_images') ? 's3' : 'public';

        // remove if now none
        if (empty($value)) {
            if (isset($this->{$attribute_name}) && !empty($this->{$attribute_name})) {
                $this->trashImage($this->{$attribute_name});
            }
            $this->attributes[$attribute_name] = null;
        }

        if (Str::startsWith($value, 'data:image')) {
            $image = Image::make($value)->encode('jpg', 90);

            $destination_path = $this->getImageDestinationPath($attribute_name);
            $filename = $this->getImageFilename($value, $attribute_name);

            Storage::disk($disk)->put($destination_path.'/'.$filename, $image->stream(), 'public');

            // remove if different from saves
            if (isset($this->{$attribute_name}) && !empty($this->{$attribute_name})) {
                $this->trashImage($this->{$attribute_name});
            }

            $this->attributes[$attribute_name] = Storage::disk($disk)->url($destination_path.'/'.$filename);
        } elseif (!empty($value)) {
            // This can either be a URL that we want to store, or a tmp S3 KEY we want to move permanently and store
            // The temp S3 KEY will look like this: tmp/a866dc90-b2e2-494b-8fcc-bc729b047685
            // First we want to check for that

            // check for temp S3 KEY
            if(Str::startsWith($value, 'tmp/')) {
                // we have a temp S3 URL, lets move it to the right place
                $destination_path = $this->getImageDestinationPath($attribute_name);
                $filename = $this->getImageFilename($value, $attribute_name);
                $new_path = $destination_path.'/'.$filename;

                Storage::disk($disk)->copy($value, $new_path);
                Storage::disk($disk)->setVisibility($new_path, 'public');
                // delete the tmp file option, not needed really as we will do clean up
                config('features.delete_tmp_s3_files') ? $this->trashImage($value) : null;

                // remove if different from saves
                if (isset($this->{$attribute_name}) && !empty($this->{$attribute_name})) {
                    $this->trashImage($this->{$attribute_name});
                }

                $this->attributes[$attribute_name] = Storage::disk($disk)->url($new_path);
            } else {
                // we have a URL, lets just store it
                $this->attributes[$attribute_name] = $value;
            }

        }

    }

    // trash a S3 image
    public function trashImage($image_location)
    {
        // image location will be a whole URL, so we need to parse it and get the S3 true path
        $disk = config('features.use_s3_images') ? 's3' : 'public';

        $image_location = parse_url($image_location, PHP_URL_PATH);
        // Break down the image path into directory, filename, and extension
        $image_pathinfo = pathinfo($image_location);

        // Construct the new image location by adding the model's ID to the start of the filename
        $new_image_location = 'trash/'. $image_pathinfo['dirname'] . '/' . $this->id . '_' . $image_pathinfo['basename'];

        // Move the file to the new location within the "trash" directory
        Storage::disk($disk)->move($image_location, $new_image_location);

        // Set the visibility of the trashed file to 'private'
        Storage::disk($disk)->setVisibility($new_image_location, 'private');

    }

    protected function getImageDestinationPath($attribute_name)
    {
        // Define the specific destination path logic for each model
        $year = date('Y');
        $month = date('m');
        $day = date('d');

        // get the model name
        $model_name = strtolower(class_basename($this));

        return "/$model_name/$year/$month/$day/$attribute_name";
    }

    protected function getImageFilename($value, $attribute_name)
    {
        // Define the specific filename logic for each model
        $timestamp = time();
        return $attribute_name . '_' . "$timestamp" . '_' . md5($value.$timestamp) . '.jpg';
    }

}

and this is me using it in my user model to upload avatars

...
use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use CrudTrait, HasRoles;
    ...
    use ImageTrait;

...
    public function setAvatarAttribute($value)
    {
        $this->setImageUrlAttribute($value, 'avatar');
    }

Also, this was another important step, I needed to make sure i had a UserPolicy created like so:

<?php

namespace App\Policies;

use App\Models\User;
use Illuminate\Auth\Access\Response;

class UserPolicy
{
    /**
     * Determine whether the user can view any models.
     */
    public function viewAny(User $user): bool
    {
        //
    }

    /**
     * Determine whether the user can view the model.
     */
    public function view(User $user, User $model): bool
    {
        //
    }

    /**
     * Determine whether the user can create models.
     */
    public function create(User $user): bool
    {
        //
    }

    /**
     * Determine whether the user can update the model.
     */
    public function update(User $user, User $model): bool
    {
        //
    }

    /**
     * Determine whether the user can delete the model.
     */
    public function delete(User $user, User $model): bool
    {
        //
    }

    /**
     * Determine whether the user can restore the model.
     */
    public function restore(User $user, User $model): bool
    {
        //
    }

    /**
     * Determine whether the user can permanently delete the model.
     */
    public function forceDelete(User $user, User $model): bool
    {
        //
    }

    public function uploadFiles(User $user): bool
    {
        return true;
    }
}

to give vapor the permission to upload in the client side

with this provider

<?php

namespace App\Providers;

// use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use App\Policies\UserPolicy;

class AuthServiceProvider extends ServiceProvider
{
    /**
     * The model to policy mappings for the application.
     *
     * @var array<class-string, class-string>
     */
    protected $policies = [
        // 'App\Models\Model' => 'App\Policies\ModelPolicy',
        User::class => UserPolicy::class,
    ];

    /**
     * Register any authentication / authorization services.
     */
    public function boot(): void
    {
        //
    }
}

which is put in app.php in the providers

'providers' => [
...
App\Providers\AuthServiceProvider::class,

Finally, I was having trouble getting larvel-vapor client upload understanding the backpack auth, due the guard being different.

Setting the guard to null in base.php works i.e.

'guard' => null,

However I has a work around to run this in the crud which was a hack but worked:

$user = backpack_auth()->user();
Auth::login($user, $remember = true);

CRUD::addField([ ...

This was a hack that worked, and sure a better way, but thats what i landed on before i wiped the guard.

So thats about it. Im sure i have done some things in a round aout way, so very happy to hear improvements and so on! I especially think my lack of totally understanding on the AuthServiceProvider means im doing something the hard way around.

Anyway, once all of this is done, i simply push production or staging and 5 min later up on vapor :-)

One more note, make sure you have supplied VAPOR_API_TOKEN in github secrets, just ran into that on a new project

tabacitu commented 1 year ago

Thank you SO MUCH @malek77z . Seems like we can do quite a bit to improve the DX for Vapor users. That's GREAT. It'll take us a while to process this and come up with appropriate, more general solutions. Seems like a Size: Medium (1 week) and we don't have the bandwidth for that right now. But we will soon, after the dust settles with the v6 launch 😉

Thanks again, we super appreciate it!