memi-board 0.45.0 → 0.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/composables/useMemiBoardComments.d.ts +2 -3
- package/dist/{en-BVUBLkxc.js → en-DxkqyZq2.js} +2 -1
- package/dist/{en-BVUBLkxc.js.map → en-DxkqyZq2.js.map} +1 -1
- package/dist/index.js +776 -741
- package/dist/index.js.map +1 -1
- package/dist/runtime/components/CategorySettings.vue +3 -1
- package/dist/runtime/components/CommentThread.vue +1 -1
- package/dist/runtime/components/Editor.vue +1 -0
- package/dist/{slugify-6bG6GSO_.js → slugify-BuOMFLxN.js} +3 -2
- package/dist/{slugify-6bG6GSO_.js.map → slugify-BuOMFLxN.js.map} +1 -1
- package/dist/storage.js +62 -49
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/utils/deletePostCascade.d.ts +0 -2
- package/dist/utils/findReadablePost.d.ts +4 -0
- package/dist/utils/saveBoardVisibility.d.ts +3 -0
- package/dist/utils/subscribeCommentRange.d.ts +9 -0
- package/docs/firebase-setup.md +20 -1
- package/docs/firestore.rules.example +36 -7
- package/docs/storage.rules.example +25 -14
- package/package.json +1 -1
package/dist/storage.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.js","names":[],"sources":["../src/utils/compressImage.ts","../src/composables/useMemiBoardStorage.ts"],"sourcesContent":["import type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\n\n/**\n * Canvas API로 썸네일용 이미지 압축 (shineb 와 동일).\n */\nexport async function compressImage(\n file: File | Blob,\n { maxWidth = 400, quality = 0.8 }: { maxWidth?: number, quality?: number } = {},\n locale?: MemiBoardLocale,\n): Promise<Blob> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n const objectUrl = URL.createObjectURL(file)\n\n img.onload = () => {\n URL.revokeObjectURL(objectUrl)\n\n const ratio = Math.min(1, maxWidth / img.naturalWidth)\n const canvas = document.createElement('canvas')\n canvas.width = Math.round(img.naturalWidth * ratio)\n canvas.height = Math.round(img.naturalHeight * ratio)\n\n const ctx = canvas.getContext('2d')\n if (!ctx) {\n reject(new Error(translate(locale, 'storage.canvasUnavailable')))\n return\n }\n ctx.drawImage(img, 0, 0, canvas.width, canvas.height)\n\n canvas.toBlob(\n blob => (blob ? resolve(blob) : reject(new Error(translate(locale, 'storage.compressFailed')))),\n 'image/jpeg',\n quality,\n )\n }\n\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n reject(new Error(translate(locale, 'storage.loadFailed')))\n }\n\n img.src = objectUrl\n })\n}\n","import {\n getStorage,\n ref as storageRef,\n uploadBytes,\n uploadBytesResumable,\n getDownloadURL,\n deleteObject,\n} from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { safeFileName } from '../utils/slugify'\nimport { compressImage } from '../utils/compressImage'\nimport { useBoardPathConfig } from '../config'\nimport { postStorageFolder } from '../utils/boardPaths'\nimport type { Attachment, EditorImageEntry } from '../types'\nimport type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\nimport { useMemiBoardI18n } from '../i18n/useMemiBoardI18n'\n\n/** 에디터 이미지 최대 크기 (바이트) */\nexport const EDITOR_IMAGE_MAX_BYTES = 5 * 1024 * 1024\n/** 휴대폰 원본 이미지 선택 허용 크기. 큰 이미지는 업로드 전에 최적화한다. */\nexport const EDITOR_IMAGE_SOURCE_MAX_BYTES = 25 * 1024 * 1024\n\nfunction isHeicLike(file: File): boolean {\n const type = (file.type || '').toLowerCase()\n const name = file.name.toLowerCase()\n return type === 'image/heic'\n || type === 'image/heif'\n || name.endsWith('.heic')\n || name.endsWith('.heif')\n}\n\n/** 압축해도 5MB 를 넘는 경우 — 다른 최적화 실패와 구분해 그대로 보여 준다. */\nclass ImageTooLargeError extends Error {}\n\nasync function resizeJpegIfNeeded(blob: Blob, locale?: MemiBoardLocale): Promise<Blob> {\n if (blob.size <= EDITOR_IMAGE_MAX_BYTES) return blob\n let next = await compressImage(blob, { maxWidth: 2560, quality: 0.85 }, locale)\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n next = await compressImage(blob, { maxWidth: 2048, quality: 0.75 }, locale)\n }\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n throw new ImageTooLargeError(translate(locale, 'storage.tooLargeAfterCompress'))\n }\n return next\n}\n\n/**\n * 에디터 업로드용 정규화.\n * - 5MB 초과 → 리사이즈\n * - HEIC/HEIF 원본 파일 → 호환 형식 안내\n */\nasync function optimizeEditorImage(file: File, locale?: MemiBoardLocale): Promise<{ blob: File | Blob, compressed: boolean }> {\n if (file.size > EDITOR_IMAGE_SOURCE_MAX_BYTES) {\n throw new Error(translate(locale, 'storage.sourceTooLarge', { max: EDITOR_IMAGE_SOURCE_MAX_BYTES / 1024 / 1024 }))\n }\n\n if (isHeicLike(file)) {\n throw new Error(translate(locale, 'storage.heicUnsupported'))\n }\n\n if (file.size <= EDITOR_IMAGE_MAX_BYTES) {\n return { blob: file, compressed: false }\n }\n\n try {\n const blob = await resizeJpegIfNeeded(file, locale)\n return { blob, compressed: true }\n }\n catch (cause) {\n if (cause instanceof ImageTooLargeError) throw cause\n throw new Error(translate(locale, 'storage.cannotOptimize'))\n }\n}\n\nexport function useMemiBoardStorage() {\n const cfg = () => useBoardPathConfig()\n const app = useFirebaseApp()\n const { t, locale } = useMemiBoardI18n()\n\n /** postId는 작성 화면 진입 시 미리 생성한 Firestore 자동 ID를 사용한다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${postStorageFolder(cfg(), postId)}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n /**\n * 에디터 본문 이미지: 원본 + 400px JPEG 썸네일.\n * Storage:\n * `memiBoardPosts/{postId}/images/{ts}-{name}.ext`\n * `memiBoardPosts/{postId}/images/thumbnails/{ts}-{name}.jpg`\n */\n async function uploadEditorImage(file: File, postId: string): Promise<EditorImageEntry> {\n if (!file.type.startsWith('image/')) {\n throw new Error(t('storage.imageOnly'))\n }\n const optimized = await optimizeEditorImage(file, locale.value)\n\n const storage = getStorage(app)\n const ext = (optimized.compressed ? 'jpg' : (file.name.split('.').pop() || file.type.split('/')[1] || 'png'))\n .replace(/[^a-zA-Z0-9]/g, '')\n .slice(0, 8) || 'png'\n const safeName = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40)\n const baseName = `${Date.now()}-${safeName || 'image'}`\n const ns = postId || `new-${Date.now()}`\n const folder = postStorageFolder(cfg(), ns)\n\n const originalPath = `${folder}/images/${baseName}.${ext}`\n const originalRef = storageRef(storage, originalPath)\n await uploadBytes(originalRef, optimized.blob, {\n contentType: optimized.blob.type || 'image/jpeg',\n })\n const originalUrl = await getDownloadURL(originalRef)\n\n const thumbBlob = await compressImage(optimized.blob, { maxWidth: 400, quality: 0.8 }, locale.value)\n const thumbnailPath = `${folder}/images/thumbnails/${baseName}.jpg`\n const thumbRef = storageRef(storage, thumbnailPath)\n await uploadBytes(thumbRef, thumbBlob, { contentType: 'image/jpeg' })\n const thumbnailUrl = await getDownloadURL(thumbRef)\n\n return { originalUrl, originalPath, thumbnailUrl, thumbnailPath }\n }\n\n /** 원본·썸네일 쌍 삭제 (고아 정리·첨부 삭제용) */\n async function deleteEditorImage(entry: Pick<EditorImageEntry, 'originalPath' | 'thumbnailPath'>): Promise<void> {\n const storage = getStorage(app)\n await Promise.all([\n deleteObject(storageRef(storage, entry.originalPath)).catch(() => {}),\n deleteObject(storageRef(storage, entry.thumbnailPath)).catch(() => {}),\n ])\n }\n\n /**\n * 프로필 사진. 경로가 uid로 고정돼 있어 다시 올리면 그냥 덮어쓴다(고아 파일 없음).\n * 파일은 이미 크롭 단계에서 정사각형 JPEG로 만들어져 오므로 여기선 그대로 업로드한다.\n */\n async function uploadAvatar(uid: string, file: File): Promise<string> {\n const storage = getStorage(app)\n const path = `${cfg().usersCollection}/${uid}/avatar.jpg`\n const fileRef = storageRef(storage, path)\n await uploadBytes(fileRef, file, { contentType: file.type || 'image/jpeg' })\n return getDownloadURL(fileRef)\n }\n\n return {\n uploadAttachment,\n deleteAttachment,\n uploadEditorImage,\n deleteEditorImage,\n uploadAvatar,\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"storage.js","names":[],"sources":["../src/utils/compressImage.ts","../src/composables/useMemiBoardStorage.ts"],"sourcesContent":["import type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\n\n/**\n * Canvas API로 썸네일용 이미지 압축 (shineb 와 동일).\n */\nexport async function compressImage(\n file: File | Blob,\n { maxWidth = 400, quality = 0.8 }: { maxWidth?: number, quality?: number } = {},\n locale?: MemiBoardLocale,\n): Promise<Blob> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n const objectUrl = URL.createObjectURL(file)\n\n img.onload = () => {\n URL.revokeObjectURL(objectUrl)\n\n const ratio = Math.min(1, maxWidth / img.naturalWidth)\n const canvas = document.createElement('canvas')\n canvas.width = Math.round(img.naturalWidth * ratio)\n canvas.height = Math.round(img.naturalHeight * ratio)\n\n const ctx = canvas.getContext('2d')\n if (!ctx) {\n reject(new Error(translate(locale, 'storage.canvasUnavailable')))\n return\n }\n ctx.drawImage(img, 0, 0, canvas.width, canvas.height)\n\n canvas.toBlob(\n blob => (blob ? resolve(blob) : reject(new Error(translate(locale, 'storage.compressFailed')))),\n 'image/jpeg',\n quality,\n )\n }\n\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n reject(new Error(translate(locale, 'storage.loadFailed')))\n }\n\n img.src = objectUrl\n })\n}\n","import {\n getStorage,\n ref as storageRef,\n uploadBytes,\n uploadBytesResumable,\n getDownloadURL,\n deleteObject,\n} from 'firebase/storage'\nimport type { UploadTask } from 'firebase/storage'\nimport { useFirebaseApp } from 'vuefire'\nimport { getAuth } from 'firebase/auth'\nimport { safeFileName } from '../utils/slugify'\nimport { compressImage } from '../utils/compressImage'\nimport { useBoardPathConfig } from '../config'\nimport { postStorageFolder } from '../utils/boardPaths'\nimport type { Attachment, EditorImageEntry } from '../types'\nimport type { MemiBoardLocale } from '../i18n/locales'\nimport { translate } from '../i18n/translate'\nimport { useMemiBoardI18n } from '../i18n/useMemiBoardI18n'\n\n/** 에디터 이미지 최대 크기 (바이트) */\nexport const EDITOR_IMAGE_MAX_BYTES = 5 * 1024 * 1024\n/** 휴대폰 원본 이미지 선택 허용 크기. 큰 이미지는 업로드 전에 최적화한다. */\nexport const EDITOR_IMAGE_SOURCE_MAX_BYTES = 25 * 1024 * 1024\n\nfunction isHeicLike(file: File): boolean {\n const type = (file.type || '').toLowerCase()\n const name = file.name.toLowerCase()\n return type === 'image/heic'\n || type === 'image/heif'\n || name.endsWith('.heic')\n || name.endsWith('.heif')\n}\n\n/** 압축해도 5MB 를 넘는 경우 — 다른 최적화 실패와 구분해 그대로 보여 준다. */\nclass ImageTooLargeError extends Error {}\n\nasync function resizeJpegIfNeeded(blob: Blob, locale?: MemiBoardLocale): Promise<Blob> {\n if (blob.size <= EDITOR_IMAGE_MAX_BYTES) return blob\n let next = await compressImage(blob, { maxWidth: 2560, quality: 0.85 }, locale)\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n next = await compressImage(blob, { maxWidth: 2048, quality: 0.75 }, locale)\n }\n if (next.size > EDITOR_IMAGE_MAX_BYTES) {\n throw new ImageTooLargeError(translate(locale, 'storage.tooLargeAfterCompress'))\n }\n return next\n}\n\n/**\n * 에디터 업로드용 정규화.\n * - 5MB 초과 → 리사이즈\n * - HEIC/HEIF 원본 파일 → 호환 형식 안내\n */\nasync function optimizeEditorImage(file: File, locale?: MemiBoardLocale): Promise<{ blob: File | Blob, compressed: boolean }> {\n if (file.size > EDITOR_IMAGE_SOURCE_MAX_BYTES) {\n throw new Error(translate(locale, 'storage.sourceTooLarge', { max: EDITOR_IMAGE_SOURCE_MAX_BYTES / 1024 / 1024 }))\n }\n\n if (isHeicLike(file)) {\n throw new Error(translate(locale, 'storage.heicUnsupported'))\n }\n\n if (file.size <= EDITOR_IMAGE_MAX_BYTES) {\n return { blob: file, compressed: false }\n }\n\n try {\n const blob = await resizeJpegIfNeeded(file, locale)\n return { blob, compressed: true }\n }\n catch (cause) {\n if (cause instanceof ImageTooLargeError) throw cause\n throw new Error(translate(locale, 'storage.cannotOptimize'))\n }\n}\n\nexport function useMemiBoardStorage() {\n const cfg = () => useBoardPathConfig()\n const app = useFirebaseApp()\n const { t, locale } = useMemiBoardI18n()\n\n function uploadMetadata() {\n const uid = getAuth(app).currentUser?.uid\n if (!uid) throw new Error('Sign in before uploading a file.')\n return { ownerUid: uid }\n }\n\n /** postId는 작성 화면 진입 시 미리 생성한 Firestore 자동 ID를 사용한다. */\n function uploadAttachment(\n file: File,\n postId: string,\n onProgress?: (ratio: number) => void,\n ): { promise: Promise<Attachment>, cancel: () => void } {\n const storage = getStorage(app)\n const path = `${postStorageFolder(cfg(), postId)}/attachments/${Date.now()}-${safeFileName(file.name)}`\n const fileRef = storageRef(storage, path)\n const task: UploadTask = uploadBytesResumable(fileRef, file, {\n customMetadata: uploadMetadata(),\n contentType: file.type || 'application/octet-stream',\n contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,\n })\n\n const promise = new Promise<Attachment>((resolve, reject) => {\n task.on(\n 'state_changed',\n (snapshot) => {\n if (onProgress && snapshot.totalBytes > 0) {\n onProgress(snapshot.bytesTransferred / snapshot.totalBytes)\n }\n },\n reject,\n async () => {\n const url = await getDownloadURL(fileRef)\n resolve({ name: file.name, url, path, size: file.size, type: file.type })\n },\n )\n })\n\n return { promise, cancel: () => task.cancel() }\n }\n\n async function deleteAttachment(attachment: Pick<Attachment, 'path'>): Promise<void> {\n const storage = getStorage(app)\n await deleteObject(storageRef(storage, attachment.path)).catch(() => {})\n }\n\n /**\n * 에디터 본문 이미지: 원본 + 400px JPEG 썸네일.\n * Storage:\n * `memiBoardPosts/{postId}/images/{ts}-{name}.ext`\n * `memiBoardPosts/{postId}/images/thumbnails/{ts}-{name}.jpg`\n */\n async function uploadEditorImage(file: File, postId: string): Promise<EditorImageEntry> {\n if (!file.type.startsWith('image/')) {\n throw new Error(t('storage.imageOnly'))\n }\n const optimized = await optimizeEditorImage(file, locale.value)\n\n const storage = getStorage(app)\n const ext = (optimized.compressed ? 'jpg' : (file.name.split('.').pop() || file.type.split('/')[1] || 'png'))\n .replace(/[^a-zA-Z0-9]/g, '')\n .slice(0, 8) || 'png'\n const safeName = safeFileName(file.name.replace(/\\.[^.]+$/, '') || 'image', 40)\n const baseName = `${Date.now()}-${safeName || 'image'}`\n const ns = postId || `new-${Date.now()}`\n const folder = postStorageFolder(cfg(), ns)\n\n const originalPath = `${folder}/images/${baseName}.${ext}`\n const originalRef = storageRef(storage, originalPath)\n await uploadBytes(originalRef, optimized.blob, {\n customMetadata: uploadMetadata(),\n contentType: optimized.blob.type || 'image/jpeg',\n })\n const originalUrl = await getDownloadURL(originalRef)\n\n const thumbBlob = await compressImage(optimized.blob, { maxWidth: 400, quality: 0.8 }, locale.value)\n const thumbnailPath = `${folder}/images/thumbnails/${baseName}.jpg`\n const thumbRef = storageRef(storage, thumbnailPath)\n await uploadBytes(thumbRef, thumbBlob, { contentType: 'image/jpeg', customMetadata: uploadMetadata() })\n const thumbnailUrl = await getDownloadURL(thumbRef)\n\n return { originalUrl, originalPath, thumbnailUrl, thumbnailPath }\n }\n\n /** 원본·썸네일 쌍 삭제 (고아 정리·첨부 삭제용) */\n async function deleteEditorImage(entry: Pick<EditorImageEntry, 'originalPath' | 'thumbnailPath'>): Promise<void> {\n const storage = getStorage(app)\n await Promise.all([\n deleteObject(storageRef(storage, entry.originalPath)).catch(() => {}),\n deleteObject(storageRef(storage, entry.thumbnailPath)).catch(() => {}),\n ])\n }\n\n /**\n * 프로필 사진. 경로가 uid로 고정돼 있어 다시 올리면 그냥 덮어쓴다(고아 파일 없음).\n * 파일은 이미 크롭 단계에서 정사각형 JPEG로 만들어져 오므로 여기선 그대로 업로드한다.\n */\n async function uploadAvatar(uid: string, file: File): Promise<string> {\n const storage = getStorage(app)\n const path = `${cfg().usersCollection}/${uid}/avatar.jpg`\n const fileRef = storageRef(storage, path)\n await uploadBytes(fileRef, file, { contentType: file.type || 'image/jpeg' })\n return getDownloadURL(fileRef)\n }\n\n return {\n uploadAttachment,\n deleteAttachment,\n uploadEditorImage,\n deleteEditorImage,\n uploadAvatar,\n }\n}\n"],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -164,6 +164,8 @@ export interface BoardModel {
|
|
|
164
164
|
* 보드 목록·칩에서 제외, 글·댓글 listed=false, 댓글 쓰기는 담당 스태프만. 미지정은 public.
|
|
165
165
|
*/
|
|
166
166
|
visibility?: BoardVisibility;
|
|
167
|
+
/** Incomplete visibility change; save the target again to resume. */
|
|
168
|
+
visibilityTransition?: BoardVisibility;
|
|
167
169
|
listView?: BoardListView;
|
|
168
170
|
/** 일반 제목·본문 폼 또는 사진 갤러리 폼. 미지정 레거시는 이미지 리스트뷰일 때 image. */
|
|
169
171
|
editorType?: BoardEditorType;
|
|
@@ -3,8 +3,6 @@ import { FirebaseStorage } from 'firebase/storage';
|
|
|
3
3
|
import { BoardPathConfig } from './boardPaths';
|
|
4
4
|
/** 글에 달린 본문·댓글 좋아요를 모두 지운다. 둘 다 postId 필드를 가진다. */
|
|
5
5
|
export declare function deleteLikesForPost(db: Firestore, cfg: BoardPathConfig, postId: string): Promise<void>;
|
|
6
|
-
/** 글에 달린 신고를 모두 지운다. */
|
|
7
|
-
export declare function deleteReportsForPost(db: Firestore, cfg: BoardPathConfig, postId: string): Promise<void>;
|
|
8
6
|
/** 댓글 하나에 달린 좋아요만 지운다. */
|
|
9
7
|
export declare function deleteLikesForComment(db: Firestore, cfg: BoardPathConfig, commentId: string): Promise<void>;
|
|
10
8
|
/** 글 하나의 댓글(답글 포함), 좋아요, 본문, 첨부 Storage, 메타를 순서대로 삭제한다. */
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Firestore } from 'firebase/firestore';
|
|
2
|
+
import { BoardPathConfig } from './boardPaths';
|
|
3
|
+
/** Query only documents the caller can read; rules cannot filter private drafts. */
|
|
4
|
+
export declare function findReadablePostBySlug(db: Firestore, cfg: BoardPathConfig, boardId: string, slug: string, uid: string | undefined, canManage: boolean): Promise<import('firebase/firestore').QueryDocumentSnapshot<import('firebase/firestore').DocumentData, import('firebase/firestore').DocumentData>>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Firestore, QueryDocumentSnapshot } from 'firebase/firestore';
|
|
2
|
+
import { BoardPathConfig } from './boardPaths';
|
|
3
|
+
import { CommentModel } from '../types';
|
|
4
|
+
/** Keep the loaded range live; after the final page, include newly added rows. */
|
|
5
|
+
export declare function subscribeCommentRange(db: Firestore, cfg: BoardPathConfig, postId: string, options: {
|
|
6
|
+
rootId?: string;
|
|
7
|
+
cursor: QueryDocumentSnapshot | null;
|
|
8
|
+
hasMore: boolean;
|
|
9
|
+
}, receive: (comments: CommentModel[]) => void, onError: (cause: Error) => void): import('firebase/firestore').Unsubscribe;
|
package/docs/firebase-setup.md
CHANGED
|
@@ -69,7 +69,7 @@ firebase init firestore storage
|
|
|
69
69
|
|
|
70
70
|
## 5. 게시판 Security Rules 병합 + 배포
|
|
71
71
|
|
|
72
|
-
README의 [Security Model](../README.md#security-model
|
|
72
|
+
README의 [Security Model](../README.md#security-model) 섹션대로 `docs/firestore.rules.example`/`docs/storage.rules.example`을 프로젝트 규칙 파일에 병합한 뒤:
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
75
|
firebase deploy --only firestore:rules,storage
|
|
@@ -77,6 +77,25 @@ firebase deploy --only firestore:rules,storage
|
|
|
77
77
|
|
|
78
78
|
**병합만 하고 배포를 안 하면 글쓰기·댓글에서 `permission-denied`가 난다** — 가장 흔한 설치 실수다.
|
|
79
79
|
|
|
80
|
+
### Storage 규칙의 Firestore 조회 권한 (부모/호스트 프로젝트 필수 설정)
|
|
81
|
+
|
|
82
|
+
Storage 예시 규칙은 첨부 업로드와 파일 삭제 시 `firestore.exists()` / `firestore.get()`으로 글의 존재 여부, `authorUid`, 사용자 `role`을 조회한다. 따라서 **호스트 Firebase 프로젝트의 Storage 서비스 계정**에 Firestore 조회 역할이 필요하다. 패키지 설치나 Firestore Rules 배포만으로 이 IAM 설정이 완료되지는 않는다.
|
|
83
|
+
|
|
84
|
+
호스트 프로젝트 관리자가 다음을 설정한다:
|
|
85
|
+
|
|
86
|
+
1. 호스트 저장소에서 위 배포 명령을 대화형으로 실행한다. Firebase CLI가 교차 서비스 규칙용 IAM 역할 추가를 물으면 승인한다. Firebase 콘솔에서 Storage Rules를 저장할 때 표시되는 연결 권한 안내로도 설정할 수 있다.
|
|
87
|
+
2. Google Cloud Console → **IAM 및 관리자 → IAM**에서 같은 Firebase 프로젝트를 선택하고 **Google 제공 역할 부여 포함(Include Google-provided role grants)**을 켠다.
|
|
88
|
+
3. `service-<PROJECT_NUMBER>@gcp-sa-firebasestorage.iam.gserviceaccount.com` 계정을 찾아 **Firebase Rules Firestore Service Agent** (`roles/firebaserules.firestoreServiceAgent`) 역할이 있는지 확인한다. `<PROJECT_NUMBER>`는 프로젝트 ID가 아니라 Firebase 프로젝트 설정의 숫자 프로젝트 번호다. 역할이 없으면 IAM 변경 권한이 있는 호스트 관리자가 이 서비스 계정에 추가한다.
|
|
89
|
+
4. 권한 반영 후 로그인 상태에서 새 글에 20MiB 미만의 작은 첨부파일을 올려 확인한다. 기존 글에서는 작성자·스태프의 첨부 작업도 확인한다.
|
|
90
|
+
|
|
91
|
+
이 역할은 프로젝트의 Firestore 문서 조회(`datastore.entities.get`)를 허용한다. 일반 사용자 계정에 부여하는 역할이 아니며, Firestore 쓰기·삭제 권한은 포함하지 않는다. Storage 규칙의 소유권·역할 검사는 그대로 유지한다.
|
|
92
|
+
|
|
93
|
+
**CI의 `--non-interactive` 배포 성공만으로 IAM 설정까지 완료됐다고 판단하지 않는다.** 최초 설정은 호스트 관리자가 대화형 배포 또는 IAM 콘솔에서 수행하고, 위 서비스 계정의 역할을 확인한다.
|
|
94
|
+
|
|
95
|
+
2MB ZIP처럼 용량 조건을 충족해도 이 권한이 없으면 `memiBoardPosts/{postId}/attachments/...`에서 `storage/unauthorized`가 날 수 있다. 새 글 문서가 아직 없어도 `firestore.exists()` 조회 자체에는 권한이 필요하다. 현재 본문 이미지 업로드는 Firestore를 조회하지 않으므로, 이미지 업로드가 성공해도 첨부 업로드·파일 삭제의 조회 권한이 정상이라는 뜻은 아니다.
|
|
96
|
+
|
|
97
|
+
공식 근거: [교차 서비스 Storage Rules 권한 설정](https://firebase.google.com/docs/rules/manage-deploy#manage_permissions_for_cross-service_cloud_storage_security_rules), [역할의 포함 권한](https://docs.cloud.google.com/iam/docs/roles-permissions/firebaserules#firebaserules.firestoreServiceAgent).
|
|
98
|
+
|
|
80
99
|
## 6. 로그인 공급자 설정
|
|
81
100
|
|
|
82
101
|
### Google
|
|
@@ -69,6 +69,19 @@ service cloud.firestore {
|
|
|
69
69
|
function isBoardManager(boardId) {
|
|
70
70
|
return isBoardStaff() && isAllowedStaffForBoard(boardId);
|
|
71
71
|
}
|
|
72
|
+
// A visibility transition freezes new content until every listed flag is synchronized.
|
|
73
|
+
function boardWritesOpen(boardId) {
|
|
74
|
+
return get(boardSettingsPath(boardId)).data.get('visibilityTransition', '') == '';
|
|
75
|
+
}
|
|
76
|
+
function canDeletePostData(postId) {
|
|
77
|
+
let post = get(postPath(postId));
|
|
78
|
+
return isOwner(post) || isBoardManager(post.data.boardId);
|
|
79
|
+
}
|
|
80
|
+
function canDeleteLike(data) {
|
|
81
|
+
return canDeletePostData(data.postId)
|
|
82
|
+
|| (data.get('target', '') == 'comment'
|
|
83
|
+
&& isOwner(get(commentPath(data.commentId))));
|
|
84
|
+
}
|
|
72
85
|
function canWriteBoard(boardId) {
|
|
73
86
|
let requiredRole = get(boardSettingsPath(boardId)).data.get('writeRole', 'user');
|
|
74
87
|
return exists(boardSettingsPath(boardId))
|
|
@@ -227,7 +240,9 @@ service cloud.firestore {
|
|
|
227
240
|
&& request.resource.data.get('visibility', 'public')
|
|
228
241
|
== resource.data.get('visibility', 'public')
|
|
229
242
|
&& request.resource.data.get('allowedStaffUids', [])
|
|
230
|
-
== resource.data.get('allowedStaffUids', [])
|
|
243
|
+
== resource.data.get('allowedStaffUids', [])
|
|
244
|
+
&& request.resource.data.get('visibilityTransition', '')
|
|
245
|
+
== resource.data.get('visibilityTransition', ''));
|
|
231
246
|
allow delete: if isBoardAdmin();
|
|
232
247
|
}
|
|
233
248
|
|
|
@@ -239,18 +254,28 @@ service cloud.firestore {
|
|
|
239
254
|
&& request.resource.data.authorUid == request.auth.uid
|
|
240
255
|
&& request.resource.data.moderationStatus == 'approved'
|
|
241
256
|
&& canWriteBoard(request.resource.data.boardId)
|
|
257
|
+
&& boardWritesOpen(request.resource.data.boardId)
|
|
242
258
|
&& request.resource.data.listed is bool
|
|
243
259
|
&& request.resource.data.listed == expectedListed(request.resource.data.boardId)
|
|
244
260
|
&& request.resource.data.isPublished == false;
|
|
245
261
|
|
|
246
|
-
allow update: if
|
|
247
|
-
|
|
262
|
+
allow update: if (
|
|
263
|
+
(isOwner(resource) || isBoardManager(resource.data.boardId))
|
|
264
|
+
&& request.resource.data.boardId == resource.data.boardId
|
|
265
|
+
&& request.resource.data.authorUid == resource.data.authorUid
|
|
266
|
+
&& (
|
|
267
|
+
(request.resource.data.listed == resource.data.listed
|
|
268
|
+
&& boardWritesOpen(resource.data.boardId))
|
|
269
|
+
|| (isBoardManager(resource.data.boardId)
|
|
270
|
+
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['listed']))
|
|
271
|
+
)
|
|
272
|
+
) || isCommentCountUpdate() || isLikeCountUpdate() || isViewCountUpdate();
|
|
248
273
|
allow delete: if isOwner(resource) || isBoardManager(resource.data.boardId);
|
|
249
274
|
|
|
250
275
|
match /body/{docId} {
|
|
251
276
|
allow read: if canReadPost(get(postPath(postId)).data);
|
|
252
|
-
allow write: if isOwner(
|
|
253
|
-
|| isBoardManager(
|
|
277
|
+
allow write: if isOwner(getAfter(postPath(postId)))
|
|
278
|
+
|| isBoardManager(getAfter(postPath(postId)).data.boardId);
|
|
254
279
|
}
|
|
255
280
|
}
|
|
256
281
|
|
|
@@ -268,6 +293,7 @@ service cloud.firestore {
|
|
|
268
293
|
// 일치하는지 확인한다(비용: 댓글 생성마다 get() 1회 추가).
|
|
269
294
|
&& get(postPath(request.resource.data.postId)).data.boardId == request.resource.data.boardId
|
|
270
295
|
&& canWriteBoardComment(request.resource.data.boardId)
|
|
296
|
+
&& boardWritesOpen(request.resource.data.boardId)
|
|
271
297
|
&& request.resource.data.authorUid == request.auth.uid
|
|
272
298
|
&& request.resource.data.body is string
|
|
273
299
|
&& request.resource.data.body.size() > 0
|
|
@@ -303,7 +329,9 @@ service cloud.firestore {
|
|
|
303
329
|
// 없으면 resource.data 접근이 실패해 일반 사용자의 첫 좋아요가 permission-denied 가 된다.
|
|
304
330
|
allow read: if (isSignedIn() && resource == null)
|
|
305
331
|
|| (isSignedIn() && request.auth.uid == resource.data.uid)
|
|
306
|
-
|| (resource != null && canReadPost(get(postPath(resource.data.postId)).data))
|
|
332
|
+
|| (resource != null && canReadPost(get(postPath(resource.data.postId)).data))
|
|
333
|
+
|| (resource != null && resource.data.get('target', '') == 'comment'
|
|
334
|
+
&& canReadComment(get(commentPath(resource.data.commentId)).data));
|
|
307
335
|
|
|
308
336
|
allow create: if isBoardWriter()
|
|
309
337
|
&& request.resource.data.uid == request.auth.uid
|
|
@@ -311,7 +339,8 @@ service cloud.firestore {
|
|
|
311
339
|
&& request.resource.data.boardId is string
|
|
312
340
|
&& (isValidPostLike(likeId) || isValidCommentLike(likeId));
|
|
313
341
|
|
|
314
|
-
allow delete: if isSignedIn()
|
|
342
|
+
allow delete: if isSignedIn()
|
|
343
|
+
&& (request.auth.uid == resource.data.uid || canDeleteLike(resource.data));
|
|
315
344
|
// update 는 허용하지 않음 — 토글은 항상 create-or-delete.
|
|
316
345
|
}
|
|
317
346
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// memi-board 0.23+ Storage Rules (flat schema)
|
|
2
2
|
// memiBoardPosts/{postId}/attachments|images/...
|
|
3
3
|
// postId 가 전역 고유이므로 boardId prefix 는 더 이상 필요 없음.
|
|
4
|
+
// 부모/호스트 Firebase 프로젝트에서 Storage 서비스 계정에
|
|
5
|
+
// roles/firebaserules.firestoreServiceAgent 역할을 설정해야 한다.
|
|
6
|
+
// firestore.exists/get 조회 권한이 없으면 첨부 업로드·파일 삭제가 unauthorized로 실패한다.
|
|
7
|
+
// 설정 및 확인 절차: docs/firebase-setup.md의 Storage 규칙의 Firestore 조회 권한 절.
|
|
4
8
|
|
|
5
9
|
rules_version = '2';
|
|
6
10
|
service firebase.storage {
|
|
@@ -9,12 +13,24 @@ service firebase.storage {
|
|
|
9
13
|
function isSignedIn() {
|
|
10
14
|
return request.auth != null;
|
|
11
15
|
}
|
|
16
|
+
// Storage permits at most two distinct Firestore document reads:
|
|
17
|
+
// post + user role. Keep the existing global staff Storage permission.
|
|
12
18
|
function isBoardStaff() {
|
|
13
19
|
let userPath = /databases/(default)/documents/memiBoardUsers/$(request.auth.uid);
|
|
14
|
-
return isSignedIn()
|
|
15
|
-
&& firestore.exists(userPath)
|
|
20
|
+
return isSignedIn() && firestore.exists(userPath)
|
|
16
21
|
&& firestore.get(userPath).data.role in ['admin', 'staff'];
|
|
17
22
|
}
|
|
23
|
+
function canWritePostFile(postId) {
|
|
24
|
+
return isSignedIn() && (postExists(postId)
|
|
25
|
+
? (isPostOwner(postId) || isBoardStaff())
|
|
26
|
+
: (request.resource.metadata.ownerUid == request.auth.uid
|
|
27
|
+
&& (resource == null || resource.metadata.ownerUid == request.auth.uid)));
|
|
28
|
+
}
|
|
29
|
+
function canDeletePostFile(postId) {
|
|
30
|
+
return isSignedIn() && (postExists(postId)
|
|
31
|
+
? (isPostOwner(postId) || isBoardStaff())
|
|
32
|
+
: resource.metadata.ownerUid == request.auth.uid);
|
|
33
|
+
}
|
|
18
34
|
function postExists(postId) {
|
|
19
35
|
return firestore.exists(/databases/(default)/documents/memiBoardPosts/$(postId));
|
|
20
36
|
}
|
|
@@ -33,31 +49,26 @@ service firebase.storage {
|
|
|
33
49
|
// write 는 여기에 넣지 않는다 — 용량·image/* 제한은 아래 세부 match 가 담당.
|
|
34
50
|
match /memiBoardPosts/{postId}/{allPaths=**} {
|
|
35
51
|
allow list, get: if true;
|
|
36
|
-
allow delete: if
|
|
37
|
-
&& (!postExists(postId) || isPostOwner(postId) || isBoardStaff());
|
|
52
|
+
allow delete: if canDeletePostFile(postId);
|
|
38
53
|
}
|
|
39
54
|
|
|
40
55
|
match /memiBoardPosts/{postId}/attachments/{fileName} {
|
|
41
56
|
allow read: if true;
|
|
42
|
-
allow
|
|
43
|
-
&& (!postExists(postId) || isPostOwner(postId) || isBoardStaff())
|
|
57
|
+
allow create, update: if canWritePostFile(postId)
|
|
44
58
|
&& request.resource.size < 20 * 1024 * 1024;
|
|
45
|
-
allow delete: if
|
|
46
|
-
&& (!postExists(postId) || isPostOwner(postId) || isBoardStaff());
|
|
59
|
+
allow delete: if canDeletePostFile(postId);
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
match /memiBoardPosts/{postId}/images/{allPaths=**} {
|
|
50
63
|
allow read: if true;
|
|
51
64
|
// 글쓰기 화면은 Firestore 문서를 만들기 전에 Storage 경로(미리 딴 postId)로
|
|
52
|
-
// 올린다. 붙여넣기 이미지는 image/png 등이
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
allow write: if isSignedIn()
|
|
65
|
+
// 올린다. 붙여넣기 이미지는 image/png 등이 온다.
|
|
66
|
+
// 기존 글은 작성자/스태프만 수정한다. 아직 저장하지 않은 파일은 ownerUid로 보호한다.
|
|
67
|
+
allow create, update: if canWritePostFile(postId)
|
|
56
68
|
&& request.resource != null
|
|
57
69
|
&& request.resource.size < 5 * 1024 * 1024
|
|
58
70
|
&& request.resource.contentType.matches('image/.*');
|
|
59
|
-
allow delete: if
|
|
60
|
-
&& (!postExists(postId) || isPostOwner(postId) || isBoardStaff());
|
|
71
|
+
allow delete: if canDeletePostFile(postId);
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
// 프로필 사진 — 경로가 uid로 고정돼 있어 본인만 쓸 수 있다. 공개 프로필의 일부라 읽기는 전체 공개.
|