jazzband / django-tinymce

TinyMCE integration for Django
http://django-tinymce.readthedocs.org/
MIT License
1.29k stars 316 forks source link

tinymce django image upload #356

Open zealouskestrel opened 3 years ago

zealouskestrel commented 3 years ago

i wanna know how upload image i already set tinymce.init and the problem is the images_upload_url how i can upload them and it's require csrf too :( help meeee problem tinymce upload image Screenshot_2021-06-09_00-13-04

StevenMapes commented 1 year ago

You need to write the JS uploader and then the Django view to handle the upload yourself. Here's what I did to achieve this where I use Django Storages as the backend but I use default_storage to write the file to the backend so everything should work for you with the exception of the full URL to the uploaded file.

Step 1. Ensure the the these two keys are set within the TINYMCE_DEFAULT_CONFIG

    "images_upload_url": "/tinymce/upload",
    "images_upload_handler": "tinymce_image_upload_handler"

The first is the path to which you would like the javascript to make the POST. The 2nd is the name of the javascript function to handle the upload

Step 2 Because of CSRF you need to be able to read the token. In order to do this I use the js-cookie lib mentioned within the Django docs

Add the below into your settings.py to tell it yo include two additional JS file. I'm using the jsdelivr CDN hosted version for the above package

TINYMCE_EXTRA_MEDIA = {
    'css': {
        'all': [
        ],
    },
    'js': [
        "https://cdn.jsdelivr.net/npm/js-cookie@3.0.1/dist/js.cookie.min.js",
        "admin/js/tinymce-upload.js",
    ], 
}

The second file is holds the custom JS file upload handler I will post below.

Step 3. Here are the contents of the tinymce-upload.js file I created. Note it uses cookie-js to read the value of the CSRF token and add it to the headers of the AJAX POST. I also hardset the URL path to post to as the same value as the one I add in the settings.py. You may be able to read it from somewhere but I haven't looked into that yet.

function tinymce_image_upload_handler (blobInfo, success, failure, progress) {
    let xhr, formData;
    xhr = new XMLHttpRequest();
    xhr.withCredentials = false;
    xhr.open('POST', '/tinymce/upload');
    xhr.setRequestHeader('X-CSRFToken', Cookies.get("csrftoken"));
    xhr.upload.onprogress = function (e) {
        progress(e.loaded / e.total * 100);
    };
    xhr.onload = function() {
        let json;

        if (xhr.status === 403) {
            failure('HTTP Error: ' + xhr.status, { remove: true });
            return;
        }

        if (xhr.status < 200 || xhr.status >= 300) {
            failure('HTTP Error: ' + xhr.status);
            return;
        }

        json = JSON.parse(xhr.responseText);

        if (!json || typeof json.location != 'string') {
            failure('Invalid JSON: ' + xhr.responseText);
            return;
        }

        success(json.location);
    };
    xhr.onerror = function () {
        failure('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
    };

    formData = new FormData();
    formData.append('file', blobInfo.blob(), blobInfo.filename());

    xhr.send(formData);
}

Step 4 Write the view to handle the upload. In my case I want to use a subfolder of media called tinymce with a uuid4 named folder to then hold the file in to avoid name clashes.

The key part is to return a JSONResponse with the location of the image you just uploaded.

In my case I am using Django Storages to store files on S3 but I serve them from AWS Cloudfront using a custom domain that I have set within settings.py so I append that to the start

def tinymce_upload(request):
    """Uplaod file to S3 and return the location"""
    file = request.FILES.get('file')
    filename = f"tinymce/{uuid4()}/{str(file)}"
    with default_storage.open(filename, "wb") as f:
        f.write(file.read())

    return JsonResponse({"location": f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/media/{filename}"})

Now I can upload images using the TinvMCE plugin ensuring that CSRF checks are made

newtonsbm commented 1 year ago

Althought its unsecure, you could exempt the view images_upload_url from csrf token. Take a look at this: https://docs.djangoproject.com/en/4.1/howto/csrf/#disabling-csrf-protection-for-just-a-few-views

some1ataplace commented 1 year ago

I did not test any of this this but hopefully someone could make a PR from this.

When using Django's CsrfViewMiddleware to include a CSRF token in your Django form to protect it from Cross-Site Request Forgery attacks, it can be tricky to perform file uploads when the user wants to upload images or other files that do not have an appropriate HTML form. Here's an alternative approach to include the CSRF token in the upload request when using django-tinymce:

  1. Add the following JS code in your template that will extract the CSRF token from the cookie and insert it into a POST request that uploads the image:
function tinymce_upload_handler(file, success, failure) {
    var csrftoken = getCookie('csrftoken');
    var xhr, formData;

    xhr = new XMLHttpRequest();
    xhr.withCredentials = true;
    xhr.onreadystatechange = function () {
        if (xhr.readyState === 4) {
            if (xhr.status === 200) {
                success(JSON.parse(xhr.responseText).location);
            } else {
                failure('HTTP Error: ' + xhr.status);
            }
        }
    };

    formData = new FormData();
    formData.append('file', file);
    formData.append('csrfmiddlewaretoken', csrftoken);

    xhr.open('POST', '/upload-handler/');
    xhr.setRequestHeader('X-CSRFToken', csrftoken);
    xhr.send(formData);
}

function getCookie(name) {
    var cookieValue = null;
    if (document.cookie && document.cookie !== '') {
        var cookies = document.cookie.split(';');
        for (var i = 0; i < cookies.length; i++) {
            var cookie = cookies[i].trim();
            // Does this cookie string begin with the name we want?
            if (cookie.substring(0, name.length + 1) === (name + '=')) {
                cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                break;
            }
        }
    }
    return cookieValue;
}
  1. Next, create a view that will handle the file upload. For example:
   from django.http import JsonResponse

   def upload_image(request):
       if request.method == 'POST':
           image_file = request.FILES.get('file')
           # handle uploaded file
           return JsonResponse({'success': True})
       return JsonResponse({'success': False})
  1. In the tiny_mce_init.js file, define a custom file picker that includes the CSRF token:
   file_picker_callback: function(callback, value, meta) {
        var input = document.createElement('input');
        input.setAttribute('type', 'file');
        input.setAttribute('accept', 'image/*'); // Only accept image files
        input.onchange = function() {
            var file = this.files[0];
            var reader = new FileReader();
            reader.onload = function () {
                // Build the request to send to the server
                var xhr = new XMLHttpRequest();
                xhr.open('POST', '/upload/file/', true);
                xhr.setRequestHeader("X-CSRFToken", getCookie("csrftoken")); // Include the CSRF token
                xhr.setRequestHeader("Content-Type", "application/octet-stream");
                xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
                xhr.onreadystatechange = function() {
                    if (xhr.readyState == 4 && xhr.status == 200) {
                        var json = JSON.parse(xhr.responseText);
                        if (!json.error) {
                            callback(json.location); // Return the URL of the uploaded image
                        } else {
                            alert(json.error);
                        }
                    }
                };

In your Django URLconf, add the following URL pattern:

from django.conf.urls import url
from django.views.decorators.csrf import csrf_exempt
from tinymce.views import handle_upload

urlpatterns = [
    url(r'^tinymce/upload/$', csrf_exempt(handle_upload), name='tinymce_upload'),
]

In this example, we're adding a URL pattern that maps the URL /tinymce/upload/ to the handle_upload view provided by Django-tinymce. We're also using the csrf_exempt decorator to disable CSRF protection for this view.

In your TinyMCE configuration, add the following options:

TINYMCE_DEFAULT_CONFIG = {
    'image_upload_url': reverse('tinymce_upload'),
    'image_upload_method': 'POST',
}

In this example, we're setting the image_upload_url option to the URL pattern we defined earlier (reverse('tinymce_upload')), and the image_upload_method option to 'POST'.

Note that by using csrf_exempt, we're disabling CSRF protection for the handle_upload view. If you want to enable CSRF protection for this view, you'll need to use a different approach, such as including the CSRF token in the upload request.

from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse

@csrf_exempt
def upload_file(request):
    if request.method == 'POST':
        # Here you can handle the uploaded file and store it somewhere
        # Make sure to return a JSON response with the URL of the uploaded file
        # For example
        return JsonResponse({'location': '/path/to/uploaded/file'})

In your tiny_mce_init.js file, update the file_picker_callback option to use the tinymce_upload_handler function we defined earlier and set the upload_url option to the URL of the file upload view you just created. Here's an example:

tinymce.init({
    file_picker_callback: function(callback, value, meta) {
        tinymce_upload_handler(callback, value, meta, '/upload-file/');
    },
    upload_url: '/upload-file/'
});

In your urls.py, add a URL pattern that will map to the view you just created. Here's an example:

from django.urls import path
from .views import image_upload_view

urlpatterns = [
    path('upload-image/', image_upload_view, name='image_upload_view'),
]

In your template or JavaScript file, use the tinymce.init function to initialize TinyMCE with a custom image_upload_url that corresponds to the URL pattern you just added to your urls.py.

tinymce.init({
    selector: '#mytextarea',
    plugins: 'image',
    toolbar: 'image',
    image_upload_url: '/upload/image/',
    image_upload_credentials: true,
    file_picker_types: 'image',
    file_picker_callback: function (callback, value, meta) {
        tinymce.activeEditor.windowManager.openUrl({
            url: '/file-picker/',
            onMessage: function (api, message) {
                callback(message.data);
            }
        });
    }
});

Test your implementation by creating a new Post or Page in your site that contains an image. When you click the image button in TinyMCE's toolbar, you should see an "Upload" tab that allows you to select an image file and upload it to your Django application. Once the upload is complete, the image should be inserted into the text editor.

mehdi-mirzaie78 commented 1 month ago

Hi there, I used @StevenMapes solution and tried to improve it a little bit. Thanks to him

  1. in settings.py add images_upload_url and file_picker_callback:

TINYMCE_DEFAULT_CONFIG = {
    "height": 490,
    "promotion": False,
    "menubar": False,
    "width": "full",
    "plugins": "advlist autolink lists link image media charmap preview anchor pagebreak directionality emoticons fullscreen table visualblocks code codesample",
    "toolbar1": "fullscreen preview | undo redo | fontfamily fontsize | numlist bullist accordion | indent outdent ltr rtl | blocks",
    "toolbar2": (
        "pagebreak footnotes | bold italic underline strikethrough subscript superscript | formatpainter backcolor forecolor removeformat |"
        "align lineheight | charmap emoticons link image media table codesample | code visualblocks"
    ),
    # Add these two lines
    "images_upload_url": "/tinymce/upload/",
    "file_picker_callback": "filePickerCallback",
}

right after that add this


TINYMCE_EXTRA_MEDIA = {
    "css": {
        "all": [],
    },
    "js": [
        "https://cdn.jsdelivr.net/npm/js-cookie@3.0.1/dist/js.cookie.min.js",
        # Path to the script
        "admin/js/tinymce-upload.js", # change it accordingly
    ],
}
  1. add the script (tinymce-upload.js) with this content

    Note: Don't forget to check the value of images_upload_url must be the same xhr.open("POST", images_upload_url);


function filePickerCallback(cb, value, meta) {
  var input = document.createElement("input");
  input.setAttribute("type", "file");

  if (meta.filetype == "image") {
    input.setAttribute("accept", "image/*");
  }

  input.onchange = function () {
    var file = this.files[0];
    var formData = new FormData();
    formData.append("file", file);

    var xhr = new XMLHttpRequest();
    xhr.open("POST", "/tinymce/upload/"); // change this accordingly
    xhr.setRequestHeader("X-CSRFToken", Cookies.get("csrftoken"));

    xhr.onload = function () {
      if (xhr.status < 200 || xhr.status >= 300) {
        console.error("Upload failed with status: " + xhr.status);
        return;
      }

      var json = JSON.parse(xhr.responseText);
      if (!json || typeof json.location !== "string") {
        console.error("Invalid JSON response: " + xhr.responseText);
        return;
      }

      cb(json.location, { title: file.name });
    };

    xhr.onerror = function () {
      console.error("Upload failed.");
    };

    xhr.send(formData);
  };

  input.click();
}
  1. write a view to handle uploading in the backend

from core.models import File
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def tinymce_upload(request):
    """Upload file to AWS S3 and return the location."""
    if request.method != "POST" or not request.FILES.get("file"):
        return JsonResponse({"error": "Invalid request"}, status=400)

    file = request.FILES["file"]
    file_instance = File.objects.create(file=file)
    return JsonResponse({"location": file_instance.file.url})

Note: Here I configured AWS and django storages and I also added a model called File:

settings.py


STORAGES = {
    "default": {
        "BACKEND": "storages.backends.s3.S3Storage",
    },
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
    },
}

AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL")
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME")
AWS_SERVICE_NAME = os.getenv("AWS_SERVICE_NAME", "s3")
AWS_S3_FILE_OVERWRITE = False if os.getenv("AWS_S3_FILE_OVERWRITE") == "False" else True
AWS_LOCAL_STORAGE = BASE_DIR / os.getenv("AWS_LOCAL_STORAGE", "aws")
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False

in one of your applications in models.py


class File(models.Model):
    file = models.FileField()

    def __str__(self):
        return self.name

You can use default_storage functionality and generate the url yourself I preferred this solution to make it more flexible.

Hope it helps! ✌️