diff --git a/package.json b/package.json index d3d908e..d7d1d26 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/features/publish/api/deleteStore.ts b/src/features/publish/api/deleteStore.ts index b15f9a7..bc9aef9 100644 --- a/src/features/publish/api/deleteStore.ts +++ b/src/features/publish/api/deleteStore.ts @@ -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; @@ -17,7 +19,7 @@ interface Props extends IFireStore { */ export default async function deleteStore(props: Props): Promise { 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)); diff --git a/src/features/publish/api/uploadImage.ts b/src/features/publish/api/uploadImage.ts index 207967b..2b481ee 100644 --- a/src/features/publish/api/uploadImage.ts +++ b/src/features/publish/api/uploadImage.ts @@ -5,6 +5,7 @@ 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 { @@ -12,9 +13,10 @@ interface Props extends IFireStore { } /** - * @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 { const { src, collection, category, title } = props; @@ -63,10 +65,8 @@ export default async function uploadImage(props: Props): Promise { // 유닉스 타임 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 () => { diff --git a/src/features/publish/lib/getStorageFolder.spec.ts b/src/features/publish/lib/getStorageFolder.spec.ts new file mode 100644 index 0000000..4e1de75 --- /dev/null +++ b/src/features/publish/lib/getStorageFolder.spec.ts @@ -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])); + }); +}); diff --git a/src/features/publish/lib/getStorageFolder.ts b/src/features/publish/lib/getStorageFolder.ts new file mode 100644 index 0000000..3045384 --- /dev/null +++ b/src/features/publish/lib/getStorageFolder.ts @@ -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('/'); +} diff --git a/src/features/publish/lib/toSafePathSegment.spec.ts b/src/features/publish/lib/toSafePathSegment.spec.ts new file mode 100644 index 0000000..8dd5735 --- /dev/null +++ b/src/features/publish/lib/toSafePathSegment.spec.ts @@ -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('유클러버스'); + }); +}); diff --git a/src/features/publish/lib/toSafePathSegment.ts b/src/features/publish/lib/toSafePathSegment.ts new file mode 100644 index 0000000..69daf4f --- /dev/null +++ b/src/features/publish/lib/toSafePathSegment.ts @@ -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, '-') + ); +} diff --git a/src/features/publish/lib/toStorageFileName.ts b/src/features/publish/lib/toStorageFileName.ts index 163f2e5..07b52a7 100644 --- a/src/features/publish/lib/toStorageFileName.ts +++ b/src/features/publish/lib/toStorageFileName.ts @@ -1,5 +1,4 @@ -/** 파일 이름에 남길 문자. 이 밖의 문자가 이어지면 `-` 하나로 바꾼다 */ -const UNSAFE_CHARS_PATTERN = /[^\p{L}\p{N}_-]+/gu; +import toSafePathSegment from './toSafePathSegment'; /** * @description 이미지 주소에서 Storage 에 올릴 파일 이름을 만든다. @@ -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); }