본문 바로가기
PHP

[PHP] 섬머노트(Summernote) 이미지, 영상 등록

by teamnova 2022. 5. 26.

이전 게시글 '[PHP] 섬머노트(Summernote) 사용해서 게시글 작성하기' 글의 다음 글입니다.

이전글을 먼저 보고와주세요. (https://stickode.tistory.com/466)

 

오늘은 이미지, 동영상을 업로드를 해보도록 하겠습니다.

 

먼저 이미지, 동영상 파일을 붙여넣을 수 있게 newpost.php 부터 수정하겠습니다.

newpost.php

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>글쓰기</title>
    <!-- include libraries(jQuery, bootstrap) -->
    <link href="http://netdna.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.css" rel="stylesheet">
    <script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.js"></script>
    <script src="http://netdna.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.js"></script>

    <!-- include summernote css/js-->
    <link href="http://cdnjs.cloudflare.com/ajax/libs/summernote/0.8.8/summernote.css" rel="stylesheet">
    <script src="http://cdnjs.cloudflare.com/ajax/libs/summernote/0.8.8/summernote.js"></script>

    <style>
    body {
        padding: 1rem;
    }

    h1 {
        text-align: center;
    }

    button {
        float: right;
    }

    #userInfoContainer {
        display: flex;
        justify-content: flex-end;
        gap: 1rem;
    }

    #inputTitle {
        width: 100%;
        font-size: xx-large;
    }

    /* 
    이미지, 비디오 버튼 삭제. 드래그앤 드랍만 사용하기 위해서 버튼을 삭제했다.
    이미지, 동영상 추가 버튼은 이해가 어려웠음.
     */
    .note-insert {
        display: none
    }
    </style>

</head>

<body>

    <h1>게시글 작성</h1>
    <div id="userInfoContainer">
        <div>
            <label for="inputNickname">
                닉네임 :
            </label>
            <input id="inputNickname" />
        </div>
        <div>
            <label for="inputPassword">
                비밀번호 :
            </label>
            <input id="inputPassword" type='password' />
        </div>
    </div>
    <input id="inputTitle" placeholder="제목을 작성해주세요" />
    <div id="summernote"></div>
    <button onclick="summit()">완료</button>


    <script>
    // 메인화면 페이지 로드 함수
    $(document).ready(function() {
        $('#summernote').summernote({
            placeholder: '내용을 작성하세요',
            height: 400,
            maxHeight: 400,
            callbacks: {
                // 파일 업로드시 동작하는 코드
                // onImageUpload 이지만 비디오 드랍도 동작함.
                onImageUpload: function(files) {
                    setFiles(files);
                },

                // 클립보드에 있는(윈도우 + 쉬프트 + s) 한 경우에 에디터에서 붙여넣기(컨트롤+v) 하는 경우
                // 섬머노트 기본 이미지 붙여넣기 기능을 막는 코드.
                // 없으면 이미지 2장씩 들어간다. ( 하나는 setFiles(file 형태) 로 하나는 base64(string 형태) 로 )
                onPaste: function(e) {
                    const clipboardData = e.originalEvent.clipboardData;
                    if (clipboardData && clipboardData.items && clipboardData.items.length) {
                        const item = clipboardData.items[0];
                        // 붙여넣는게 파일이고, 이미지면
                        if (item.kind === 'file' && item.type.indexOf('image/') !== -1) {
                            // 이벤트 막음
                            e.preventDefault();
                        }
                    }
                }
            },
        });
    });


    // summit 함수 만들기
    function summit() {

        const button = event.srcElement;
        button.disabled = true;

        // nickname, password, content를 가지고와서 formdata 로 전송
        const nickname = document.getElementById('inputNickname').value;
        const password = document.getElementById('inputPassword').value;
        const title = document.getElementById('inputTitle').value;
        let content = $('#summernote').summernote('code');

        const formData = new FormData;

        // 에디터 내부에 img, iframe 태그가 남아있는지 확인.
        const sommernoteWriteArea = document.getElementsByClassName("note-editable")[0];
        const srcArray = [];
        // getElementsByTagName 가 반환하는 형태는 HTMLCollection 인데 실제 배열이 없어서 forEach() 가 없음..
        // 그래서 Array.from 로 array 로 만들어줌.
        const iframeTags = Array.from(sommernoteWriteArea.getElementsByTagName('iframe'));
        const imgsTags = Array.from(sommernoteWriteArea.getElementsByTagName('img'));

        // 람다 사용함. ( 공부해보시면 좋을것 같네요.. )
        iframeTags.forEach(iframe => {
            srcArray.push(iframe.src);
        });
        imgsTags.forEach(img => {
            srcArray.push(img.src);
        });

        const filesArrayLenght = filesArray.length;
        for (let i = 0; i < filesArrayLenght; i++) {
            const itrFile = filesArray[i];

            // 에디터 안에 주소가 쓰이고 있으면
            if (srcArray.includes(itrFile.name)) {

                console.log(itrFile.name);

                // 이유는 모르겠는데 서버에서 받는 파일 이름은 스키마나 baseUrl값이 없어져있었다.
                // 그래서 여기서 문자열을 변환해주도록 만들었다.
                const pathSplitArray = itrFile.name.split('/');
                content = content.replace(itrFile.name, pathSplitArray[pathSplitArray.length - 1]);

                // 왼쪽부터 (서버에서 받을때 사용할 파일 배열키, 파일)
                // 서버에서 항상 배열로 받을려면 키 뒤에 '[]' 필요.
                formData.append('files[]', itrFile);
            }
            // 이제 url 객체는 필요없으니까 메모리 해제
            URL.revokeObjectURL(itrFile.name);
        }

        formData.append("nickname", nickname);
        formData.append("password", password);
        formData.append("title", title);
        formData.append("content", content);
        console.log(content);

        const httpRequest = new XMLHttpRequest();
        httpRequest.onreadystatechange = () => {
            if (httpRequest.readyState === XMLHttpRequest.DONE) {
                if (httpRequest.status === 200) {
                    console.log(httpRequest.response);
                    location.href = "/post.php?id=" + httpRequest.response;
                } else {
                    alert("게시물 등록중 오류가 발생했습니다.");
                    button.disabled = false;
                }
            }
        }
        httpRequest.open('post', '/summitPost.php', true);
        httpRequest.send(formData);
    }


    // filesArray 는 서버로 전송하기 전에 임시로 uri들을 들고 있는 배열이다.
    const filesArray = [];

    // 드래그앤 드랍시 동작하는 코드
    function setFiles(files) {
        const filesLenght = files.length;
        for (let i = 0; i < filesLenght; i++) {
            const file = files[i];

            if (file.type.match('image.*')) {
                // 임시 url 생성하는 부분
                const url = URL.createObjectURL(file);
                file.name = url;
                // filesArray 이름을 방금 받은 url 로 담아둔다. (나중에 서버로 파일 보낼때 필요)
                filesArray.push(new File([file], url, {
                    type: file.type
                }));

                // 에디터에 이미지 붙여넣기.
                const imgElement = document.createElement("img");
                imgElement.src = url;
                const sommernoteWriteArea = document.getElementsByClassName("note-editable")[0];
                sommernoteWriteArea.appendChild(imgElement);


            } else if (file.type.match('video.*')) {
                // 임시 url 생성하는 부분
                const url = URL.createObjectURL(file);
                console.log(file.type);
                filesArray.push(new File([file], url, {
                    type: file.type
                }));


                const videoIframe = document.createElement("iframe");
                videoIframe.src = url;
                videoIframe.width = "640px";
                videoIframe.height = "480px";
                videoIframe.frameBorder = "0";
                videoIframe.className = "note-video-clip";

                // 에디터에 영상 붙여넣기 note-editable 에 붙여넣으면 됌.
                const sommernoteWriteArea = document.getElementsByClassName("note-editable")[0];
                sommernoteWriteArea.appendChild(videoIframe);

                // 비디오나 이미지가 아니면
            } else {
                alert('지원하지 않는 파일 타입입니다.');
            }
        }
    }
    </script>
</body>

</html>

 

 

주석으로 대부분 설명이 되어 있으니 간단하게만 설명하자면 파일을 드래그 앤 드랍했을 때 동작하는 콜백(섬머노트에서 제공)에서 파일을 들고 있도록 코드를 추가했습니다.

 

setFiles 이벤트에서는 이미지나 비디오를 에디터에 붙여넣고 filesArray 에 담아둡니다.

 

마지막으로 완료 버튼이 눌리면 에디터 안에 있는 img, iframe 태그에 들어있는 모든 src 값을 가져와서 지금까지 저장하고 있던 파일들을 확인하고, 파일이 있으면 formData 에 넣어 주도록 작성했습니다.( 이 과정이 없으면 백스페이스로 지워진 파일도 서버로 보내짐 )

 

 

다음으로 받는쪽 코드 작성해보도록 하겠습니다.

summitPost.php

<?php

$mediaBaseUrl = 'http://localhost/upload/';
$mediaRoot = '/var/www/html/upload/';

// $mediaRoot 경로에 $iam( apm 자동설치 했으면 보통 www-data ) 유저, 그룹 권한과 755 권한을 줘야한다.
$iam = exec('whoami');

$nickname = $_POST['nickname'];
// 비밀번호 암호화
$password = password_hash($_POST['password'], PASSWORD_DEFAULT);
$title = $_POST['title'];
$content = $_POST['content'];

if (isset($_FILES['files'])) {

    $files = $_FILES['files'];
    $countfiles = count($files['name']);

    for ($i = 0; $i < $countfiles; $i++) {

        $filename = $files['name'][$i];
        // 확장자 가져오기. 보통 사용할때는 크게 문제 없어보임.
        // 혹시 확장자가 빈문자열이면 php.ini 업로드 용량 제한 확인해볼 것.
        $extension = explode('/', $files['type'][$i])[1];
        $filePath = $filename . '.' . $extension;

        // 해당 코드는 해킹 위험이 있습니다.
        // 관련 블로그 글( https://mytory.net/archives/3011 )
        // 파일 업로드 성공했다면
        if (move_uploaded_file($files['tmp_name'][$i], $mediaRoot . $filePath)) {
            // 기존 경로값을 서버의 파일 경로로 변경.
            $content = str_replace($filename, $mediaBaseUrl . $filePath, $content);

            // 파일 업로드 실패했다면
        } else {
            // 에러는 번호로 나옴. 구글 검색해볼 것.
            $error = $files['error'][0];
            die();
        }
    }
}

// 비밀번호 hash 후 길이 60( 궁금하면 echo 찍어보면 됨 )
$length = strlen($password);

include("../db.php");
$stmt = $mysqli->prepare("insert into board(nickname,password,title,content) values(?,?,?,?)");
// 닉네임(s), 컨텐츠(s), 패스워드(s) 를 각각 입력(sql 인젝션 방어 코드)
$stmt->bind_param("ssss", $nickname, $password, $title, $content);
$stmt->execute();
// 성공적으로 디비에 들어가면 게시물 id 값을 클라이언트로 보내줌.
echo $stmt->insert_id;
$stmt->close();

DB 정보를 저장 전에 파일을 저장하는 코드가 들어가도록 변경되었습니다.

 

설명은 주석을 참고해주세요.

 

다음으로 서버 루트 ( /var/www/html ) 에 upload 파일을 만들고 권한을 변경해주세요.

// 서버 루트로 이동
cd /var/www/html

// upload 파일 생성
mkdir upload

// 아파치 접속 유저(보통 www-data)로 upload 폴더 권한 변경
chown -R www-data:www-data upload

// upload 폴더 사용 권한 변경
chmod -R 755 upload

 

혹시 파일 업로드가 안되시면 php.ini 파일의 업로드 파일 용량 제한 설정을 변경하시고 아파치를 재시작 해주세요.

 

php.ini 파일 위치는 phpinfo(); 상단에 Loaded Configuration File 를 확인해보시면 됩니다.

 

 

이제 마지막으로 등록한 게시물을 볼 수 있는 화면을 만들어보도록 하겠습니다.

post.php

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>게시물</title>
    <style>
    body {
        padding: 1rem;
    }

    h1 {
        text-align: center;
    }

    #userInfoContainer {
        display: flex;
        justify-content: flex-end;
        gap: 1rem;
    }

    #title {
        width: 100%;
        font-size: xx-large;
    }
    </style>

</head>

<body>
    <?php
    $id = $_GET["id"];
    include("../db.php");
    $stmt = $mysqli->prepare("select * from board where id =?");
    $stmt->bind_param("i", $id);
    $stmt->execute();
    $result = $stmt->get_result();
    $post = $result->fetch_assoc();
    ?>

    <h1>게시글 작성</h1>
    <div id="userInfoContainer">
        <div>
            작성자 : <?php echo $post['nickname'] ?>
        </div>
    </div>
    <div id="title">
        <?php echo $post['title'] ?>
    </div>
    <div id="contents">
        <?php echo $post['content'] ?>
    </div>
</body>

</html>

 

newpost.php 에서 구조를 가져왔습니다.

 

특별한건 없고 get 에 들어있는 id 값을 가져와서 DB 검색 후 결과를 html 에 넣어주는 방식으로 구현했습니다.

 

 

결과 )