관리 메뉴

Mini

24.10.15. 개발일지 // oauth fix, 이미지 업로드, Not allowed to load local resource error 해결 본문

JS/boostCamp

24.10.15. 개발일지 // oauth fix, 이미지 업로드, Not allowed to load local resource error 해결

Mini_96 2024. 10. 16. 22:50

* oauth fix

  • white list 추가

  • 배포를고려한 redirectURL 수정
async process(req: Request, res: Response, paramMap: Map<string, string>, model: Map<string, object>) {
    const clientId: string = process.env.GITHUB_CLIENT_ID;
    const clientSecret: string = process.env.GITHUB_CLIENT_SECRET;

    const host = req.headers.get('host');
    const path = '/auth/github/callback';
    const fullUrl = `${PROTOCOL.HTTP}://${host}${path}`;
    const redirectURI = fullUrl;
    const { code } = req.query;

    const tokenResponse = await axios.post('https://github.com/login/oauth/access_token', {
        client_id: clientId,
        client_secret: clientSecret,
        code,
        redirect_uri: redirectURI,
    }, {
  • 깃허브 설정 수정
    • localhost -> 실제 ip
    • 이래야 localhost로 안가고 운영서버로 콜백요청이 감

 

* 이미지 업로드 삽질...

  • post 컬럼추가
@Column({
  nullable:true,
})
image?: string;
  • 이미지 저장할 절대경로, 상대경로 추가
import {join} from 'path';

//서버 프로젝트의 루트폴더 절대경로
export const PROJECT_ROOT_PATH = process.cwd();

export const PUBLIC_FOLDER_NAME = 'public';

//포스트 이미지들을 저장할 폴더 이름
export const POSTS_FOLDER_NAME = 'posts';

//공개폴더의 절대경로
// /{}/public
export const PUBLIC_FOLDER_PATH = join(
  PROJECT_ROOT_PATH,
  PUBLIC_FOLDER_NAME
)

//포스트 이미지를 저장할 폴더
/// /{}/public/posts
export const POST_IMAGE_PATH = join(
  PUBLIC_FOLDER_PATH,
  POSTS_FOLDER_NAME
)

// 상대경로
// /public/posts/xxx.jpg
// FE에서 요청시 localhost붙여서 요청하면됨.
export const POST_PUBLIC_IMAGE_PATH = join(
  PUBLIC_FOLDER_NAME,
  POSTS_FOLDER_NAME,
)
  • body에 이런식으로 옴.

------WebKitFormBoundarycGsmlZXk3btPaghA
Content-Disposition: form-data; name="image"; filename="pepe.jpg"
Content-Type: image/jpeg

���� JFIF  ` `  �� C 




�� C		

��  � �" ��           	

j�]�4;ms������ok�<�� ǭ��Y~��־���c�?�M�^,�U����R&�&-��)�׸�ῆ��0��ѭt]>1�
���ǫs�t�=y5���B��j}��o�מ�����+���-PEP�� �RO�2߈��� ӕ�~-~�� �I?��~"�;� NV��|�(梒��0��CM� մ�[�Y��1,�H��}Af�;�`?���M�}'�ĝZU�c[M/Y�}��v��qI!�7A����9���#Oԭ5kX�n����e&��H�=C�@1I�S��yc�N��lḁ��$�&�E7�5��"��~x�'W�'���z�����
��2������<�O�O� =t�BhO�X�Ҿ�����qR�!�� �9|/�&���Xdq��w*?AX3� �=5�v&��M藺Nq_l��JM�ҳt�-����^�G��G�-��'�4	�o;Kx� =�k&�����	��k[��}�������"2���G��~����,�?���G�+J��	����><�a��4b� �0����]�}^��(��#���'� j�����M
2��g� �ӝ�j�<Mw�m<�l�A�놌7Q�]��:t�T����V��A7��O� �V����~����R�f;��Tʽo�� <��W��h�;������J~����5��
Z�m�U8�d3���E*��S���M�t▊( ��( ��( �����<9�� K�R�.��h6�f��nR���FO���ϟ�R�B?���� I.���O�8���?�(���U�_c� �B?m+/�3X���S�mspo$V��W8*$�yX�K��b@����R(�2�|�(��T�UP�Q�QE UUP�Q�QE UUP�Q�QE UUP�Q�QE UUP�Q�QE UUP�Q�QE ](��(���4Q@��
------WebKitFormBoundarycGsmlZXk3btPaghA--
  • buffer를 string으로 바꾸면 data 손실발생!
  • 특히 img처리시 바꾸면 안됨!
import * as fs from 'fs';

// 이미지 파일을 Buffer로 읽기
const imageBuffer = fs.readFileSync('example.jpg');

// Buffer를 문자열로 변환 (데이터 손실 발생)
const imageString = imageBuffer.toString('utf8');

// 문자열을 다시 Buffer로 변환
const reconstructedBuffer = Buffer.from(imageString, 'utf8');

// 원본 Buffer와 비교
console.log('Original buffer length:', imageBuffer.length);
console.log('Reconstructed buffer length:', reconstructedBuffer.length);
console.log('Are buffers equal?', imageBuffer.equals(reconstructedBuffer));

// 손실된 Buffer를 파일로 저장
fs.writeFileSync('reconstructed.jpg', reconstructedBuffer);

// 16진수로 출력하여 차이 확인
console.log('Original buffer (hex):', imageBuffer.slice(0, 20).toString('hex'));
console.log('Reconstructed buffer (hex):', reconstructedBuffer.slice(0, 20).toString('hex'));
  • 지금 내 was는 body를 toString후 저장하고있음.

  • 해결 : toString전의 BufferType인 rawBody를 사용

  • 위의사진에서
  • \r\n\r\n으로 파싱 -> \r\n으로 다시파싱 -> 첫번째원소 == boundary
  • data = \r\n\r\n이후의 data
  • imgData  = data에서 다시 boundary를 찾음 && 제거 => 파일의 끝
  • imgData를 저장
import * as fs from 'fs';
import * as path from 'path';
import {POST_IMAGE_PATH} from "../domain/common/const/path.const";
import {randomUUID} from "node:crypto";

export class MultiPartImageExtractor {
    private static CONTENT_TYPE_REGEX = /^Content-Type:\s*(.+)$/i;
    private static FILENAME_REGEX = /filename="(.+)"/i;

    private extractBoundaryAndData(buffer: Buffer): { boundary: string, data: Buffer } | null {
        const headerEnd = buffer.indexOf('\r\n\r\n');
        if (headerEnd === -1) {
            console.error('올바른 multipart/form-data 형식이 아닙니다.');
            return null;
        }

        const header = buffer.slice(0, headerEnd).toString('utf8');
        const boundary = header.split('\r\n')[0]

        const data = buffer.slice(headerEnd + 4); // '\r\n\r\n' 이후의 데이터

        return { boundary, data };
    }

    async saveImage(buffer: Buffer): Promise<string | null> {
        const extractedInfo = this.extractBoundaryAndData(buffer);
        if (!extractedInfo) {
            console.error('boundary와 데이터를 추출할 수 없습니다.');
            return null;
        }

        const { boundary, data } = extractedInfo;

        const uploadDir = POST_IMAGE_PATH;

        if (!fs.existsSync(uploadDir)) {
            fs.mkdirSync(uploadDir, { recursive: true });
        }

        const idx = data.indexOf(boundary)
        const imageData = data.slice(0, idx);

        const filepath = path.join(uploadDir, randomUUID()+'.png');

        return new Promise((resolve, reject) => {
            fs.writeFile(filepath, imageData, (err) => {
                if (err) {
                    console.error('파일 저장 중 오류 발생:', err);
                    reject(err);
                } else {
                    console.log(`이미지가 성공적으로 저장되었습니다: ${filepath}`);
                    resolve(data.toString('utf8'));
                }
            });
        });
    }
}

// 사용 예:
// const extractor = new MultipartImageExtractor();
// const buffer = // 여기에 multipart/form-data 형식의 Buffer 데이터
// extractor.saveImage(buf

결과

  • 현재 static serving은 src/main/views만 탐색한다.
  • 파일이 없을경우, public도 탐색하도록 수정.

 

 

  • 자꾸 url이 undifiend 에러가 뜬다.
try {
    const response = await fetch('/upload-image', {
        method: 'POST',
        body: formData
    });

    if (response.status === 200) {
        const result = await response.json();
        insertImageToEditor(result.imageUrl);
    } else {
        console.error('Image upload failed');
        alert('이미지 업로드에 실패했습니다.');
    }
} catch (error) {
    console.error('Error:', error);
    alert('이미지 업로드 중 오류가 발생했습니다.');
}
  • fetch요청에 대해 어떤 req가 생기는지 살펴보자.
POST /upload-image HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123XYZ
Content-Length: [전체 본문의 길이]

------WebKitFormBoundaryABC123XYZ
Content-Disposition: form-data; name="image"; filename="example.jpg"
Content-Type: image/jpeg

[이미지 파일의 바이너리 데이터]
------WebKitFormBoundaryABC123XYZ-
  • ???

  • 어딘가에서 맘대로 redirect 시키고 있다.

  • 문제 : 헤더가없는 요청이 들어와서 was가 터지고있음.

 

 

  • 다른 캠퍼들을 참고해보자. 
  • boundary를 기준으로 
  • 첫줄에만 header가있고 

  • postman에서하면 또 잘된다..

  • 찾아보니까 다른 컨텐트 타입들을 하나의 req안에서 처리하기위해 만들어놓은폼이다.
  • -> boundray를 찾고, 그안에서 contentType에 따른 처리를 각각 해줘야된다!

 

* 24.10.16. 처음부터 다시시작

req

  • header의 모습
POST /upload-image HTTP/1.1
User-Agent: PostmanRuntime/7.42.0
Accept: */*
Cache-Control: no-cache
Postman-Token: 533ed65f-8e4e-4587-bc58-76a06981a2de
Host: localhost:3000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------708332700606877762347998
Content-Length: 135360
  • body의 모습
----------------------------708332700606877762347998
Content-Disposition: form-data; name="txt"

hello
----------------------------708332700606877762347998
Content-Disposition: form-data; name=""; filename="pepe.jpg"
Content-Type: image/jpeg

����
  • test결과 첫번째요청은 header가 오는데 2번째 요청부터는 header는 제외된채로 전송이되는 문제가 있다.
  • 원인 : tcp, keep-alive로 인해 2번째 요청부터는 헤더제외 하고 요청을 보낸다!! -> 는 청크를 잘못하고있었기떄문
rawHeaders: POST /upload-image HTTP/1.1
User-Agent: PostmanRuntime/7.42.0
Accept: /
Cache-Control: no-cache
Postman-Token: 81a44f3e-0cb2-48bd-a74d-f39fdcb5ce7d
Host: localhost:3000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------024227012828041052730836
Content-Length: 135360
[183ac9d5-69f3-4a27-bde9-06cb965ce6c5] [POST] [/upload-image]
이미지가 성공적으로 저장되었습니다: C:\Users\Bisu\Desktop\boostCamp Membership\web-be-p2-was\web-be-p2-was\public\posts\fcd1f1b2-821e-4e31-92ef-c8b552d08e9c.png
rawHeaders: 
----------------------------110236873527679473783056
Content-Disposition: form-data; name=""; filename="pepe_maga.jpg"
Content-Type: image/jpeg

두번째 요청부터는 헤더가 전송되지않는이유?

 

네, 좋은 질문입니다. 이 상황은 HTTP 지속 연결(persistent connection) 또는 Keep-Alive 연결과 관련이 있습니다. 두 번째 요청부터 헤더가 전송되지 않는 것처럼 보이는 이유를 설명해 드리겠습니다.

  1. HTTP 지속 연결: 현대의 HTTP/1.1은 기본적으로 지속 연결을 사용합니다. 이는 하나의 TCP 연결을 통해 여러 HTTP 요청과 응답을 주고받을 수 있다는 의미입니다.
  2. 첫 번째 요청과 두 번째 요청의 차이:
    • 첫 번째 요청: 완전한 HTTP 헤더와 본문을 포함합니다.
    • 두 번째 이후의 요청: 같은 연결을 재사용하므로, 클라이언트는 전체 HTTP 헤더를 다시 보내지 않고, 변경된 부분만 보낼 수 있습니다.
  3. 멀티파트 폼 데이터의 특성: 멀티파트 폼 데이터는 여러 부분으로 나뉘어 전송됩니다. 각 부분은 자체적인 헤더와 본문을 가집니다.
  4. 데이터 스트리밍: 대용량 파일 업로드의 경우, 데이터가 청크(chunk) 단위로 스트리밍될 수 있습니다. 이 경우 첫 번째 청크에만 전체 헤더가 포함되고, 이후의 청크들은 데이터만 포함할 수 있습니다.

 

  • 해결방법1 : res에 connection close 명시 => 클라에서 연결을 끊도록함.
  • 해봤는데 안된다. 이게 원인이 아닌것 같다.

잘되는 예시 (postman)
잘안되는 예시 form

  • 간단한 htmlform으로 바꿔보자.
<form id="writing-form" class="writing-form" action="/post/save" method="POST" enctype="multipart/form-data">
    <div class="input-form">
        <div class="input-block">
            <label for="title" class="input-label">제목</label>
            <input
                    class="input-data title-input"
                    type="text"
                    id="title"
                    name="title"
                    required
                    placeholder="글의 제목을 입력하세요"
            />
        </div>
        <div class="input-block">
            <label for="content" class="input-label">제목</label>
            <input
                    class="input-data content-input"
                    type="text"
                    id="content"
                    name="content"
                    required
                    placeholder="글의 내용을 입력하세요"
            />
        </div>
        <div class="input-block">
            <label for="image" class="input-label">이미지 첨부</label>
            <input type="file" id="image" name="image" accept="image/*" class="image-upload">
        </div>
        <div class="submit-button-block">
            <button type="submit" class="button-long submit">작성완료</button>
        </div>
    </div>
</form>
  • 파싱대상 header, body
GET /post/save HTTP/1.1
User-Agent: PostmanRuntime/7.42.0
Accept: */*
Cache-Control: no-cache
Postman-Token: 4351a27f-23a9-4e27-a0c9-77ac4336c990
Host: localhost:3000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------068831087757975582587906
Content-Length: 12785
body: ----------------------------068831087757975582587906
Content-Disposition: form-data; name="title"

제목
----------------------------068831087757975582587906
Content-Disposition: form-data; name="content"

11
----------------------------068831087757975582587906
Content-Disposition: form-data; name=""; filename="pepe.jpg"
Content-Type: image/jpeg

����JFIF``��C
O�O�=t�BhO�X�Ҿ�����qR�!���9|/�&���Xdq��w*?AX3��=5�v&��M藺Nq_l��JM�ҳt�-����^�G��G�-��'�4 �o;Kx�=�k&����� ��k[��}�������"2���G��~����,�?���G�+J�� ����><�a��4b��0����]�}^��(��#���'� j�����M
2��g��ӝ�j�<Mw�m<�l�A�놌7Q�]��:t�T����V��A7��O��V����~����R�f;�Tʽo��<��W�h�;������J~����5��
Z�m�U8�d3��E*��S���M�t▊(��(��(�����<9��K�R�.��h6�f��nR���FO���ϟ�R�B
?���� I.���O�8���?�(���U�_c��B?m+/�3X���S�m⸮spo$V��W8*$�yX�K��b@����R⸮(�2�|�(��T�UP�Q�QEUUP�Q�QEUUP�Q�QEUUP�Q�QEUUP�Q�QEUUP�Q�QE](��(���4Q@��
----------------------------068831087757975582587906--
  • 서버에 로직추가
    • multipart인경우, 파싱후 req에 넣기
    • file은 req.file에 넣기
if(req.headers.get('content-type')?.includes('multipart/form-data')){
    const parsedFields = parseMultipartFormData(body);
    processMultipartData(parsedFields, req);
    console.log(parsedFields);
}

  • 문제 : 버퍼를 string 으로 바꾸면 data 손실발생
  • 해결 : 버퍼 그 자체로 조작 해야함.
import { Request } from './request';
import { Response } from './response';
import * as fs from "node:fs";
import * as path from "node:path";
import {randomUUID} from "node:crypto";
import {POST_IMAGE_PATH} from "../domain/common/const/path.const";

interface ParsedField {
    name: string;
    value: string | Buffer;
    filename?: string;
    contentType?: string;
}

export function parseMultipartFormData(bodyBuffer: Buffer): ParsedField[] {
    const result: ParsedField[] = [];
    let startIndex = 0;
    let endIndex = bodyBuffer.indexOf('\r\n'.charCodeAt(0));
    const boundary = bodyBuffer.slice(startIndex, endIndex).toString();

    startIndex = endIndex + 2; // Move past \r\n

    while (startIndex < bodyBuffer.length) {
        endIndex = bodyBuffer.indexOf(boundary, startIndex);
        if (endIndex === -1) break;

        const partBuffer = bodyBuffer.slice(startIndex, endIndex);
        const field = parsePartBuffer(partBuffer);
        if (field) result.push(field);

        startIndex = endIndex + boundary.length + 2; // Move past boundary and \r\n
    }

    return result;
}

function parsePartBuffer(partBuffer: Buffer): ParsedField | null {
    const headerEndIndex = partBuffer.indexOf('\r\n\r\n');
    if (headerEndIndex === -1) return null;

    const headerStr = partBuffer.slice(0, headerEndIndex).toString();
    const headers = parseHeaders(headerStr);
    const contentStartIndex = headerEndIndex + 4; // Move past \r\n\r\n

    const field: ParsedField = { name: headers.name, value: '' };
    if (headers.filename) {
        field.filename = headers.filename;
        field.contentType = headers.contentType;
        field.value = partBuffer.slice(contentStartIndex);
    } else {
        field.value = partBuffer.slice(contentStartIndex).toString().trim();
    }

    return field;
}

function parseHeaders(headerStr: string): { name: string; filename?: string; contentType?: string } {
    const headers: { name: string; filename?: string; contentType?: string } = { name: '' };
    const lines = headerStr.split('\r\n');

    for (const line of lines) {
        if (line.startsWith('Content-Disposition:')) {
            const nameMatch = line.match(/name="([^"]+)"/);
            if (nameMatch) headers.name = nameMatch[1];

            const filenameMatch = line.match(/filename="([^"]+)"/);
            if (filenameMatch) headers.filename = filenameMatch[1];
        } else if (line.startsWith('Content-Type:')) {
            headers.contentType = line.split(':')[1].trim();
        }
    }

    return headers;
}

export function processMultipartData(parsedFields: ParsedField[], req: Request): void {
    req.body = {};

    for (const field of parsedFields) {
        if (field.filename && field.contentType) {
            // This is a file
            req.file = {
                fieldname: field.name,
                originalname: field.filename,
                encoding: 'binary', // 바이너리 데이터이므로 'binary'로 설정
                mimetype: field.contentType,
                buffer: field.value instanceof Buffer ? field.value : Buffer.from(field.value, 'binary')
            };
        } else {
            // This is a text field
            req.body[field.name] = field.value.toString();
        }
    }
}

export async function saveUploadedImage(req: Request): Promise<string | null> {
    if (!req.file) {
        console.error('No file uploaded');
        return null;
    }

    const uploadDir = POST_IMAGE_PATH;

    // 업로드 디렉토리가 존재하지 않으면 생성
    if (!fs.existsSync(uploadDir)) {
        fs.mkdirSync(uploadDir, { recursive: true });
    }

    // 파일 확장자 추출
    const fileExt = path.extname(req.file.originalname);

    // UUID를 사용하여 고유한 파일 이름 생성
    const fileName = `${randomUUID()}${fileExt}`;

    const filePath = path.join(uploadDir, fileName);

    try {
        // 파일 저장
        // 파일 저장 (바이너리 모드 사용)
        await fs.promises.writeFile(filePath, req.file.buffer, { flag: 'wx' });
        console.log(`File saved successfully: ${filePath}`);
        return fileName; // 저장된 파일 이름 반환
    } catch (error) {
        console.error('Error saving file:', error);
        return null;
    }
}

  • 문제 : 브라우저요청시 여전히 header(url)가 없음.
  • 해결 : 기존의 handleSocket은 아래와같은 문제가 있다.

private async handleSocket(socket: net.Socket) {
    let buffer = Buffer.alloc(0);
    let headersParsed = false;
    let contentLength = 0;
    let headers = '';

    for await (const chunk of socket) {
        buffer = Buffer.concat([buffer, chunk]);

        if (!headersParsed) {
            const headerEndIndex = buffer.indexOf(CRLF + CRLF);
            if (headerEndIndex !== -1) {
                headers = buffer.slice(0, headerEndIndex).toString();
                contentLength = this.getContentLength(headers);
                buffer = buffer.slice(headerEndIndex + 4);
                headersParsed = true;
            }
        }

        if (headersParsed && (buffer.length >= contentLength || contentLength === 0)) {
            await this.handleRequest(headers, buffer.slice(0, contentLength), socket);
            buffer = buffer.slice(contentLength);
            headersParsed = false;
            contentLength = 0;
        }
    }
}

private handleSocketOld(socket: net.Socket){
    let bufferedData = Buffer.alloc(0);
    let contentLength = 0;
    let headers = '';

    socket.on('data', (data: Buffer) => {
        [bufferedData, headers, contentLength] = this.processData(data, socket, bufferedData, headers, contentLength);
    });

    socket.on('error', (error: Error) => {
        console.error('Socket error:', error);
    });
}

 private processData(
    data: Buffer,
    socket: net.Socket,
    bufferedData: Buffer,
    headers: string,
    contentLength: number
) : [Buffer, string, number] {
    const dataStr = data.toString();
    const headerEndIndex = dataStr.indexOf(CRLF + CRLF);

    if (headerEndIndex !== -1) {
        // 헤더의 끝을 찾은 경우, 헤더와 본문을 분리합니다.
        headers = dataStr.slice(0, headerEndIndex);
        contentLength = this.getContentLength(headers);
        bufferedData = Buffer.concat([bufferedData, data.slice(headerEndIndex + CRLF.length * 2)]);
    } else {
        // 헤더의 끝을 찾지 못한 경우, 데이터를 버퍼에 추가합니다.
        bufferedData = Buffer.concat([bufferedData, data]);
    }

   // 버퍼된 데이터의 길이가 콘텐츠 길이보다 크거나 같은 경우, 요청을 처리합니다.
    if (bufferedData.length >= contentLength) {
        this.handleRequest(headers, bufferedData.slice(0, contentLength), socket);
        bufferedData = bufferedData.slice(contentLength);
    }

    // console.log("=====================================");
    // console.log('bufferedData:', bufferedData, 'headers:', headers, 'contentLength:', contentLength);

    return [bufferedData, headers, contentLength];
}

특징작동하는 코드 (위)작동하지 않는 코드 (아래)

데이터 읽기 방식 for await...of 루프 'data' 이벤트 리스너
비동기 처리 async/await 사용 콜백 기반
버퍼 관리 단일 누적 버퍼 함수 내부에서 관리
요청 완전성 확인 헤더와 본문 모두 수신 후 처리 각 청크마다 처리 가능
에러 처리 루프 내에서 자동 catch 별도의 'error' 이벤트 핸들러
코드 복잡성 상대적으로 단순 상태 관리로 인해 복잡
메모리 사용 전체 요청을 버퍼에 저장 부분적으로 버퍼 사용
다중 요청 처리 순차적 처리 보장 동시 처리 가능성 있음
handleRequest 호출 시점 전체 요청 수신 후 부분 데이터로도 호출 가능
확장성 대용량 요청 처리에 제한 있음 스트리밍 처리 가능

 

* Not allowed to load local resource error 해결

  • 원인 : /C:boost/web-p2-was로 직접요청
  • 해결 : 이미지도 get 요청으로 받도록 수정 
<% if (post.image) { %>
    <img src="<%= imagePath.imagePath %>" alt="Post Image" class="post-image">
<% } %>
  • 이게 처음에 C:boost/~~ 경로로 되있었음
  • /public/posts/dlfksidkfsf.jpg로 변경
/**
 * image : 이미지의 이름 (uuid)
 * imageUrl : 이미지의 경로
 */
if(findPost.image){
    const image = findPost.image;
    const imagePath = '/'+path.join(POSTS_FOLDER_NAME_ABS, image);

    model.set("imagePath", {imagePath : imagePath});
}

 

* 본문에 이미지 표시 구현

  • post의 image이름을 db에 저장해놓았다. 123.jpg
  • post-detail 페이지에서 이 이름을 이용해 요청을 보낼 경로를 만든다. /public/posts/123.png
  • ejs의 img src에 넣으면 '  /public/posts/123.png '으로 서버에 다시 요청을 보낸다.
  • 서버는 이미지를 static-serving해준다.
<% if (post.image) { %>
    <img src="<%= imagePath.imagePath %>" alt="Post Image" class="post-image">
<% } %>

결과

 

* 이전글, 다음글 구현

  • post-detail controller에서 다음글, 이전글 정보도 db에서 가져온다.
const [prevPost, nextPost] = await Promise.all([
    this.postRepository.createQueryBuilder('post')
        .where('post.id < :id', { id: postId })
        .orderBy('post.id', 'DESC')
        .select(['post.id', 'post.title'])
        .getOne(),
    this.postRepository.createQueryBuilder('post')
        .where('post.id > :id', { id: postId })
        .orderBy('post.id', 'ASC')
        .select(['post.id', 'post.title'])
        .getOne()
]);
model.set("prevPost", prevPost);
model.set("nextPost", nextPost);
  • ejs에서 동적 렌더링
<div class="post-navigation">
    <div class="nav-buttons">
        <% if (prevPost) { %>
            <a href="/post/<%= prevPost.id %>" class="nav-button">이전글: <%= prevPost.title %></a>
        <% } else { %>
            <span class="nav-button disabled">이전글 없음</span>
        <% } %>
        <% if (nextPost) { %>
            <a href="/post/<%= nextPost.id %>" class="nav-button">다음글: <%= nextPost.title %></a>
        <% } else { %>
            <span class="nav-button disabled">다음글 없음</span>
        <% } %>
    </div>
    <div class="list-button">
        <a href="/index" class="nav-button">목록으로</a>
    </div>
</div>

결과
결과2

  • QA 도중 에러발생
  • 원인 : tcp 연결끊김 (???)

 

* bug : post의 image값이 지워지는 현상

  • ?? 머지