9Cells

WYSIWYG 에디터 Quill에서 이미지 업로드 구현

Quill은 인기있는 오픈소스 WYSIWYG 에디터입니다. Quill에서 이미지를 포함하는 문서를 작성할 수 있어도 서버에 이미지를 저장하는 작업은 quill의 몫이 아닙니다. Quill에서 이미지를 포함하는 문서를 작성하면 이미지는 문서 텍스트 안에 base64 인코딩이 된 형태로 저장됩니다. 이 텍스트를 그대로 사용할 수도 있겠지만 db에 이미지 텍스트를 저장하는 것은 부담이 됩니다. 아닌가? 아무튼 DB의 부담을 줄이기 위해 문서 저장 시 이미지를 별도의 파일로 저장하여 사용하는 방법을 알아봅시다.

코드

HTML

<div class="form-group">
    <label for="content">내용</label>
    <div ref="content">{!! $post ? $post->content : '' !!}</div>
    <input type="file" ref="image" class="d-none" @change="changeImage"/>
    <input type="hidden" v-model="content" name="content">
    <input type="hidden" v-model="imgjson" name="imgjson">
</div>

이 HTML 페이지는 텍스트 생성과 수정에 모두 쓰입니다. $post가 있으면 수정, 없으면 생성입니다. ref=content에 quill로 편집한 HTML을 출력하고 quill 라이브러리를 초기화하면 편집화면이 나타납니다.

ref=image는 이미지 선택 dialog를 띄우기 위한 숨겨진 input입니다. 이미지가 선택되면 changeImage를 호출하여 업로드 예정 이미지 배열을 만듭니다.

name=content, name=imgjson 이 hidden 필드는 에디터로 편집된 결과를 form submit 시에 서버에 전달하기 위해 추가한 것입니다.

Vue 코드

var vm = new Vue({
    el: '#app',
    data: function () {
        return {
            quill: null,
            content: null,
            images: [],
            imgjson: null
        };
    },
    mounted: function () {
        var self = this;
        this.quill = new Quill(self.$refs.content, {
            modules: {
                toolbar: {
                    container: [
                        [{'header': [1, 2, 3, 4, 5, 6, false]}],
                        ['image', 'bold', 'italic', 'underline', 'strike'],
                        ['blockquote', 'code-block'],
                        [{'list': 'ordered'}, {'list': 'bullet'}],
                        [{'script': 'sub'}, {'script': 'super'}],
                        [{'indent': '-1'}, {'indent': '+1'}],
                        [{'color': []}, {'background': []}],
                        ['clean']
                    ],
                    handlers: {
                        'image': function (value) {
                            if (value) {
                                self.$refs.image.click();
                            } else {
                                self.quill.format('image', false);
                            }
                        }
                    },
                }
            },
            theme: 'snow'
        });
    },
    methods: {
        changeImage: function (e) {
            if (e.target.files.length === 0) return;
            var self = this;
            var fr = new FileReader();
            fr.onloadend = function () {
                var img = fr.result,
                    idx = self.quill.getSelection().index;
                self.images.push(img);
                self.quill.insertEmbed(idx, 'image', img);
                self.quill.setSelection(idx + 1);
            }
            fr.readAsDataURL(e.target.files[0]);
        },
        clickSubmit: function (e) {
            this.content = this.quill.root.innerHTML;
            this.imgjson = JSON.stringify(this.images);
        }
    }
});

Quill을 초기화 합니다. 툴바에서 image가 선택되면 handlers에서 정의한대로 숨겨둔 file 타입 input을 click() 호출하여 파일 선택 dialog를 띄웁니다.

사용자가 이미지를 선택하면 changeImage()이 호출됩니다. 여기에서 이미지를 base64로 인코딩하여 에디터에 추가하게 되고 폼 전송 후 Laravel에서 파일로 저장할 수 있도록 images 배열에 따로 저장합니다.

사용자가 폼 전송을 하면 clickSubmit()이 호출됩니다. 사용자가 작성한 텍스트는 quill.root.innerHTML 을 얻어서 HTML 형태로 전송합니다. 여기서 HTML 대신에 quill의 delta 포맷을 전송하고 백엔드에서 사용자에게 보여줄 때 서버사이드 렌더링을 사용할 수도 있습니다. imgjson에는 업로드 예정인 base64 인코딩 이미지들의 배열을 json으로 인코딩하여 전송합니다.

Laravel에서 이미지 처리

    public function editPost()
    {
        $content = $request->get('content');
        $imgjson = $request->input('imgjson');
        $content = $this->saveImages($content, $imgjson);
    }

    private function saveImages($content, $imgjson)
    {
        $imgs = json_decode($imgjson, true);

        foreach ($imgs as $img) {
            $image_parts = explode(";base64,", $img);
            $image_type_aux = explode("image/", $image_parts[0]);
            $image_type = $image_type_aux[1];
            $image_base64 = base64_decode($image_parts[1]);
            $filename = Str::random(40);
            [$dir1, $dir2] = str_split($filename, 2);
            $date = Carbon::now()->format('Y/m/d');
            $path = "uploads/$date/$dir1/$dir2/$filename.$image_type";
            Storage::disk('public')->put($path, $image_base64);
            $content = str_replace($img, url("/storage/$path"), $content);
        }

        $dir = "HTMLPurifier";
        $cachePath = storage_path("app/$dir");
        if (!File::exists($cachePath)) {
            Storage::makeDirectory($dir);
        }
        $config = \HTMLPurifier_Config::createDefault();
        $config->set('Cache.SerializerPath', $cachePath);
        $purifier = new \HTMLPurifier($config);
        $content = $purifier->purify($content);

        return $content;
    }

서버는 클라이언트가 전송한 텍스트와 이미지 배열을 저장합니다. 이미지 배열은 파일로 만들어 public 공간에 저장합니다.

본문 텍스트에는 base64로 인코딩된 이미지 텍스트가 포함되어 있습니다. 이를 파일로 저장했으므로 본문의 이미지 텍스트는 파일의 url로 치환합니다.

XSS 공격을 막기위해 content를 HTMLPurifier로 purify합니다. ezyang/htmlpurifier 패키지를 사용했습니다. 사용자 입력을 그대로 DB에 저장하고 출력시에 purify 하는 것도 가능하지만 DB에 잠재적으로 문제가 될 수 있는 텍스트가 저장되는 것을 피하고 사용자 접근 때마다 매번 purify 연산을 해야하는 낭비를 줄이기 위해 DB 저장 시에 purify 합니다. 이 경우 quill이 만들어낸 스타일 설정이 purifier에 의해 지워지면 지워진채로 DB에 저장되기 때문에 이후 설정을 바꾼다고 해도 문서 편집시에 지정한 스타일을 복구할 수 없으므로 설정에 유의해야합니다.

정리

Quill 에디터에서 이미지를 포함한 문서를 작성하고 서버에 저장하는 방법을 알아봤습니다. Quill 에디터는 본문 이미지를 base64 형태로 인코딩하여 전송하기 때문에 이를 db에 그대로 저장하기에 부담스럽습니다. 편집 문서 전송시, 이미지 정보를 추가적으로 포함하여 이미지는 별도로 저장하고 본문의 이미지는 저장된 파일로 바꾸는 식으로 구현을 했습니다.