memi-board 0.45.0 → 0.46.0
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 +29 -5
- package/dist/composables/boardLookup.d.ts +2 -0
- package/dist/composables/useMemiBoardAuth.d.ts +5 -1
- package/dist/composables/useMemiBoardComments.d.ts +2 -3
- package/dist/config.d.ts +2 -0
- package/dist/{en-BVUBLkxc.js → en-CaPfPNDy.js} +8 -2
- package/dist/en-CaPfPNDy.js.map +1 -0
- package/dist/index.js +831 -768
- package/dist/index.js.map +1 -1
- package/dist/runtime/components/CategorySettings.vue +15 -6
- package/dist/runtime/components/CommentThread.vue +1 -1
- package/dist/runtime/components/Editor.vue +1 -0
- package/dist/runtime/components/Settings.vue +8 -1
- package/dist/runtime/components/Users.vue +4 -3
- package/dist/{slugify-6bG6GSO_.js → slugify-DJ82ifCd.js} +14 -5
- package/dist/slugify-DJ82ifCd.js.map +1 -0
- package/dist/storage.js +62 -49
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/utils/boardAccess.d.ts +3 -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/section.d.ts +1 -1
- package/dist/utils/subscribeCommentRange.d.ts +9 -0
- package/docs/firebase-setup.md +48 -1
- package/docs/firestore.rules.example +59 -14
- package/docs/roles-migration.md +32 -0
- package/docs/storage.rules.example +27 -16
- package/package.json +1 -1
- package/dist/en-BVUBLkxc.js.map +0 -1
- package/dist/slugify-6bG6GSO_.js.map +0 -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":";;;;;AAMA,eAAsB,EACpB,GACA,EAAE,cAAW,KAAK,aAAU,OAAiD,CAAC,GAC9E,GACe;CACf,OAAO,IAAI,SAAS,GAAS,MAAW;EACtC,IAAM,IAAM,IAAI,MAAM,GAChB,IAAY,IAAI,gBAAgB,CAAI;EA6B1C,AA3BA,EAAI,eAAe;GACjB,IAAI,gBAAgB,CAAS;GAE7B,IAAM,IAAQ,KAAK,IAAI,GAAG,IAAW,EAAI,YAAY,GAC/C,IAAS,SAAS,cAAc,QAAQ;GAE9C,AADA,EAAO,QAAQ,KAAK,MAAM,EAAI,eAAe,CAAK,GAClD,EAAO,SAAS,KAAK,MAAM,EAAI,gBAAgB,CAAK;GAEpD,IAAM,IAAM,EAAO,WAAW,IAAI;GAClC,IAAI,CAAC,GAAK;IACR,EAAW,MAAM,EAAU,GAAQ,2BAA2B,CAAC,CAAC;IAChE;GACF;GAGA,AAFA,EAAI,UAAU,GAAK,GAAG,GAAG,EAAO,OAAO,EAAO,MAAM,GAEpD,EAAO,QACL,MAAS,IAAO,EAAQ,CAAI,IAAI,EAAW,MAAM,EAAU,GAAQ,wBAAwB,CAAC,CAAC,GAC7F,cACA,CACF;EACF,GAEA,EAAI,gBAAgB;GAElB,AADA,IAAI,gBAAgB,CAAS,GAC7B,EAAW,MAAM,EAAU,GAAQ,oBAAoB,CAAC,CAAC;EAC3D,GAEA,EAAI,MAAM;CACZ,CAAC;AACH;;;ACvBA,IAAa,IAAyB,SAEzB,IAAgC;AAE7C,SAAS,EAAW,GAAqB;CACvC,IAAM,KAAQ,EAAK,QAAQ,GAAA,CAAI,YAAY,GACrC,IAAO,EAAK,KAAK,YAAY;CACnC,OAAO,MAAS,gBACX,MAAS,gBACT,EAAK,SAAS,OAAO,KACrB,EAAK,SAAS,OAAO;AAC5B;AAGA,IAAM,IAAN,cAAiC,MAAM,CAAC;AAExC,eAAe,EAAmB,GAAY,GAAyC;CACrF,IAAI,EAAK,QAAA,SAAgC,OAAO;CAChD,IAAI,IAAO,MAAM,EAAc,GAAM;EAAE,UAAU;EAAM,SAAS;CAAK,GAAG,CAAM;CAI9E,IAHI,EAAK,OAAA,YACP,IAAO,MAAM,EAAc,GAAM;EAAE,UAAU;EAAM,SAAS;CAAK,GAAG,CAAM,IAExE,EAAK,OAAA,SACP,MAAM,IAAI,EAAmB,EAAU,GAAQ,+BAA+B,CAAC;CAEjF,OAAO;AACT;AAOA,eAAe,EAAoB,GAAY,GAA+E;CAC5H,IAAI,EAAK,OAAA,UACP,MAAU,MAAM,EAAU,GAAQ,0BAA0B,EAAE,KAAK,IAAgC,OAAO,KAAK,CAAC,CAAC;CAGnH,IAAI,EAAW,CAAI,GACjB,MAAU,MAAM,EAAU,GAAQ,yBAAyB,CAAC;CAG9D,IAAI,EAAK,QAAA,SACP,OAAO;EAAE,MAAM;EAAM,YAAY;CAAM;CAGzC,IAAI;EAEF,OAAO;GAAE,MAAA,MADU,EAAmB,GAAM,CAAM;GACnC,YAAY;EAAK;CAClC,SACO,GAAO;EAEZ,MADI,aAAiB,IAA0B,IACrC,MAAM,EAAU,GAAQ,wBAAwB,CAAC;CAC7D;AACF;AAEA,SAAgB,IAAsB;CACpC,IAAM,UAAY,EAAmB,GAC/B,IAAM,EAAe,GACrB,EAAE,MAAG,cAAW,EAAiB;CAEvC,SAAS,IAAiB;EACxB,IAAM,IAAM,EAAQ,CAAG,CAAC,CAAC,aAAa;EACtC,IAAI,CAAC,GAAK,MAAU,MAAM,kCAAkC;EAC5D,OAAO,EAAE,UAAU,EAAI;CACzB;CAGA,SAAS,EACP,GACA,GACA,GACsD;EACtD,IAAM,IAAU,EAAW,CAAG,GACxB,IAAO,GAAG,EAAkB,EAAI,GAAG,CAAM,EAAE,eAAe,KAAK,IAAI,EAAE,GAAG,EAAa,EAAK,IAAI,KAC9F,IAAU,EAAW,GAAS,CAAI,GAClC,IAAmB,EAAqB,GAAS,GAAM;GAC3D,gBAAgB,EAAe;GAC/B,aAAa,EAAK,QAAQ;GAC1B,oBAAoB,gCAAgC,mBAAmB,EAAK,IAAI;EAClF,CAAC;EAkBD,OAAO;GAAE,SAAA,IAhBW,SAAqB,GAAS,MAAW;IAC3D,EAAK,GACH,kBACC,MAAa;KACZ,AAAI,KAAc,EAAS,aAAa,KACtC,EAAW,EAAS,mBAAmB,EAAS,UAAU;IAE9D,GACA,GACA,YAAY;KACV,IAAM,IAAM,MAAM,EAAe,CAAO;KACxC,EAAQ;MAAE,MAAM,EAAK;MAAM;MAAK;MAAM,MAAM,EAAK;MAAM,MAAM,EAAK;KAAK,CAAC;IAC1E,CACF;GACF,CAES;GAAS,cAAc,EAAK,OAAO;EAAE;CAChD;CAEA,eAAe,EAAiB,GAAqD;EACnF,IAAM,IAAU,EAAW,CAAG;EAC9B,MAAM,EAAa,EAAW,GAAS,EAAW,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;CACzE;CAQA,eAAe,EAAkB,GAAY,GAA2C;EACtF,IAAI,CAAC,EAAK,KAAK,WAAW,QAAQ,GAChC,MAAU,MAAM,EAAE,mBAAmB,CAAC;EAExC,IAAM,IAAY,MAAM,EAAoB,GAAM,EAAO,KAAK,GAExD,IAAU,EAAW,CAAG,GACxB,KAAO,EAAU,aAAa,QAAS,EAAK,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,EAAK,KAAK,MAAM,GAAG,CAAC,CAAC,MAAM,MAAA,CACnG,QAAQ,iBAAiB,EAAE,CAAC,CAC5B,MAAM,GAAG,CAAC,KAAK,OACZ,IAAW,EAAa,EAAK,KAAK,QAAQ,YAAY,EAAE,KAAK,SAAS,EAAE,GACxE,IAAW,GAAG,KAAK,IAAI,EAAE,GAAG,KAAY,WACxC,IAAK,KAAU,OAAO,KAAK,IAAI,KAC/B,IAAS,EAAkB,EAAI,GAAG,CAAE,GAEpC,IAAe,GAAG,EAAO,UAAU,EAAS,GAAG,KAC/C,IAAc,EAAW,GAAS,CAAY;EACpD,MAAM,EAAY,GAAa,EAAU,MAAM;GAC7C,gBAAgB,EAAe;GAC/B,aAAa,EAAU,KAAK,QAAQ;EACtC,CAAC;EACD,IAAM,IAAc,MAAM,EAAe,CAAW,GAE9C,IAAY,MAAM,EAAc,EAAU,MAAM;GAAE,UAAU;GAAK,SAAS;EAAI,GAAG,EAAO,KAAK,GAC7F,IAAgB,GAAG,EAAO,qBAAqB,EAAS,OACxD,IAAW,EAAW,GAAS,CAAa;EAIlD,OAHA,MAAM,EAAY,GAAU,GAAW;GAAE,aAAa;GAAc,gBAAgB,EAAe;EAAE,CAAC,GAG/F;GAAE;GAAa;GAAc,cAAA,MAFT,EAAe,CAAQ;GAEA;EAAc;CAClE;CAGA,eAAe,EAAkB,GAAgF;EAC/G,IAAM,IAAU,EAAW,CAAG;EAC9B,MAAM,QAAQ,IAAI,CAChB,EAAa,EAAW,GAAS,EAAM,YAAY,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,GACpE,EAAa,EAAW,GAAS,EAAM,aAAa,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CACvE,CAAC;CACH;CAMA,eAAe,EAAa,GAAa,GAA6B;EACpE,IAAM,IAAU,EAAW,CAAG,GACxB,IAAO,GAAG,EAAI,CAAC,CAAC,gBAAgB,GAAG,EAAI,cACvC,IAAU,EAAW,GAAS,CAAI;EAExC,OADA,MAAM,EAAY,GAAS,GAAM,EAAE,aAAa,EAAK,QAAQ,aAAa,CAAC,GACpE,EAAe,CAAO;CAC/B;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;CACF;AACF"}
|
package/dist/types.d.ts
CHANGED
|
@@ -113,7 +113,7 @@ export interface CommentModel {
|
|
|
113
113
|
/** 클라이언트 트랜잭션으로 증감하는 UI 편의 필드. 기존 댓글에는 없을 수 있음(표시 시 0). */
|
|
114
114
|
likeCount?: number;
|
|
115
115
|
}
|
|
116
|
-
export type BoardUserRole = 'admin' | 'staff' | 'user';
|
|
116
|
+
export type BoardUserRole = 'owner' | 'admin' | 'manager' | 'staff' | 'user';
|
|
117
117
|
/** memiBoardUsers/{uid} — 이메일만 빼고 전부 공개 프로필 정보다(누구나 읽을 수 있음). */
|
|
118
118
|
export interface BoardUserModel {
|
|
119
119
|
id: string;
|
|
@@ -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;
|
|
@@ -174,6 +176,8 @@ export interface BoardModel {
|
|
|
174
176
|
* 비어 있으면 담당 없음(관리자만). 관리자는 항상 통과.
|
|
175
177
|
*/
|
|
176
178
|
allowedStaffUids?: string[];
|
|
179
|
+
/** 이 게시판의 설정 및 스태프 배정을 관리하는 매니저 */
|
|
180
|
+
allowedManagerUids?: string[];
|
|
177
181
|
/**
|
|
178
182
|
* 게시판 화면 언어(MEMI_BOARD_LOCALES 코드). 비어 있으면 호스트 언어를 따른다.
|
|
179
183
|
*/
|
|
@@ -2,9 +2,11 @@ import { BoardVisibility, BoardWriteRole } from '../types';
|
|
|
2
2
|
/** 스태프가 이 보드 담당인지. 목록에 있는 uid만. 비어 있으면 담당 없음(관리자만). */
|
|
3
3
|
export declare function isAssignedStaff(uid: string | undefined, board: {
|
|
4
4
|
allowedStaffUids?: string[];
|
|
5
|
+
allowedManagerUids?: string[];
|
|
5
6
|
} | null | undefined): boolean;
|
|
6
7
|
export declare function canManageBoardByRole(uid: string | undefined, isAdmin: boolean, isStaff: boolean, board: {
|
|
7
8
|
allowedStaffUids?: string[];
|
|
9
|
+
allowedManagerUids?: string[];
|
|
8
10
|
} | null | undefined): boolean;
|
|
9
11
|
/**
|
|
10
12
|
* 댓글 작성 권한. 숨김 보드는 commentWriteRole 과 무관하게 관리자·담당 스태프만.
|
|
@@ -14,4 +16,5 @@ export declare function canWriteCommentByRole(uid: string | undefined, isAdmin:
|
|
|
14
16
|
visibility?: BoardVisibility;
|
|
15
17
|
commentWriteRole?: BoardWriteRole;
|
|
16
18
|
allowedStaffUids?: string[];
|
|
19
|
+
allowedManagerUids?: string[];
|
|
17
20
|
} | null | undefined): boolean;
|
|
@@ -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>>;
|
package/dist/utils/section.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export declare const BOARD_SECTION_SORTS: {
|
|
|
21
21
|
value: BoardSectionSort;
|
|
22
22
|
label: string;
|
|
23
23
|
}[];
|
|
24
|
-
export declare function defaultSectionCount(kind: BoardSectionKind):
|
|
24
|
+
export declare function defaultSectionCount(kind: BoardSectionKind): 4 | 5;
|
|
25
25
|
export declare function clampSectionCount(kind: BoardSectionKind, value: unknown): number;
|
|
26
26
|
export declare function pairSectionCols(cols: BoardSectionCols): BoardSectionCols | null;
|
|
27
27
|
export declare function sectionColClass(cols: BoardSectionCols): string;
|
|
@@ -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,53 @@ firebase deploy --only firestore:rules,storage
|
|
|
77
77
|
|
|
78
78
|
**병합만 하고 배포를 안 하면 글쓰기·댓글에서 `permission-denied`가 난다** — 가장 흔한 설치 실수다.
|
|
79
79
|
|
|
80
|
+
### 0.45.1 규칙 변경과 기존 호스트 전환
|
|
81
|
+
|
|
82
|
+
0.45.1은 클라이언트와 Firestore·Storage 규칙을 함께 갱신해야 한다. 사이트 자동 배포는 Firebase 규칙 배포를 대신하지 않는다.
|
|
83
|
+
|
|
84
|
+
| 대상 | 변경 내용 |
|
|
85
|
+
|------|-----------|
|
|
86
|
+
| 글 수정 | `boardId`·`authorUid` 변경을 금지하고 `listed` 변경을 공개 범위 전환으로 제한한다. |
|
|
87
|
+
| 글·본문 동시 생성 | 본문 쓰기에서 `getAfter()`로 batch 적용 후 부모 글의 소유권을 확인한다. |
|
|
88
|
+
| 공개 범위 전환 | 보드 설정의 `visibilityTransition` 동안 새 글·댓글 생성과 일반 글 수정을 잠근다. 담당 관리자의 `listed` 동기화는 허용한다. |
|
|
89
|
+
| 삭제·감사 기록 | 콘텐츠 작성자·담당 관리자가 연관 좋아요를 정리할 수 있다. 글을 지워도 신고 기록은 남긴다. |
|
|
90
|
+
| 이미지·첨부 업로드 | 기존 글은 작성자 또는 전역 admin·staff만 쓴다. 글 저장 전에는 업로드 파일의 `customMetadata.ownerUid`가 로그인 UID와 같아야 한다. 덮어쓰기는 기존 파일의 `ownerUid`도 확인한다. |
|
|
91
|
+
| 파일 삭제 | 글 문서가 있으면 작성자 또는 전역 admin·staff, 없으면 파일에 기록된 `ownerUid` 본인만 삭제한다. |
|
|
92
|
+
|
|
93
|
+
적용 순서:
|
|
94
|
+
|
|
95
|
+
1. 호스트에서 `memi-board@^0.45.1` 이상을 설치하고 lockfile을 갱신한다. 직접 만든 업로드 코드도 이미지·썸네일·첨부파일 모두에 `customMetadata: { ownerUid: user.uid }`를 넣는다.
|
|
96
|
+
2. 최신 예제의 함수와 컬렉션·경로별 규칙 전체를 호스트에 병합한다. 컬렉션 이름을 바꿨다면 함수 내부 Firestore 경로도 맞춘다. 예전의 `!postExists(postId)`만으로 파일 쓰기·삭제를 허용하는 조건은 제거한다. Rules의 겹치는 `match` 중 하나라도 허용하면 접근이 가능하므로, 넓은 `allow write`를 남겨두지 않는다.
|
|
97
|
+
3. 에뮬레이터에서 두 계정으로 새 글·본문 동시 저장, 이미지·첨부 업로드, 타인 파일 덮어쓰기·삭제 거절, 댓글 좋아요 정리, 숨김 전환 재시도를 확인한다.
|
|
98
|
+
4. 새 클라이언트와 규칙 배포를 같은 배포 작업으로 진행하고 앱이 사용하는 모든 버킷에 적용한다. 새 규칙이 적용되면 기존 브라우저의 구버전 클라이언트는 저장 전 업로드에 `ownerUid`가 없어 거절될 수 있으므로 새 버전을 불러온 뒤 재시도한다. 아래 교차 서비스 IAM 설정도 확인한다.
|
|
99
|
+
|
|
100
|
+
기존 글의 파일은 `ownerUid`가 없어도 부모 글의 작성자·역할로 판단하므로 메타데이터 일괄 수정이 필요하지 않다. 반면 부모 글 문서가 없고 `ownerUid`도 없는 예전 고아 파일은 클라이언트에서 수정·삭제할 수 없다. 필요한 정리는 신뢰할 수 있는 Admin SDK 또는 콘솔에서 수행한다.
|
|
101
|
+
|
|
102
|
+
공개 범위 전환 중 실패하면 `visibilityTransition`을 임의로 지우거나 반대 범위로 바꾸지 말고, 설정 화면에서 **같은 목표 범위로 다시 저장**해 동기화를 완료한다. 숨김 전환은 콘텐츠의 `listed`를 먼저 내리고 마지막에 `visibility: 'hidden'`을 확정한다. 공개 복귀는 보드 공개를 먼저 허용한 뒤 콘텐츠를 동기화하고 전환 표시를 비운다.
|
|
103
|
+
|
|
104
|
+
Storage의 Firestore 조회 제한은 서로 다른 문서 최대 2개이므로 예제는 글 문서와 사용자 역할 문서만 읽는다. 따라서 Storage의 admin·staff는 보드 담당 여부와 무관한 전역 파일 관리 권한을 유지한다. Firestore의 담당 스태프 범위와 혼동하지 않는다. [공식 조회 제한](https://firebase.google.com/docs/storage/security/rules-conditions#enhance_with_firestore)을 참고한다.
|
|
105
|
+
|
|
106
|
+
예제의 파일 읽기와 목록 조회는 계속 공개다. 숨김 보드·비공개 초안 파일까지 비공개로 저장해야 하는 서비스는 별도의 Storage 읽기 정책을 설계해야 한다.
|
|
107
|
+
|
|
108
|
+
### Storage 규칙의 Firestore 조회 권한 (부모/호스트 프로젝트 필수 설정)
|
|
109
|
+
|
|
110
|
+
Storage 예시 규칙은 이미지·첨부 업로드와 파일 삭제 시 `firestore.exists()` / `firestore.get()`으로 글의 존재 여부, `authorUid`, 사용자 `role`을 조회한다. 따라서 **호스트 Firebase 프로젝트의 Storage 서비스 계정**에 Firestore 조회 역할이 필요하다. 패키지 설치나 Firestore Rules 배포만으로 이 IAM 설정이 완료되지는 않는다.
|
|
111
|
+
|
|
112
|
+
호스트 프로젝트 관리자가 다음을 설정한다:
|
|
113
|
+
|
|
114
|
+
1. 호스트 저장소에서 위 배포 명령을 대화형으로 실행한다. Firebase CLI가 교차 서비스 규칙용 IAM 역할 추가를 물으면 승인한다. Firebase 콘솔에서 Storage Rules를 저장할 때 표시되는 연결 권한 안내로도 설정할 수 있다.
|
|
115
|
+
2. Google Cloud Console → **IAM 및 관리자 → IAM**에서 같은 Firebase 프로젝트를 선택하고 **Google 제공 역할 부여 포함(Include Google-provided role grants)**을 켠다.
|
|
116
|
+
3. `service-<PROJECT_NUMBER>@gcp-sa-firebasestorage.iam.gserviceaccount.com` 계정을 찾아 **Firebase Rules Firestore Service Agent** (`roles/firebaserules.firestoreServiceAgent`) 역할이 있는지 확인한다. `<PROJECT_NUMBER>`는 프로젝트 ID가 아니라 Firebase 프로젝트 설정의 숫자 프로젝트 번호다. 역할이 없으면 IAM 변경 권한이 있는 호스트 관리자가 이 서비스 계정에 추가한다.
|
|
117
|
+
4. 권한 반영 후 로그인 상태에서 새 글에 20MiB 미만의 작은 첨부파일을 올려 확인한다. 기존 글에서는 작성자·스태프의 첨부 작업도 확인한다.
|
|
118
|
+
|
|
119
|
+
이 역할은 프로젝트의 Firestore 문서 조회(`datastore.entities.get`)를 허용한다. 일반 사용자 계정에 부여하는 역할이 아니며, Firestore 쓰기·삭제 권한은 포함하지 않는다. Storage 규칙의 소유권·역할 검사는 그대로 유지한다.
|
|
120
|
+
|
|
121
|
+
**CI의 `--non-interactive` 배포 성공만으로 IAM 설정까지 완료됐다고 판단하지 않는다.** 최초 설정은 호스트 관리자가 대화형 배포 또는 IAM 콘솔에서 수행하고, 위 서비스 계정의 역할을 확인한다.
|
|
122
|
+
|
|
123
|
+
2MB ZIP처럼 용량 조건을 충족해도 이 권한이 없으면 `memiBoardPosts/{postId}/attachments/...`에서 `storage/unauthorized`가 날 수 있다. 새 글 문서가 아직 없어도 `firestore.exists()` 조회 자체에는 권한이 필요하다. 0.45.1의 본문 이미지 업로드도 같은 Firestore 조회 권한이 필요하다.
|
|
124
|
+
|
|
125
|
+
공식 근거: [교차 서비스 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).
|
|
126
|
+
|
|
80
127
|
## 6. 로그인 공급자 설정
|
|
81
128
|
|
|
82
129
|
### Google
|
|
@@ -32,12 +32,12 @@ service cloud.firestore {
|
|
|
32
32
|
function isBoardAdmin() {
|
|
33
33
|
return isSignedIn()
|
|
34
34
|
&& exists(boardUserPath())
|
|
35
|
-
&& get(boardUserPath()).data.role
|
|
35
|
+
&& get(boardUserPath()).data.role in ['owner', 'admin'];
|
|
36
36
|
}
|
|
37
37
|
function isBoardStaff() {
|
|
38
38
|
return isSignedIn()
|
|
39
39
|
&& exists(boardUserPath())
|
|
40
|
-
&& get(boardUserPath()).data.role in ['admin', 'staff'];
|
|
40
|
+
&& get(boardUserPath()).data.role in ['owner', 'admin', 'manager', 'staff'];
|
|
41
41
|
}
|
|
42
42
|
function boardSettingsPath(boardId) {
|
|
43
43
|
return /databases/$(database)/documents/memiBoardSettings/$(boardId);
|
|
@@ -64,11 +64,40 @@ service cloud.firestore {
|
|
|
64
64
|
}
|
|
65
65
|
function isAllowedStaffForBoard(boardId) {
|
|
66
66
|
let allowed = get(boardSettingsPath(boardId)).data.get('allowedStaffUids', []);
|
|
67
|
-
return isBoardAdmin() || request.auth.uid in allowed
|
|
67
|
+
return isBoardAdmin() || request.auth.uid in allowed
|
|
68
|
+
|| request.auth.uid in get(boardSettingsPath(boardId)).data.get('allowedManagerUids', []);
|
|
69
|
+
}
|
|
70
|
+
function isBoardOwner() {
|
|
71
|
+
return isSignedIn() && get(boardUserPath()).data.role == 'owner';
|
|
72
|
+
}
|
|
73
|
+
function canConfigureBoard(boardId) {
|
|
74
|
+
return isBoardAdmin() || (isSignedIn()
|
|
75
|
+
&& get(boardUserPath()).data.role == 'manager'
|
|
76
|
+
&& request.auth.uid in get(boardSettingsPath(boardId)).data.get('allowedManagerUids', []));
|
|
77
|
+
}
|
|
78
|
+
function canChangeRole(uid) {
|
|
79
|
+
return isSignedIn() && uid != request.auth.uid
|
|
80
|
+
&& request.resource.data.role in ['admin', 'manager', 'staff', 'user']
|
|
81
|
+
&& resource.data.role != 'owner'
|
|
82
|
+
&& (isBoardOwner() || (isBoardAdmin()
|
|
83
|
+
&& resource.data.role != 'admin' && request.resource.data.role != 'admin'));
|
|
68
84
|
}
|
|
69
85
|
function isBoardManager(boardId) {
|
|
70
86
|
return isBoardStaff() && isAllowedStaffForBoard(boardId);
|
|
71
87
|
}
|
|
88
|
+
// A visibility transition freezes new content until every listed flag is synchronized.
|
|
89
|
+
function boardWritesOpen(boardId) {
|
|
90
|
+
return get(boardSettingsPath(boardId)).data.get('visibilityTransition', '') == '';
|
|
91
|
+
}
|
|
92
|
+
function canDeletePostData(postId) {
|
|
93
|
+
let post = get(postPath(postId));
|
|
94
|
+
return isOwner(post) || isBoardManager(post.data.boardId);
|
|
95
|
+
}
|
|
96
|
+
function canDeleteLike(data) {
|
|
97
|
+
return canDeletePostData(data.postId)
|
|
98
|
+
|| (data.get('target', '') == 'comment'
|
|
99
|
+
&& isOwner(get(commentPath(data.commentId))));
|
|
100
|
+
}
|
|
72
101
|
function canWriteBoard(boardId) {
|
|
73
102
|
let requiredRole = get(boardSettingsPath(boardId)).data.get('writeRole', 'user');
|
|
74
103
|
return exists(boardSettingsPath(boardId))
|
|
@@ -167,7 +196,7 @@ service cloud.firestore {
|
|
|
167
196
|
&& (!('moderationBlockCount' in request.resource.data)
|
|
168
197
|
|| request.resource.data.moderationBlockCount == 0
|
|
169
198
|
|| request.resource.data.moderationBlockCount == 1);
|
|
170
|
-
allow update: if (
|
|
199
|
+
allow update: if (canChangeRole(uid) && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['role', 'updatedAt']))
|
|
171
200
|
|| (isSignedIn()
|
|
172
201
|
&& request.auth.uid == uid
|
|
173
202
|
&& request.resource.data.role == resource.data.role
|
|
@@ -223,11 +252,13 @@ service cloud.firestore {
|
|
|
223
252
|
allow read: if true;
|
|
224
253
|
allow create: if isBoardAdmin();
|
|
225
254
|
allow update: if isBoardAdmin()
|
|
226
|
-
|| (
|
|
255
|
+
|| (canConfigureBoard(boardId)
|
|
227
256
|
&& request.resource.data.get('visibility', 'public')
|
|
228
257
|
== resource.data.get('visibility', 'public')
|
|
229
|
-
&& request.resource.data.get('
|
|
230
|
-
== resource.data.get('
|
|
258
|
+
&& request.resource.data.get('allowedManagerUids', [])
|
|
259
|
+
== resource.data.get('allowedManagerUids', [])
|
|
260
|
+
&& request.resource.data.get('visibilityTransition', '')
|
|
261
|
+
== resource.data.get('visibilityTransition', ''));
|
|
231
262
|
allow delete: if isBoardAdmin();
|
|
232
263
|
}
|
|
233
264
|
|
|
@@ -239,18 +270,28 @@ service cloud.firestore {
|
|
|
239
270
|
&& request.resource.data.authorUid == request.auth.uid
|
|
240
271
|
&& request.resource.data.moderationStatus == 'approved'
|
|
241
272
|
&& canWriteBoard(request.resource.data.boardId)
|
|
273
|
+
&& boardWritesOpen(request.resource.data.boardId)
|
|
242
274
|
&& request.resource.data.listed is bool
|
|
243
275
|
&& request.resource.data.listed == expectedListed(request.resource.data.boardId)
|
|
244
276
|
&& request.resource.data.isPublished == false;
|
|
245
277
|
|
|
246
|
-
allow update: if
|
|
247
|
-
|
|
278
|
+
allow update: if (
|
|
279
|
+
(isOwner(resource) || isBoardManager(resource.data.boardId))
|
|
280
|
+
&& request.resource.data.boardId == resource.data.boardId
|
|
281
|
+
&& request.resource.data.authorUid == resource.data.authorUid
|
|
282
|
+
&& (
|
|
283
|
+
(request.resource.data.listed == resource.data.listed
|
|
284
|
+
&& boardWritesOpen(resource.data.boardId))
|
|
285
|
+
|| (isBoardManager(resource.data.boardId)
|
|
286
|
+
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['listed']))
|
|
287
|
+
)
|
|
288
|
+
) || isCommentCountUpdate() || isLikeCountUpdate() || isViewCountUpdate();
|
|
248
289
|
allow delete: if isOwner(resource) || isBoardManager(resource.data.boardId);
|
|
249
290
|
|
|
250
291
|
match /body/{docId} {
|
|
251
292
|
allow read: if canReadPost(get(postPath(postId)).data);
|
|
252
|
-
allow write: if isOwner(
|
|
253
|
-
|| isBoardManager(
|
|
293
|
+
allow write: if isOwner(getAfter(postPath(postId)))
|
|
294
|
+
|| isBoardManager(getAfter(postPath(postId)).data.boardId);
|
|
254
295
|
}
|
|
255
296
|
}
|
|
256
297
|
|
|
@@ -268,6 +309,7 @@ service cloud.firestore {
|
|
|
268
309
|
// 일치하는지 확인한다(비용: 댓글 생성마다 get() 1회 추가).
|
|
269
310
|
&& get(postPath(request.resource.data.postId)).data.boardId == request.resource.data.boardId
|
|
270
311
|
&& canWriteBoardComment(request.resource.data.boardId)
|
|
312
|
+
&& boardWritesOpen(request.resource.data.boardId)
|
|
271
313
|
&& request.resource.data.authorUid == request.auth.uid
|
|
272
314
|
&& request.resource.data.body is string
|
|
273
315
|
&& request.resource.data.body.size() > 0
|
|
@@ -303,7 +345,9 @@ service cloud.firestore {
|
|
|
303
345
|
// 없으면 resource.data 접근이 실패해 일반 사용자의 첫 좋아요가 permission-denied 가 된다.
|
|
304
346
|
allow read: if (isSignedIn() && resource == null)
|
|
305
347
|
|| (isSignedIn() && request.auth.uid == resource.data.uid)
|
|
306
|
-
|| (resource != null && canReadPost(get(postPath(resource.data.postId)).data))
|
|
348
|
+
|| (resource != null && canReadPost(get(postPath(resource.data.postId)).data))
|
|
349
|
+
|| (resource != null && resource.data.get('target', '') == 'comment'
|
|
350
|
+
&& canReadComment(get(commentPath(resource.data.commentId)).data));
|
|
307
351
|
|
|
308
352
|
allow create: if isBoardWriter()
|
|
309
353
|
&& request.resource.data.uid == request.auth.uid
|
|
@@ -311,7 +355,8 @@ service cloud.firestore {
|
|
|
311
355
|
&& request.resource.data.boardId is string
|
|
312
356
|
&& (isValidPostLike(likeId) || isValidCommentLike(likeId));
|
|
313
357
|
|
|
314
|
-
allow delete: if isSignedIn()
|
|
358
|
+
allow delete: if isSignedIn()
|
|
359
|
+
&& (request.auth.uid == resource.data.uid || canDeleteLike(resource.data));
|
|
315
360
|
// update 는 허용하지 않음 — 토글은 항상 create-or-delete.
|
|
316
361
|
}
|
|
317
362
|
|
|
@@ -319,7 +364,7 @@ service cloud.firestore {
|
|
|
319
364
|
match /memiBoardReports/{reportId} {
|
|
320
365
|
allow read: if (isSignedIn() && resource == null)
|
|
321
366
|
|| (isSignedIn() && request.auth.uid == resource.data.uid)
|
|
322
|
-
||
|
|
367
|
+
|| isBoardAdmin() || isBoardManager(resource.data.boardId);
|
|
323
368
|
|
|
324
369
|
allow create: if isBoardWriter()
|
|
325
370
|
&& request.resource.data.uid == request.auth.uid
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# 0.46 역할 마이그레이션
|
|
2
|
+
|
|
3
|
+
역할: user(일반 사용자), staff(콘텐츠 담당), manager(담당 게시판 설정·스태프 배정), admin(전체 관리), owner(관리자 지정).
|
|
4
|
+
|
|
5
|
+
`allowedStaffUids`는 콘텐츠 담당자, `allowedManagerUids`는 설정 담당자다. manager도 다른 게시판의 allowedStaffUids에만 배정되면 그 게시판에서는 콘텐츠만 관리한다. 글로벌 역할은 자격이고 실제 범위는 게시판 문서가 정한다. 매장 등 호스트의 조직을 게시판에 연결하고 배정을 동기화하는 책임은 호스트에 있다.
|
|
6
|
+
|
|
7
|
+
호환성을 위해 `isAdmin`은 owner를, `isStaff`는 manager를 포함한다. 정확한 역할은 `role`, `isOwner`, `isManager`로 확인한다. 설정 화면 진입에는 `canConfigureBoard(boardId)`, 콘텐츠 관리에는 `canManageBoard(boardId)`를 사용한다. 글·댓글 작성 최소 역할은 기존 user/staff/admin을 유지한다(manager는 staff 수준, owner는 admin 수준).
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// nuxt.config.ts
|
|
11
|
+
memiBoard: {
|
|
12
|
+
roleLabels: { user: '고객', staff: '매장 직원', manager: '매장 관리자', admin: '운영자', owner: '최고 관리자' }
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
생략하면 언어별 기본 명칭을 쓴다. 커스텀 명칭은 모든 언어에 동일하게 적용된다.
|
|
17
|
+
|
|
18
|
+
## 적용 순서
|
|
19
|
+
|
|
20
|
+
1. 호스트에 새 패키지와 역할 인식 코드를 반영한다.
|
|
21
|
+
2. Firestore·Storage 규칙 예제를 호스트의 기존 규칙에 병합하고 에뮬레이터로 검사한다.
|
|
22
|
+
3. 새 규칙을 배포한다. App Hosting 배포만으로 규칙이 갱신되지 않는다.
|
|
23
|
+
4. 신뢰할 수 있는 Admin SDK로 기존 admin을 owner로, 기존 staff를 manager로 옮긴다. 각 게시판의 기존 staff UID를 allowedManagerUids로 이전한다. 일반 사용자는 그대로 둔다. 기존 admin이 여럿이면 모두 owner로 이전하므로 사전에 수량을 검토한다.
|
|
24
|
+
5. 새 호스트를 배포하고 로그인 상태를 새로고침한다.
|
|
25
|
+
|
|
26
|
+
memi-dev의 scripts/migrate-board-roles.mjs는 기본 dry-run, --apply로 트랜잭션 적용한다. 이전 역할·배정은 클라이언트 접근이 막힌 memiBoardMigrations/roles-v046에 기록한다. 완료 마커가 있으면 재실행해도 신규 역할을 재승격하지 않는다. owner의 신규 지정·이관은 신뢰할 수 있는 서버 작업으로만 수행한다.
|
|
27
|
+
|
|
28
|
+
## 파일 권한
|
|
29
|
+
|
|
30
|
+
Storage 규칙의 Firestore 조회 제한 때문에 기본 규칙은 작성자와 전역 admin/owner만 타인 파일을 수정·삭제할 수 있게 한다. staff/manager에게 전역 파일 권한을 주지 않는다. staff/manager의 타인 글 삭제 시 파일 정리는 기존 best-effort 방식이므로 남은 파일은 호스트 서버에서 정리해야 한다. 담당자의 타인 이미지·첨부 교체가 필요한 호스트는 토큰과 해당 게시판 배정을 검증하는 서버 API를 구현해야 한다.
|
|
31
|
+
|
|
32
|
+
매니저는 공개 범위 변경·게시판 생성/삭제·자신의 매니저 배정 변경·전역 역할 변경을 할 수 없다. 담당 스태프 배정은 이미 staff/manager 자격을 가진 계정을 대상으로 한다.
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
// memi-board 0.
|
|
1
|
+
// memi-board 0.45.1+ Storage Rules (flat schema, ownerUid metadata)
|
|
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,11 +13,23 @@ 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. Scoped staff must not gain global file access.
|
|
12
18
|
function isBoardStaff() {
|
|
13
19
|
let userPath = /databases/(default)/documents/memiBoardUsers/$(request.auth.uid);
|
|
14
|
-
return isSignedIn()
|
|
15
|
-
&& firestore.
|
|
16
|
-
|
|
20
|
+
return isSignedIn() && firestore.exists(userPath)
|
|
21
|
+
&& firestore.get(userPath).data.role in ['owner', 'admin'];
|
|
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);
|
|
17
33
|
}
|
|
18
34
|
function postExists(postId) {
|
|
19
35
|
return firestore.exists(/databases/(default)/documents/memiBoardPosts/$(postId));
|
|
@@ -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로 고정돼 있어 본인만 쓸 수 있다. 공개 프로필의 일부라 읽기는 전체 공개.
|
package/package.json
CHANGED
package/dist/en-BVUBLkxc.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"en-BVUBLkxc.js","names":[],"sources":["../src/i18n/messages/en/attachments.ts","../src/i18n/messages/en/auth.ts","../src/i18n/messages/en/authorMemo.ts","../src/i18n/messages/en/authorMenu.ts","../src/i18n/messages/en/avatarCropModal.ts","../src/i18n/messages/en/boardSettings.ts","../src/i18n/messages/en/boardUsers.ts","../src/i18n/messages/en/commentFeed.ts","../src/i18n/messages/en/commentForm.ts","../src/i18n/messages/en/commentItem.ts","../src/i18n/messages/en/commentList.ts","../src/i18n/messages/en/commentThread.ts","../src/i18n/messages/en/comments.ts","../src/i18n/messages/en/common.ts","../src/i18n/messages/en/detail.ts","../src/i18n/messages/en/editor.ts","../src/i18n/messages/en/footer.ts","../src/i18n/messages/en/likes.ts","../src/i18n/messages/en/list.ts","../src/i18n/messages/en/listViewSwitch.ts","../src/i18n/messages/en/miniComments.ts","../src/i18n/messages/en/miniList.ts","../src/i18n/messages/en/miniPicker.ts","../src/i18n/messages/en/miniPost.ts","../src/i18n/messages/en/moderation.ts","../src/i18n/messages/en/moderationPrompt.ts","../src/i18n/messages/en/moderationStrike.ts","../src/i18n/messages/en/posts.ts","../src/i18n/messages/en/profile.ts","../src/i18n/messages/en/profileEditModal.ts","../src/i18n/messages/en/reportButton.ts","../src/i18n/messages/en/reportList.ts","../src/i18n/messages/en/reports.ts","../src/i18n/messages/en/sectionUtil.ts","../src/i18n/messages/en/sections.ts","../src/i18n/messages/en/seo.ts","../src/i18n/messages/en/settings.ts","../src/i18n/messages/en/shareButton.ts","../src/i18n/messages/en/signIn.ts","../src/i18n/messages/en/storage.ts","../src/i18n/messages/en/swipeHint.ts","../src/i18n/messages/en/userCommentList.ts","../src/i18n/messages/en/userPostList.ts","../src/i18n/messages/en/users.ts","../src/i18n/messages/en/writingAssistant.ts","../src/i18n/messages/en/index.ts"],"sourcesContent":["export default {\n attach: 'Attach files ({count} / {max})',\n maxFiles: 'You can attach up to {max} files.',\n}\n","export default {\n writeRestricted: 'Posting and commenting are temporarily restricted due to repeated content warnings.',\n writeRestrictedUntil: 'Posting and commenting are temporarily restricted due to repeated content warnings. ({until})',\n strikeSuffix: ' (Warning {count}/{threshold})',\n strikeRestrictedUntil: ' You have reached {count} warnings, so posting and commenting are restricted ({until}).',\n strikeRestricted: ' You have reached {count} warnings, so posting and commenting are restricted.',\n notInitialized: '[memi-board] Firebase Auth is not initialized.',\n}\n","export default {\n tooLong: 'Memos can be up to {max} characters.',\n}\n","export default {\n viewProfile: 'View profile',\n viewPosts: 'View posts',\n viewComments: 'View comments',\n memoAriaLabel: 'Note about this author',\n memoTitle: 'Private note',\n memoPlaceholder: 'Jot something down about this author…',\n bad: 'Bad',\n good: 'Good',\n}\n","export default {\n title: 'Crop profile photo',\n description: 'Shown as a square (1:1). Adjust the area to fit.',\n hint: 'Drag to move and zoom · Saved as a {size}×{size} square JPEG',\n confirm: 'Crop and upload',\n}\n","export default {\n signInRequired: 'Please sign in.',\n idAndNameRequired: 'Please enter a board ID and name.',\n deleteIdRequired: 'A board ID is required to delete.',\n nameRequired: 'Please enter a board name.',\n}\n","export default {\n unsupportedRole: 'Unsupported role.',\n roleDescription: {\n admin: 'Manages user roles, board settings, and all content.',\n staff: 'Manages all posts and comments, but can’t change user roles or board settings.',\n user: 'Can write posts and comments, and edit or delete only their own content.',\n },\n}\n","export default {\n empty: 'No comments yet.',\n emptyLiked: 'No liked comments yet.',\n loadFailed: 'Couldn’t load the list: {error}',\n blinded: 'This comment was hidden by an admin.',\n}\n","export default {\n hiddenBoardDenied: 'Only assigned staff can comment on hidden boards.',\n denied: 'You don’t have permission to comment on this board.',\n signInRequired: 'Please sign in.',\n writeRestricted: 'Posting is temporarily restricted due to repeated content warnings.',\n moderationBlocked: 'This contains content that can’t be posted.',\n replyTo: 'Reply to {name}',\n placeholder: 'Write a comment',\n submitReply: 'Reply',\n submit: 'Comment',\n signInToComment: 'Sign in to leave a comment.',\n}\n","export default {\n editRestricted: 'Editing comments is temporarily restricted.',\n moderationBlocked: 'This contains content that can’t be posted.',\n editFailed: 'Couldn’t update the comment.',\n blindConfirm: 'Hiding this comment shows a notice to everyone instead of its text. The original isn’t deleted and you can unhide it later.\\n\\nHide this comment?',\n unblindConfirm: 'Unhiding shows the original comment to everyone again.\\n\\nUnhide this comment?',\n blindFailed: 'Couldn’t change the hidden state.',\n likeFailed: 'Couldn’t process the like.',\n blinded: 'This comment was hidden by an admin.',\n edit: 'Edit comment',\n blind: 'Hide comment',\n unblind: 'Unhide comment',\n delete: 'Delete comment',\n}\n","export default {\n loading: 'Loading comments',\n empty: 'No comments yet.',\n loadMore: 'More comments',\n loadingMore: 'Loading more comments',\n}\n","export default {\n deleteConfirm: 'Delete this comment?',\n deleteFailed: 'Couldn’t delete the comment.',\n hideReplies: 'Hide replies',\n showReplies: '{count} replies',\n loadMoreReplies: 'More replies',\n}\n","export default {\n bodyRequired: 'Please enter a comment.',\n bodyTooLong: 'Comments can be up to {max} characters.',\n replyTargetNotFound: 'The comment you are replying to could not be found.',\n threadNotFound: 'The comment thread could not be found.',\n hasRepliesCannotDelete: 'Comments with replies cannot be deleted right now.',\n}\n","export default {\n action: {\n cancel: 'Cancel',\n save: 'Save',\n saved: 'Saved',\n edit: 'Edit',\n delete: 'Delete',\n confirm: 'OK',\n close: 'Close',\n add: 'Add',\n more: 'More',\n prev: 'Previous',\n next: 'Next',\n retry: 'Retry',\n reply: 'Reply',\n like: 'Like',\n unlike: 'Unlike',\n share: 'Share',\n report: 'Report',\n signIn: 'Sign in',\n signOut: 'Sign out',\n write: 'Write',\n toTop: 'Top',\n },\n label: {\n anonymous: 'Anonymous',\n user: 'User',\n image: 'Image',\n draft: 'Draft',\n loading: 'Loading…',\n },\n role: {\n admin: 'Admin',\n staff: 'Staff',\n user: 'User',\n },\n date: {\n justNow: 'Just now',\n fewSecondsAgo: 'A few seconds ago',\n checking: 'Checking time',\n createdAt: 'Created: {time}',\n updatedAt: 'Updated: {time}',\n },\n}\n","export default {\n deleteConfirm: 'Delete this post? This can’t be undone.',\n notFound: 'Post not found.',\n draftPreview: 'Unpublished preview',\n draftNotice: 'This post isn’t published yet. Only you can see it until you publish.',\n publish: 'Publish',\n backToList: 'Back to list',\n postNav: 'Post navigation',\n list: 'List',\n comments: 'Comments',\n}\n","export default {\n titlePlaceholder: 'Title',\n contentPlaceholder: 'Write something… Type / to open the block menu.',\n imageContentPlaceholder: 'Write something…',\n tagsPlaceholder: 'Type a tag and press Enter',\n skipModeration: 'Skip profanity filter (admin)',\n submitEdit: 'Save changes',\n submitNext: 'Next: Preview',\n apply: 'Apply',\n imageOptimizing: 'Optimizing and uploading image…',\n cover: {\n hint: 'Photos {count}/{max} · Pick a cover · Drag to reorder · Drop or paste files to add',\n imageLink: 'Image link',\n addPhoto: 'Add photos',\n alt: 'Photo {n}',\n representative: 'Cover',\n setRepresentative: 'Set cover',\n setRepresentativeAria: 'Set photo {n} as cover',\n remove: 'Remove photo',\n add: 'Add',\n uploading: 'Uploading…',\n dropOrClick: 'Drop photos or click to add',\n emptyHint: 'Select multiple · Pick a cover after upload · Drag to reorder',\n uploadTitle: 'Upload',\n },\n uploadFailed: 'Upload failed',\n aiAssistant: 'AI writing assistant',\n insertBlock: 'Insert block',\n blockMenu: 'Block menu',\n imageDialog: {\n title: 'Add image',\n chooseFile: 'Choose image from device',\n orLink: 'or image link',\n urlLabel: 'Image URL',\n urlHelp: 'Only HTTPS URLs are allowed. If the image is removed from the source site, it won’t display here either.',\n previewAlt: 'External image preview',\n addLink: 'Add link',\n },\n customDialog: {\n title: 'Rewrite in a custom style',\n label: 'How should it be rewritten?',\n help: 'e.g. in a casual tone / in formal written style / as concise bullet points',\n placeholder: 'In a casual tone',\n },\n aiPreview: {\n title: 'Review AI result',\n currentTitle: 'Current title',\n newTitle: 'New title',\n currentContent: 'Current content',\n appendedContent: 'Content to add',\n before: 'Before',\n after: 'After',\n },\n menu: {\n ai: 'AI',\n aiContinue: 'Continue with AI',\n style: 'Style',\n insert: 'Insert',\n paragraph: 'Paragraph',\n heading1: 'Heading 1',\n heading2: 'Heading 2',\n heading3: 'Heading 3',\n bulletList: 'Bulleted list',\n orderedList: 'Numbered list',\n blockquote: 'Quote',\n codeBlock: 'Code block',\n horizontalRule: 'Divider',\n image: 'Image',\n turnInto: 'Turn into',\n resetFormatting: 'Reset formatting',\n duplicate: 'Duplicate',\n moveUp: 'Move up',\n moveDown: 'Move down',\n delete: 'Delete',\n },\n toolbar: {\n link: 'Link',\n image: 'Image',\n format: 'Format',\n bold: 'Bold',\n italic: 'Italic',\n underline: 'Underline',\n strike: 'Strikethrough',\n title: 'Title',\n heading: 'Heading',\n subheading: 'Subheading',\n body: 'Body',\n mono: 'Monospace',\n bulletList: 'Bulleted list',\n orderedList: 'Numbered list',\n blockquote: 'Block quote',\n codeBlock: 'Code block',\n horizontalRule: 'Divider',\n clearFormatting: 'Clear formatting',\n undo: 'Undo',\n redo: 'Redo',\n },\n ai: {\n proofread: 'Check spelling',\n polish: 'Polish writing',\n continue: 'Continue writing',\n summarize: 'Summarize briefly',\n translateEn: 'Translate to English',\n translateKo: 'Translate to Korean',\n title: 'Generate title from body',\n custom: 'Rewrite in a custom style…',\n failed: 'The AI task failed. Please try again.',\n },\n youtubePrompt: 'Enter a YouTube link.',\n errors: {\n coverMax: 'You can upload up to {max} photos.',\n imageUrlRequired: 'Enter an image URL.',\n imageUrlInvalid: 'Enter a valid image URL.',\n imageUrlHttps: 'Only HTTPS image URLs are allowed.',\n imageUrlCredentials: 'URLs containing login credentials aren’t allowed.',\n imageTimeout: 'The image is taking too long to load.',\n imageNotDisplayable: 'This image can’t be displayed.',\n imageLoadFailed: 'Couldn’t load the image. Check the URL or whether the site allows external linking.',\n editorNotReady: 'The editor isn’t ready. Please try again.',\n imageLinkFailed: 'Couldn’t add the image link.',\n imageOnly: 'Only image files can be uploaded.',\n imageTooLarge: 'Original images must be {mb}MB or smaller.',\n imageUploadFailed: 'Image upload failed.',\n pasteAsFile: 'Paste or drag photos as files into the area above.',\n postNotFound: 'Post not found.',\n loadPermission: 'You don’t have permission to load this post. Check that you’re signed in.',\n loadFailed: 'Couldn’t load the post: {message}',\n editPermission: 'You don’t have permission to edit. Make sure it’s your post and you’re still signed in.',\n titleRequired: 'Enter a title.',\n photoRequired: 'Upload at least one photo.',\n bodyRequired: 'Write some text or attach an image.',\n signInRequired: 'Sign-in required.',\n boardMissing: 'No board was specified.',\n writeRestricted: 'Posting and commenting are temporarily restricted due to repeated content warnings.',\n moderationBlocked: 'This contains content that can’t be posted.',\n },\n hint: {\n reviewing: 'Reviewing content…',\n saving: 'Saving…',\n },\n}\n","export default {\n introduction: 'A board built on Nuxt 4, Vue 3 and TypeScript, using Nuxt UI and Firebase to keep posts and comments flowing smoothly.',\n info: {\n title: 'Board info',\n about: 'About this board',\n history: 'Version history',\n },\n stack: {\n ui: 'Interface & experience',\n data: 'Data & sign-in',\n writing: 'Writing & convenience',\n },\n}\n","export default {\n signInRequired: 'Please sign in.',\n commentNotFound: 'Comment not found.',\n}\n","export default {\n introduction: 'This board is built on Nuxt 4, Vue 3 and TypeScript. Nuxt UI and Tailwind CSS shape a comfortable interface, while Firebase Firestore, Auth, Storage and nuxt-vuefire keep posts and comments flowing smoothly.',\n settings: 'Settings',\n loadFailed: 'Couldn’t load the list: {error}',\n empty: 'No posts yet.',\n photo: 'Photo',\n likeCount: 'Likes {count}',\n info: {\n title: 'Board info',\n about: 'About this board',\n history: 'Version history',\n },\n stack: {\n framework: 'Framework & language',\n ui: 'UI & styling',\n backend: 'Backend & data',\n },\n tech: {\n nuxt: 'Vue-based SSR framework',\n vue: 'Composition API · script setup',\n typescript: 'Type-safe data and components',\n nuxtUi: 'Board, form and modal UI',\n tailwind: 'Responsive · light/dark styles',\n lucide: 'Lightweight, consistent icons',\n dayjs: 'Readable dates and relative times',\n firestore: 'Real-time DB for posts and comments',\n auth: 'Secure sign-in and permissions',\n storage: 'Image and attachment storage',\n vuefire: 'Real-time Nuxt–Firebase binding',\n },\n}\n","export default {\n default: 'Default',\n dense: 'Compact',\n video: 'Video',\n}\n","export default {\n empty: 'No comments yet.',\n blinded: 'This comment was hidden by an admin.',\n}\n","export default {\n empty: 'No posts yet.',\n}\n","export default {\n selectedCount: 'Selected {count} / {max}',\n layoutHint: '2+ show as a carousel, 4 as a grid',\n searchPlaceholder: 'Search titles',\n loading: 'Loading posts…',\n noMore: 'No more posts to pick.',\n}\n","export default {\n empty: 'No posts selected.',\n}\n","export default {\n reviewFailed: 'We could not review your content, so it cannot be posted. Please try again shortly.',\n writeRestricted: 'Posting and commenting are temporarily restricted due to repeated content warnings.',\n}\n","export default {\n localHit: 'Expressions like “{hit}” aren’t allowed. Please rephrase.',\n localGeneric: 'This contains profanity, so it can’t be posted. Please rephrase.',\n categoryBlocked: 'This contains {category}, so it can’t be posted. Please edit it.',\n genericBlocked: 'This contains content that can’t be posted. Please edit it.',\n categories: {\n abuse: 'profanity or insults',\n spam: 'spam or advertising',\n adult: 'sexual or adult content',\n violence: 'violence or threats',\n other: 'inappropriate content',\n },\n}\n","export default {\n liftSoon: 'lifts soon',\n liftInHours: 'lifts in about {hours}h',\n liftInDays: 'lifts in about {days}d',\n liftInDaysHours: 'lifts in about {days}d {hours}h',\n}\n","export default {\n boardIdRequired: '[memi-board] boardId is required.',\n photoRequired: 'Please add a photo.',\n bodyRequired: 'Please enter some text or attach an image.',\n titleRequired: 'Please enter a title.',\n}\n","export default {\n notFound: 'User not found.',\n noName: 'No name',\n joined: 'Joined {time}',\n lastVisit: 'Last seen {time}',\n}\n","export default {\n imageOnly: 'Only image files can be selected.',\n nameRequired: 'Please enter a name.',\n saveFailed: 'Couldn’t save the profile.',\n title: 'Edit profile',\n changePhoto: 'Change photo',\n name: 'Name',\n bio: 'Bio',\n bioPlaceholder: 'Tell people about yourself',\n}\n","export default {\n failed: 'Couldn’t submit the report.',\n reported: 'Reported',\n title: 'Report post',\n done: 'Report received. Staff will review it.',\n reason: 'Reason',\n detailPlaceholder: 'Details (optional)',\n submit: 'Report',\n}\n","export default {\n staffOnly: 'Only staff can view reports.',\n loadFailed: 'Couldn’t load the list: {error}',\n empty: 'No pending reports.',\n untitled: 'Untitled post',\n author: 'by {name}',\n dismiss: 'Dismiss',\n confirm: 'Resolve',\n}\n","export default {\n reason: {\n spam: 'Spam / advertising',\n abuse: 'Abuse / hate / harassment',\n adult: 'Sexual content',\n illegal: 'Illegal content',\n other: 'Other',\n },\n fallbackLabel: 'Report',\n signInRequired: 'Please sign in.',\n cannotReportOwn: 'You cannot report your own post.',\n reasonRequired: 'Please select a reason.',\n detailTooLong: 'Details can be up to {max} characters.',\n}\n","export default {\n kind: {\n list: 'Post list',\n post: 'Posts',\n comments: 'Comments',\n },\n sort: {\n latest: 'Latest',\n likes: 'Most liked',\n },\n untitledPost: 'Post',\n view: {\n media: 'Media',\n list: 'List',\n },\n}\n","export default {\n defaultTitle: 'Section {n}',\n}\n","export default {\n allCategory: 'All',\n boardDescription: '{name} board',\n allDescription: 'Board — announcements and community news.',\n postFallbackTitle: 'Post | {site}',\n postFallbackDescription: 'Board',\n photo: 'Photo',\n postDescriptionFallback: 'Board post',\n listTitle: '{name} | {site}',\n listTitleAll: 'Board | {site}',\n listDescription: '{name} — catch up on the latest posts.',\n listDescriptionAll: 'Board — announcements and stories.',\n}\n","export default {\n loading: 'Loading settings…',\n noAccess: 'You don’t have access',\n notFound: {\n title: 'Board not found',\n checkAddress: 'Please check the address.',\n checkBoardAddress: 'Please check the board address.',\n },\n newBoard: {\n title: 'This board doesn’t exist yet',\n description: 'Fill in the details below and save to create it with this ID.',\n },\n noLabel: 'No label',\n option: {\n default: 'Standard',\n },\n id: {\n label: 'Category ID',\n help: 'Used in the URL and data',\n },\n writeRole: {\n label: 'Who can post',\n help: 'Minimum role required to post in this category',\n helpShort: 'Minimum role required to post',\n options: {\n user: 'Users and up',\n staff: 'Staff and up',\n admin: 'Admins only',\n },\n },\n commentWriteRole: {\n label: 'Who can comment',\n help: 'Minimum role required to comment in this category',\n helpShort: 'Minimum role required to comment',\n hiddenHelp: 'Comments on hidden boards don’t appear in the public feed, and only assigned staff can write them',\n hiddenFixed: 'Assigned staff only (fixed for hidden boards)',\n },\n staff: {\n label: 'Assigned staff',\n help: 'Only the selected staff can configure and manage this board. Leave empty for admins only',\n placeholder: 'Admins only (no staff assigned)',\n },\n label: {\n label: 'Display name',\n help: 'The name users see',\n },\n description: {\n label: 'Description',\n help: 'Shown at the top of the board and elsewhere',\n placeholder: 'A short description of this board (optional)',\n },\n visibility: {\n label: 'Visibility',\n help: 'Hidden boards don’t appear in the all-boards filter, and posts can only be read by admins, assigned staff, and their authors. Only assigned staff can comment',\n options: {\n public: { label: 'Visible', description: 'Shown in the full list and filters' },\n hidden: { label: 'Hidden', description: 'Journal or private. Only assigned staff can comment' },\n },\n },\n listView: {\n label: 'List view',\n help: 'How the post list is displayed',\n options: {\n dense: 'Compact',\n video: 'Video',\n },\n },\n editorType: {\n label: 'Post form',\n help: 'How posts are written',\n options: {\n default: 'Write a title and body',\n image: 'Add multiple photos and a cover photo',\n },\n },\n locale: {\n label: 'Board language',\n help: 'Leave empty to follow the site language. If set, this board always appears in that language.',\n auto: 'Auto (follow site language)',\n },\n action: {\n moveDown: 'Down',\n goToBoard: 'Go to board',\n deleteBoard: 'Delete board',\n create: 'Create',\n },\n newCategoryPlaceholder: 'New category name',\n deleteConfirm: 'Delete the “{label}” board?\\n\\nAll posts, comments, replies, inline images, and attachments on this board will be permanently deleted. This can’t be undone.',\n savedCategory: 'Saved the “{label}” category.',\n savedSettings: 'Board settings saved.',\n created: 'Board created.',\n error: {\n orderFailed: 'Couldn’t save the category order.',\n deleteFailed: 'Couldn’t delete the board and its data.',\n saveFailed: 'Couldn’t save the board settings.',\n deleteAllFailed: 'Couldn’t delete the board data.',\n },\n danger: {\n title: 'Danger zone',\n description: 'Permanently deletes all posts, comments, attachments, and category settings. Board users and admin roles are kept.',\n confirmLabel: 'Type “{phrase}” to confirm',\n phrase: 'delete board data',\n button: 'Delete all data',\n done: 'All board data has been deleted.',\n },\n}\n","export default {\n copied: 'Link copied',\n}\n","export default {\n withGoogle: 'Sign in with Google',\n withApple: 'Sign in with Apple',\n or: 'or',\n name: 'Name',\n email: 'Email',\n password: 'Password',\n signUp: 'Sign up',\n toSignUp: 'Don’t have an account? Sign up',\n toSignIn: 'Already have an account? Sign in',\n}\n","export default {\n tooLargeAfterCompress: 'The image is still larger than 5MB after compression. Please choose a smaller image.',\n sourceTooLarge: 'The original image must be {max}MB or smaller.',\n heicUnsupported: 'HEIC photos cannot be uploaded directly. Pick it from the Photos app or convert it to JPG, PNG, or WebP.',\n cannotOptimize: 'This image format cannot be optimized in the browser. Please convert it to JPG, PNG, or WebP.',\n imageOnly: 'Only image files can be uploaded.',\n canvasUnavailable: 'Canvas is not available',\n compressFailed: 'Compression failed',\n loadFailed: 'Failed to load image',\n}\n","export default {\n title: 'Try swiping left or right',\n description: 'Swipe left for the next post, right for the previous one.',\n}\n","export default {\n loadFailed: 'Couldn’t load the list: {error}',\n empty: 'No comments yet.',\n blinded: 'This comment was hidden by an admin.',\n likes: '{count} likes',\n}\n","export default {\n loadFailed: 'Couldn’t load the list: {error}',\n empty: 'No posts yet.',\n}\n","export default {\n changeRoleFailed: 'Couldn’t change the role.',\n loading: 'Loading users…',\n noPermission: {\n title: 'No permission',\n description: 'Only board admins can manage user roles.',\n },\n empty: 'No users have signed in to the board yet.',\n noName: 'No name',\n moderationWarnings: '{count} content warnings',\n}\n","export default {\n loginRequired: 'Sign in to use the AI writing assistant.',\n userMissing: 'Couldn’t verify your user info. Please try again.',\n dailyLimit: 'The AI writing assistant can be used up to {limit} times a day. Please try again in about {hours} hour(s).',\n contentRequired: 'Enter some content for the AI to work on first.',\n customRequired: 'Enter an instruction for how to rewrite it.',\n emptyResult: 'The AI couldn’t produce a result. Please try again.',\n failed: 'The AI task failed. Please try again.',\n actions: {\n proofread: 'Check spelling',\n polish: 'Polish sentences',\n continue: 'Continue writing',\n summarize: 'Summarize',\n translateEn: 'Translate to English',\n translateKo: 'Translate to Korean',\n title: 'Generate title from body',\n custom: 'Rewrite in a custom style…',\n },\n}\n","// 자동 생성 — scripts/gen-i18n-index.mjs\nimport attachments from './attachments'\nimport auth from './auth'\nimport authorMemo from './authorMemo'\nimport authorMenu from './authorMenu'\nimport avatarCropModal from './avatarCropModal'\nimport boardSettings from './boardSettings'\nimport boardUsers from './boardUsers'\nimport commentFeed from './commentFeed'\nimport commentForm from './commentForm'\nimport commentItem from './commentItem'\nimport commentList from './commentList'\nimport commentThread from './commentThread'\nimport comments from './comments'\nimport common from './common'\nimport detail from './detail'\nimport editor from './editor'\nimport footer from './footer'\nimport likes from './likes'\nimport list from './list'\nimport listViewSwitch from './listViewSwitch'\nimport miniComments from './miniComments'\nimport miniList from './miniList'\nimport miniPicker from './miniPicker'\nimport miniPost from './miniPost'\nimport moderation from './moderation'\nimport moderationPrompt from './moderationPrompt'\nimport moderationStrike from './moderationStrike'\nimport posts from './posts'\nimport profile from './profile'\nimport profileEditModal from './profileEditModal'\nimport reportButton from './reportButton'\nimport reportList from './reportList'\nimport reports from './reports'\nimport sectionUtil from './sectionUtil'\nimport sections from './sections'\nimport seo from './seo'\nimport settings from './settings'\nimport shareButton from './shareButton'\nimport signIn from './signIn'\nimport storage from './storage'\nimport swipeHint from './swipeHint'\nimport userCommentList from './userCommentList'\nimport userPostList from './userPostList'\nimport users from './users'\nimport writingAssistant from './writingAssistant'\n\nexport default {\n attachments,\n auth,\n authorMemo,\n authorMenu,\n avatarCropModal,\n boardSettings,\n boardUsers,\n commentFeed,\n commentForm,\n commentItem,\n commentList,\n commentThread,\n comments,\n common,\n detail,\n editor,\n footer,\n likes,\n list,\n listViewSwitch,\n miniComments,\n miniList,\n miniPicker,\n miniPost,\n moderation,\n moderationPrompt,\n moderationStrike,\n posts,\n profile,\n profileEditModal,\n reportButton,\n reportList,\n reports,\n sectionUtil,\n sections,\n seo,\n settings,\n shareButton,\n signIn,\n storage,\n swipeHint,\n userCommentList,\n userPostList,\n users,\n writingAssistant,\n}\n"],"mappings":"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"}
|