Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"sideEffects": true,
"type": "module",
"version": "3.0.3",
"version": "3.0.4",
"packageManager": "pnpm@9.15.0",
"keywords": [
"react-router",
Expand Down
4 changes: 3 additions & 1 deletion src/features/publish/api/deleteStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import Logger from '@/commons/lib/logger';
import { storage } from '@/commons/api/firebase.server';
import { IFireStore } from '@/commons/types/global';

import getStorageFolder from '../lib/getStorageFolder';

interface Props extends IFireStore {
/** 지우지 않을 파일의 Storage 경로. 방금 저장한 문서가 가리키는 파일이다 (`getStoragePaths`) */
keep: Set<string>;
Expand All @@ -17,7 +19,7 @@ interface Props extends IFireStore {
*/
export default async function deleteStore(props: Props): Promise<void> {
const { category, title, collection, keep } = props;
const folder = `${collection}/${category}/${title}`;
const folder = getStorageFolder({ collection, category, title });

try {
const res = await listAll(ref(storage, folder));
Expand Down
14 changes: 7 additions & 7 deletions src/features/publish/api/uploadImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ import Logger from '@/commons/lib/logger';
import { storage } from '@/commons/api/firebase.server';
import { IFireStore } from '@/commons/types/global';

import getStorageFolder from '../lib/getStorageFolder';
import toStorageFileName from '../lib/toStorageFileName';

interface Props extends IFireStore {
src: string;
}

/**
* @summary firebase에 image를 업로드하는 함수
* @param props
* @returns
* @description 원본 이미지를 받아 webp(gif 는 그대로)로 바꾸고 Firebase Storage 의 문서 폴더에 올린다
* @param props 원본 이미지 주소와 문서 폴더(collection/category/title)
* @returns 올린 파일의 GCP 공개 주소
* @throws 주소가 비었거나 이미지가 아닌 응답을 받으면 에러
*/
export default async function uploadImage(props: Props): Promise<string> {
const { src, collection, category, title } = props;
Expand Down Expand Up @@ -63,10 +65,8 @@ export default async function uploadImage(props: Props): Promise<string> {
// 유닉스 타임
const hashTime = new Date().getTime();

const collectionRef = ref(
storage,
`${collection}/${category}/${title}/${filename}-${hashTime}.${formatExt}`,
);
const folder = getStorageFolder({ collection, category, title });
const collectionRef = ref(storage, `${folder}/${filename}-${hashTime}.${formatExt}`);

// firebase에 올린 파일 주소 얻기
const imgFirebaseUrl = await uploadBytes(collectionRef, data, metadata).then(async () => {
Expand Down
35 changes: 35 additions & 0 deletions src/features/publish/lib/getStorageFolder.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* `getStorageFolder` 는 `uploadImage` 가 이미지를 올리고 `deleteStore` 가 옛 파일을 지우는 폴더를 정한다.
*
* 제목에 괄호가 들어간 글은 폴더 이름에 괄호가 남아, 발행할 때마다 그 글의 썸네일과 본문 이미지가 모두 지워졌다 (#131).
* 올린 파일의 주소에서 `getStoragePaths` 가 꺼낸 경로가 실제 경로와 같아야 `deleteStore` 가 새 파일을 남긴다.
*/
import { describe, expect, it } from 'vitest';

import getStorageFolder from './getStorageFolder';
import getStoragePaths from './getStoragePaths';

const MFE = {
collection: 'post',
category: 'frontend',
title: '쌩-npm으로-MFE-구축하기-(1)-:-개념',
};

describe('getStorageFolder 는 collection/category/title 폴더 경로를 만든다', () => {
it('제목의 괄호와 콜론을 - 로 바꾼다', () => {
expect(getStorageFolder(MFE)).toBe('post/frontend/쌩-npm으로-MFE-구축하기-1-개념');
});

it('안전한 제목은 그대로 쓴다', () => {
expect(
getStorageFolder({ collection: 'project', category: 'team-projects', title: '유클러버스' }),
).toBe('project/team-projects/유클러버스');
});

it('올린 파일의 주소에서 꺼낸 경로가 실제 경로와 같다', () => {
const fullPath = `${getStorageFolder(MFE)}/thumbnail-mfe-1790488653891.webp`;
const url = `https://storage.googleapis.com/jaehan-flow.appspot.com/${encodeURIComponent(fullPath)}`;

expect(getStoragePaths(url)).toEqual(new Set([fullPath]));
});
});
17 changes: 17 additions & 0 deletions src/features/publish/lib/getStorageFolder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type IFireStore } from '@/commons/types/global';

import toSafePathSegment from './toSafePathSegment';

/**
* @description 문서의 이미지를 올리고 지우는 Storage 폴더 경로를 만든다.
* `uploadImage` 와 `deleteStore` 가 같이 쓴다. 둘이 다른 폴더를 보면 새 파일이 옛 파일로 지워지거나 옛 파일이 남는다.
* `title` 은 Firestore 문서 id 로도 쓰는 값이라 그대로 받고, Storage 경로에서만 정리한다 (#131)
* @param props 컬렉션, 카테고리, `spaceToDash` 를 적용한 제목
* @returns `collection/category/title` 형식의 폴더 경로
* @example
* getStorageFolder({ collection: 'post', category: 'frontend', title: '쌩-npm으로-MFE-구축하기-(1)-:-개념' });
* // 'post/frontend/쌩-npm으로-MFE-구축하기-1-개념'
*/
export default function getStorageFolder({ collection, category, title }: IFireStore): string {
return [collection, category, title].map(toSafePathSegment).join('/');
}
34 changes: 34 additions & 0 deletions src/features/publish/lib/toSafePathSegment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* `toSafePathSegment` 는 Storage 경로의 폴더 이름과 파일 이름을 정리하는 규칙 하나다.
*
* 여기서 괄호 같은 문자가 남으면 `getStoragePaths` 가 주소에서 경로를 잘못 잘라, 발행 직후 방금 올린
* 이미지를 옛 파일로 보고 지운다. 응답은 200 이고 이미지만 깨지므로 조용히 실패한다 (#129, #131).
*/
import { describe, expect, it } from 'vitest';

import toSafePathSegment from './toSafePathSegment';

describe('toSafePathSegment 는 경로 조각에 문자, 숫자, -, _ 만 남긴다', () => {
it.each([
['괄호', 'team_(1)', 'team_-1-'],
['콜론', 'a:b', 'a-b'],
['공백', 'a b', 'a-b'],
['슬래시', 'a/b', 'a-b'],
])('%s 를 - 로 바꾼다', (_name, input, expected) => {
expect(toSafePathSegment(input)).toBe(expected);
});

it('바꾼 뒤 이어진 - 는 하나로 합친다', () => {
expect(toSafePathSegment('쌩-npm으로-MFE-구축하기-(1)-:-개념')).toBe(
'쌩-npm으로-MFE-구축하기-1-개념',
);
});

it('이미 안전한 조각은 그대로 둔다', () => {
expect(toSafePathSegment('함수-타입-선언하기')).toBe('함수-타입-선언하기');
});

it('자모가 나뉜 한글도 합쳐서 그대로 둔다', () => {
expect(toSafePathSegment('유클러버스'.normalize('NFD'))).toBe('유클러버스');
});
});
22 changes: 22 additions & 0 deletions src/features/publish/lib/toSafePathSegment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** 경로 조각에 남길 문자. 이 밖의 문자가 이어지면 `-` 하나로 바꾼다 */
const UNSAFE_CHARS_PATTERN = /[^\p{L}\p{N}_-]+/gu;

/**
* @description Storage 경로 조각(폴더 이름, 파일 이름)에서 문자, 숫자, `-`, `_` 만 남기고 나머지를 `-` 로 바꾼다.
* 괄호처럼 `encodeURIComponent` 가 인코딩하지 않는 문자가 주소에 남으면 `getStoragePaths` 가
* 경로를 잘못 잘라, 방금 올린 파일이 옛 파일로 분류되어 지워진다 (#129, #131)
* @param name 경로 조각 하나. `/` 가 들어 있으면 `-` 로 바뀐다
* @returns 주소와 Storage 경로에서 모양이 같은 경로 조각
* @example
* toSafePathSegment('쌩-npm으로-MFE-구축하기-(1)-:-개념'); // '쌩-npm으로-MFE-구축하기-1-개념'
*/
export default function toSafePathSegment(name: string): string {
// 자모가 나뉜(NFD) 이름은 결합 문자가 `-` 로 바뀌지 않게 먼저 합친다
return (
name
.normalize('NFC')
.replace(UNSAFE_CHARS_PATTERN, '-')
// 공백을 `-` 로 바꾼 제목(`-(1)-:-`)에서 `-` 가 여럿 이어지지 않게 합친다
.replace(/-{2,}/g, '-')
);
}
9 changes: 4 additions & 5 deletions src/features/publish/lib/toStorageFileName.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/** 파일 이름에 남길 문자. 이 밖의 문자가 이어지면 `-` 하나로 바꾼다 */
const UNSAFE_CHARS_PATTERN = /[^\p{L}\p{N}_-]+/gu;
import toSafePathSegment from './toSafePathSegment';

/**
* @description 이미지 주소에서 Storage 에 올릴 파일 이름을 만든다.
Expand All @@ -18,9 +17,9 @@ export default function toStorageFileName(src: string): string {
// 2. 첫 `.` 앞까지만 남겨 확장자를 뗀다
const basename = lastSegment.split('.')[0];

// 3. 퍼센트 인코딩을 풀고, 자모가 나뉜(NFD) 이름은 결합 문자가 `-` 로 바뀌지 않게 먼저 합친다
const decoded = decodeURIComponent(basename).normalize('NFC');
// 3. 퍼센트 인코딩을 푼다
const decoded = decodeURIComponent(basename);

// 4. 문자, 숫자, `-`, `_` 이외의 문자를 `-` 로 바꾼다
return decoded.replace(UNSAFE_CHARS_PATTERN, '-');
return toSafePathSegment(decoded);
}
Loading