memi-board 0.4.5 → 0.4.12
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 +221 -73
- package/dist/composables/useMemiBoardAuth.d.ts +15 -3
- package/dist/composables/useMemiBoardModeration.d.ts +3 -2
- package/dist/composables/useMemiBoardStorage.d.ts +5 -1
- package/dist/config.d.ts +7 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +472 -212
- package/dist/index.js.map +1 -1
- package/dist/runtime/components/CommentForm.vue +13 -2
- package/dist/runtime/components/Detail.vue +19 -5
- package/dist/runtime/components/Editor.vue +217 -71
- package/dist/types.d.ts +16 -4
- package/dist/utils/compressImage.d.ts +7 -0
- package/dist/utils/extractEditorImageUrls.d.ts +5 -0
- package/dist/utils/moderation-prompt.d.ts +10 -1
- package/dist/utils/moderation-strike.d.ts +14 -0
- package/dist/utils/renderMarkdown.d.ts +10 -0
- package/docs/firebase-setup.md +106 -0
- package/docs/firestore.rules.example +97 -0
- package/docs/storage.rules.example +49 -0
- package/package.json +11 -2
|
@@ -1,83 +1,191 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* 글쓰기 폼 — Nuxt UI UEditor + shineb PostEditor 패턴.
|
|
4
|
+
* @see https://ui.nuxt.com/docs/components/editor
|
|
5
|
+
* @see shineb app/components/PostEditor.vue
|
|
6
|
+
*
|
|
7
|
+
* 커스텀은 handlers.image(파일 업로드) + DOM paste/drop 만.
|
|
8
|
+
* TipTap Extension / PluginKey 는 쓰지 않음 (유지보수·충돌 회피).
|
|
9
|
+
*/
|
|
2
10
|
import { ref, computed, watch } from 'vue'
|
|
3
|
-
import type { Attachment } from 'memi-board'
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
import type { Attachment, EditorImageEntry } from 'memi-board'
|
|
12
|
+
import {
|
|
13
|
+
useMemiBoardAuth,
|
|
14
|
+
useMemiBoardPosts,
|
|
15
|
+
useMemiBoardModeration,
|
|
16
|
+
useMemiBoardSettings,
|
|
17
|
+
useMemiBoardStorage,
|
|
18
|
+
EDITOR_IMAGE_MAX_BYTES,
|
|
19
|
+
} from 'memi-board'
|
|
8
20
|
import MemiBoardAttachments from './Attachments.vue'
|
|
9
21
|
|
|
10
22
|
const props = defineProps<{
|
|
11
|
-
/** 지정하면 수정 모드 */
|
|
12
23
|
postId?: string
|
|
13
|
-
/**
|
|
14
|
-
* path 등으로 카테고리가 이미 정해진 경우 (예: /board/:category/new).
|
|
15
|
-
* 설정되면 선택 UI 없이 이 값으로 저장한다.
|
|
16
|
-
*/
|
|
17
24
|
fixedCategory?: string
|
|
18
25
|
}>()
|
|
19
26
|
|
|
20
27
|
const emit = defineEmits<{ saved: [id: string], cancel: [] }>()
|
|
21
28
|
|
|
22
|
-
const { user, isSignedIn } = useMemiBoardAuth()
|
|
29
|
+
const { user, isSignedIn, isWriteRestricted, restrictedMessage } = useMemiBoardAuth()
|
|
23
30
|
const { getPost, createPost, updatePost } = useMemiBoardPosts()
|
|
24
31
|
const { checkText } = useMemiBoardModeration()
|
|
25
32
|
const { categories, categoryLabel, ensureSettings, addCategory } = useMemiBoardSettings()
|
|
33
|
+
const { uploadEditorImage } = useMemiBoardStorage()
|
|
26
34
|
|
|
27
35
|
const title = ref('')
|
|
36
|
+
/** UEditor content-type=html (shineb 와 동일) */
|
|
28
37
|
const content = ref('')
|
|
29
38
|
const tagsInput = ref('')
|
|
30
|
-
/** boardSettings.categories 의 id (fixedCategory 없을 때만 선택) */
|
|
31
39
|
const category = ref<string | undefined>(undefined)
|
|
32
40
|
const attachments = ref<Attachment[]>([])
|
|
33
41
|
|
|
34
42
|
const categoryItems = computed(() =>
|
|
35
43
|
categories.value.map(c => ({ label: c.label, value: c.id })),
|
|
36
44
|
)
|
|
37
|
-
|
|
38
|
-
/** 실제 저장에 쓰는 카테고리 id */
|
|
39
45
|
const effectiveCategory = computed(() => props.fixedCategory || category.value)
|
|
40
|
-
|
|
41
46
|
const isEdit = computed(() => Boolean(props.postId))
|
|
42
47
|
const categoryLocked = computed(() => Boolean(props.fixedCategory))
|
|
48
|
+
|
|
43
49
|
const loading = ref(false)
|
|
44
50
|
const saving = ref(false)
|
|
45
|
-
/** 제출 중 단계 표시 (로컬 필터 → AI Logic → 저장) */
|
|
46
51
|
const submitHint = ref('')
|
|
47
|
-
/** 마지막 검열 결과 요약 (화면 + 콘솔) */
|
|
48
|
-
const lastModerationDebug = ref('')
|
|
49
52
|
const error = ref('')
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
flagged: boolean
|
|
53
|
-
category: string
|
|
54
|
-
reason: string
|
|
55
|
-
error?: boolean
|
|
56
|
-
via?: string
|
|
57
|
-
}): string {
|
|
58
|
-
const viaLabel: Record<string, string> = {
|
|
59
|
-
empty: '빈 텍스트',
|
|
60
|
-
disabled: '검열 끔',
|
|
61
|
-
local: '로컬 금칙어',
|
|
62
|
-
ai: 'Firebase AI Logic',
|
|
63
|
-
'ai-error-allow': 'AI 실패 → onError:allow (통과)',
|
|
64
|
-
'ai-error-block': 'AI 실패 → onError:block (차단)',
|
|
65
|
-
}
|
|
66
|
-
const via = m.via ? (viaLabel[m.via] || m.via) : '?'
|
|
67
|
-
return [
|
|
68
|
-
`via: ${via}`,
|
|
69
|
-
`flagged: ${m.flagged}`,
|
|
70
|
-
`category: ${m.category}`,
|
|
71
|
-
m.reason ? `reason: ${m.reason}` : '',
|
|
72
|
-
m.error ? 'apiError: true' : '',
|
|
73
|
-
].filter(Boolean).join(' · ')
|
|
74
|
-
}
|
|
53
|
+
const imageUploading = ref(false)
|
|
54
|
+
const imageUploadError = ref('')
|
|
75
55
|
|
|
76
56
|
const showAddCategory = ref(false)
|
|
77
57
|
const newCategoryLabel = ref('')
|
|
78
58
|
const addingCategory = ref(false)
|
|
79
59
|
|
|
80
60
|
const attachmentNamespace = ref(props.postId ?? `new-${Date.now()}`)
|
|
61
|
+
const uploadedEditorImages = ref<EditorImageEntry[]>([])
|
|
62
|
+
const editorRef = ref<{ editor?: unknown } | null>(null)
|
|
63
|
+
|
|
64
|
+
function imageNamespace(): string {
|
|
65
|
+
return props.postId || attachmentNamespace.value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function doUploadImage(file: File): Promise<EditorImageEntry> {
|
|
69
|
+
if (!file.type.startsWith('image/')) {
|
|
70
|
+
throw new Error('이미지 파일만 업로드할 수 있습니다.')
|
|
71
|
+
}
|
|
72
|
+
if (file.size > EDITOR_IMAGE_MAX_BYTES) {
|
|
73
|
+
throw new Error(`이미지는 ${EDITOR_IMAGE_MAX_BYTES / 1024 / 1024}MB 이하여야 합니다.`)
|
|
74
|
+
}
|
|
75
|
+
const entry = await uploadEditorImage(file, imageNamespace())
|
|
76
|
+
uploadedEditorImages.value.push(entry)
|
|
77
|
+
return entry
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function uploadAndSetImage(editor: any, file: File) {
|
|
81
|
+
imageUploading.value = true
|
|
82
|
+
imageUploadError.value = ''
|
|
83
|
+
try {
|
|
84
|
+
const entry = await doUploadImage(file)
|
|
85
|
+
editor.chain().focus().setImage({ src: entry.originalUrl }).run()
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
imageUploadError.value = (e as Error).message || '이미지 업로드에 실패했습니다.'
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
imageUploading.value = false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pickAndUploadImage(editor: any) {
|
|
96
|
+
const input = document.createElement('input')
|
|
97
|
+
input.type = 'file'
|
|
98
|
+
input.accept = 'image/*'
|
|
99
|
+
input.onchange = () => {
|
|
100
|
+
const file = input.files?.[0]
|
|
101
|
+
if (file) void uploadAndSetImage(editor, file)
|
|
102
|
+
}
|
|
103
|
+
input.click()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 공식 handlers API — 기본 image(URL prompt) 를 파일 업로드로 교체 (shineb) */
|
|
107
|
+
const handlers = {
|
|
108
|
+
image: {
|
|
109
|
+
canExecute: (editor: any) => editor.can().setImage({ src: '' }),
|
|
110
|
+
isActive: (editor: any) => editor.isActive('image'),
|
|
111
|
+
isDisabled: (editor: any) =>
|
|
112
|
+
!editor.extensionManager?.extensions?.some((ext: any) => ext.name === 'image'),
|
|
113
|
+
execute: (editor: any) => {
|
|
114
|
+
pickAndUploadImage(editor)
|
|
115
|
+
return editor.chain()
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const toolbarItems = [
|
|
121
|
+
[
|
|
122
|
+
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1' },
|
|
123
|
+
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2' },
|
|
124
|
+
{ kind: 'heading', level: 3, icon: 'i-lucide-heading-3' },
|
|
125
|
+
],
|
|
126
|
+
[
|
|
127
|
+
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
|
|
128
|
+
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
|
|
129
|
+
{ kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' },
|
|
130
|
+
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough' },
|
|
131
|
+
{ kind: 'mark', mark: 'code', icon: 'i-lucide-code' },
|
|
132
|
+
],
|
|
133
|
+
[
|
|
134
|
+
{ kind: 'bulletList', icon: 'i-lucide-list' },
|
|
135
|
+
{ kind: 'orderedList', icon: 'i-lucide-list-ordered' },
|
|
136
|
+
{ kind: 'blockquote', icon: 'i-lucide-quote' },
|
|
137
|
+
{ kind: 'codeBlock', icon: 'i-lucide-square-code' },
|
|
138
|
+
],
|
|
139
|
+
[
|
|
140
|
+
{ kind: 'link', icon: 'i-lucide-link' },
|
|
141
|
+
{ kind: 'image', icon: 'i-lucide-image' },
|
|
142
|
+
],
|
|
143
|
+
[
|
|
144
|
+
{ kind: 'horizontalRule', icon: 'i-lucide-minus' },
|
|
145
|
+
{ kind: 'undo', icon: 'i-lucide-undo-2' },
|
|
146
|
+
{ kind: 'redo', icon: 'i-lucide-redo-2' },
|
|
147
|
+
{ kind: 'clearFormatting', icon: 'i-lucide-remove-formatting' },
|
|
148
|
+
],
|
|
149
|
+
] as const
|
|
150
|
+
|
|
151
|
+
function isContentEmpty(html: string): boolean {
|
|
152
|
+
const plain = html
|
|
153
|
+
.replace(/<[^>]+>/g, ' ')
|
|
154
|
+
.replace(/ /g, ' ')
|
|
155
|
+
.replace(/\s+/g, ' ')
|
|
156
|
+
.trim()
|
|
157
|
+
return !plain
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function resolveEditor(): any | null {
|
|
161
|
+
const ed = (editorRef.value as any)?.editor
|
|
162
|
+
return ed?.value ?? ed ?? null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 앱 레벨 paste/drop (TipTap Extension 아님 — shineb 툴바 업로드와 동일 업로드 경로) */
|
|
166
|
+
function onEditorRootPaste(e: ClipboardEvent) {
|
|
167
|
+
const items = Array.from(e.clipboardData?.items ?? [])
|
|
168
|
+
const imageItem = items.find(i => i.type.startsWith('image/'))
|
|
169
|
+
if (!imageItem) return
|
|
170
|
+
const file = imageItem.getAsFile()
|
|
171
|
+
const editor = resolveEditor()
|
|
172
|
+
if (!file || !editor) return
|
|
173
|
+
e.preventDefault()
|
|
174
|
+
void uploadAndSetImage(editor, file)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function onEditorRootDrop(e: DragEvent) {
|
|
178
|
+
const files = Array.from(e.dataTransfer?.files ?? [])
|
|
179
|
+
const image = files.find(f => f.type.startsWith('image/'))
|
|
180
|
+
const editor = resolveEditor()
|
|
181
|
+
if (!image || !editor) return
|
|
182
|
+
e.preventDefault()
|
|
183
|
+
void uploadAndSetImage(editor, image)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function onEditorRootDragOver(e: DragEvent) {
|
|
187
|
+
if (Array.from(e.dataTransfer?.types ?? []).includes('Files')) e.preventDefault()
|
|
188
|
+
}
|
|
81
189
|
|
|
82
190
|
async function loadPost(id: string) {
|
|
83
191
|
loading.value = true
|
|
@@ -168,7 +276,7 @@ function friendlyWriteError(e: unknown): string {
|
|
|
168
276
|
|
|
169
277
|
async function handleSubmit() {
|
|
170
278
|
error.value = ''
|
|
171
|
-
if (!title.value.trim() ||
|
|
279
|
+
if (!title.value.trim() || isContentEmpty(content.value)) {
|
|
172
280
|
error.value = '제목과 내용을 입력해 주세요.'
|
|
173
281
|
return
|
|
174
282
|
}
|
|
@@ -185,28 +293,23 @@ async function handleSubmit() {
|
|
|
185
293
|
error.value = '목록에 있는 카테고리를 선택해 주세요.'
|
|
186
294
|
return
|
|
187
295
|
}
|
|
296
|
+
if (isWriteRestricted.value) {
|
|
297
|
+
error.value = restrictedMessage.value
|
|
298
|
+
|| '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.'
|
|
299
|
+
return
|
|
300
|
+
}
|
|
188
301
|
|
|
189
302
|
saving.value = true
|
|
190
|
-
submitHint.value = '내용을 검토하는 중…
|
|
191
|
-
lastModerationDebug.value = ''
|
|
303
|
+
submitHint.value = '내용을 검토하는 중…'
|
|
192
304
|
try {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
const moderation = await checkText(text)
|
|
196
|
-
lastModerationDebug.value = formatModerationDebug(moderation)
|
|
197
|
-
console.info('[memi-board] post checkText 끝', lastModerationDebug.value, moderation)
|
|
198
|
-
|
|
305
|
+
const plain = content.value.replace(/<[^>]+>/g, ' ')
|
|
306
|
+
const moderation = await checkText(`${title.value}\n${plain}`)
|
|
199
307
|
if (moderation.flagged) {
|
|
200
308
|
error.value = moderation.reason || '게시할 수 없는 내용이 포함되어 있습니다.'
|
|
201
309
|
return
|
|
202
310
|
}
|
|
203
311
|
|
|
204
|
-
|
|
205
|
-
submitHint.value = 'AI 검열 실패 — 설정상 통과 후 저장…'
|
|
206
|
-
}
|
|
207
|
-
else {
|
|
208
|
-
submitHint.value = '저장하는 중…'
|
|
209
|
-
}
|
|
312
|
+
submitHint.value = '저장하는 중…'
|
|
210
313
|
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
|
211
314
|
const payload = {
|
|
212
315
|
title: title.value.trim(),
|
|
@@ -254,7 +357,6 @@ async function handleSubmit() {
|
|
|
254
357
|
class="flex flex-col gap-4"
|
|
255
358
|
@submit.prevent="handleSubmit"
|
|
256
359
|
>
|
|
257
|
-
<!-- path 로 카테고리 고정 시: 표시만 / 아니면 선택+추가 -->
|
|
258
360
|
<div
|
|
259
361
|
v-if="categoryLocked"
|
|
260
362
|
class="flex items-center gap-2 text-sm"
|
|
@@ -322,12 +424,54 @@ async function handleSubmit() {
|
|
|
322
424
|
size="lg"
|
|
323
425
|
required
|
|
324
426
|
/>
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
427
|
+
|
|
428
|
+
<div
|
|
429
|
+
class="rounded-xl border border-default overflow-hidden"
|
|
430
|
+
@paste="onEditorRootPaste"
|
|
431
|
+
@drop="onEditorRootDrop"
|
|
432
|
+
@dragover="onEditorRootDragOver"
|
|
433
|
+
>
|
|
434
|
+
<UAlert
|
|
435
|
+
v-if="imageUploadError"
|
|
436
|
+
color="error"
|
|
437
|
+
variant="subtle"
|
|
438
|
+
icon="i-lucide-circle-alert"
|
|
439
|
+
title="업로드 실패"
|
|
440
|
+
:description="imageUploadError"
|
|
441
|
+
class="rounded-none rounded-t-xl"
|
|
442
|
+
/>
|
|
443
|
+
|
|
444
|
+
<UEditor
|
|
445
|
+
ref="editorRef"
|
|
446
|
+
v-model="content"
|
|
447
|
+
content-type="html"
|
|
448
|
+
:handlers="handlers"
|
|
449
|
+
placeholder="본문을 입력하세요…"
|
|
450
|
+
:ui="{ content: 'min-h-64 p-4' }"
|
|
451
|
+
class="w-full"
|
|
452
|
+
>
|
|
453
|
+
<template #default="{ editor }">
|
|
454
|
+
<UEditorToolbar
|
|
455
|
+
:editor="editor"
|
|
456
|
+
:items="toolbarItems"
|
|
457
|
+
class="border-b border-default p-2"
|
|
458
|
+
/>
|
|
459
|
+
<UEditorDragHandle :editor="editor" />
|
|
460
|
+
</template>
|
|
461
|
+
</UEditor>
|
|
462
|
+
|
|
463
|
+
<p
|
|
464
|
+
v-if="imageUploading"
|
|
465
|
+
class="flex items-center gap-2 border-t border-default px-4 py-2 text-xs text-muted"
|
|
466
|
+
>
|
|
467
|
+
<UIcon
|
|
468
|
+
name="i-lucide-loader-circle"
|
|
469
|
+
class="size-3.5 animate-spin"
|
|
470
|
+
/>
|
|
471
|
+
이미지 업로드 중…
|
|
472
|
+
</p>
|
|
473
|
+
</div>
|
|
474
|
+
|
|
331
475
|
<UInput
|
|
332
476
|
v-model="tagsInput"
|
|
333
477
|
placeholder="태그 (쉼표로 구분)"
|
|
@@ -339,6 +483,12 @@ async function handleSubmit() {
|
|
|
339
483
|
editable
|
|
340
484
|
/>
|
|
341
485
|
|
|
486
|
+
<p
|
|
487
|
+
v-if="isWriteRestricted && restrictedMessage"
|
|
488
|
+
class="text-sm text-warning"
|
|
489
|
+
>
|
|
490
|
+
{{ restrictedMessage }}
|
|
491
|
+
</p>
|
|
342
492
|
<p
|
|
343
493
|
v-if="error"
|
|
344
494
|
class="text-sm text-error"
|
|
@@ -351,20 +501,16 @@ async function handleSubmit() {
|
|
|
351
501
|
>
|
|
352
502
|
{{ submitHint }}
|
|
353
503
|
</p>
|
|
354
|
-
<p
|
|
355
|
-
v-if="lastModerationDebug"
|
|
356
|
-
class="text-xs font-mono text-muted break-all rounded-md bg-muted/40 px-2 py-1.5"
|
|
357
|
-
>
|
|
358
|
-
검열 디버그: {{ lastModerationDebug }}
|
|
359
|
-
</p>
|
|
360
504
|
|
|
361
505
|
<div class="flex gap-2">
|
|
362
506
|
<UButton
|
|
363
507
|
type="submit"
|
|
364
508
|
:loading="saving"
|
|
509
|
+
:disabled="isWriteRestricted || imageUploading"
|
|
365
510
|
:label="isEdit ? '수정 완료' : '게시하기'"
|
|
366
511
|
/>
|
|
367
512
|
<UButton
|
|
513
|
+
type="button"
|
|
368
514
|
variant="ghost"
|
|
369
515
|
color="neutral"
|
|
370
516
|
label="취소"
|
package/dist/types.d.ts
CHANGED
|
@@ -6,6 +6,18 @@ export interface Attachment {
|
|
|
6
6
|
size: number;
|
|
7
7
|
type: string;
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* 에디터 본문 이미지 업로드 결과 (원본 + 썸네일).
|
|
11
|
+
* Storage: `{prefix}/posts/{postId}/images/...` · `.../images/thumbnails/...`
|
|
12
|
+
* 본문 markdown 에는 originalUrl 만 넣고, 썸네일은 목록 카드 등에 활용 가능.
|
|
13
|
+
* 수정 중 버려진 파일은 호스트 스케줄러로 정리 (본문에 없는 path).
|
|
14
|
+
*/
|
|
15
|
+
export interface EditorImageEntry {
|
|
16
|
+
originalUrl: string;
|
|
17
|
+
originalPath: string;
|
|
18
|
+
thumbnailUrl: string;
|
|
19
|
+
thumbnailPath: string;
|
|
20
|
+
}
|
|
9
21
|
/** MVP: 차단된 글은 쓰기 자체가 일어나지 않으므로 항상 'approved'만 저장된다. */
|
|
10
22
|
export type ModerationStatus = 'approved';
|
|
11
23
|
export interface PostModel {
|
|
@@ -53,14 +65,14 @@ export interface BoardSettingsModel {
|
|
|
53
65
|
categories: BoardCategory[];
|
|
54
66
|
updatedAt?: Timestamp;
|
|
55
67
|
}
|
|
56
|
-
/** 검열이 어느 단계에서 끝났는지 (
|
|
57
|
-
export type ModerationVia = 'empty' | 'disabled' | 'local' | 'ai' | 'ai-error-allow' | 'ai-error-block';
|
|
68
|
+
/** 검열이 어느 단계에서 끝났는지 (호스트/로깅용, UI 노출 없음) */
|
|
69
|
+
export type ModerationVia = 'empty' | 'disabled' | 'local' | 'ai' | 'restricted' | 'ai-error-allow' | 'ai-error-block';
|
|
58
70
|
export interface ModerationResult {
|
|
59
71
|
flagged: boolean;
|
|
60
72
|
category: 'none' | 'abuse' | 'spam' | 'adult' | 'violence' | 'other';
|
|
61
73
|
reason: string;
|
|
62
|
-
/** true면 API/파싱 실패로 판단이
|
|
74
|
+
/** true면 API/파싱 실패로 판단이 불확실함 (onError 옵션에 따라 처리) */
|
|
63
75
|
error?: boolean;
|
|
64
|
-
/** 로컬 리스트 / AI / 실패 후 allow
|
|
76
|
+
/** 로컬 리스트 / AI / 실패 후 allow|block */
|
|
65
77
|
via?: ModerationVia;
|
|
66
78
|
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
export declare const MODERATION_SYSTEM = "\uB2F9\uC2E0\uC740 \uAC8C\uC2DC\uD310 \uCF58\uD150\uCE20\uB97C \uC2EC\uC0AC\uD558\uB294 \uC5C4\uACA9\uD55C \uBAA8\uB354\uB808\uC774\uD130\uC785\uB2C8\uB2E4.\n\uC544\uB798 \uD14D\uC2A4\uD2B8\uC5D0 \uC695\uC124\u00B7\uBE44\uC18D\uC5B4\u00B7\uC131\uD76C\uB871\u00B7\uD610\uC624\u00B7\uC704\uD611\u00B7\uC2A4\uD338\u00B7\uB3C4\uBC30\uC131 \uAD11\uACE0\uAC00 \uC788\uC73C\uBA74 \uBC18\uB4DC\uC2DC \uAC70\uBD80(flagged=true)\uD569\uB2C8\uB2E4.\n\nflagged=true \uC608:\n- \uC695\uC124/\uBE44\uC18D\uC5B4: \uC528\uBC1C, \uC2DC\uBC1C, \uBCD1\uC2E0, \uC9C0\uB784, \uC886, \uAC1C\uC0C8\uB07C, \uC874\uB098, fuck, shit, bitch \uB4F1\n- \uC131\uC801 \uBAA8\uC695, \uD610\uC624 \uBC1C\uC5B8, \uD611\uBC15\n- \uAC8C\uC2DC\uD310 \uC8FC\uC81C\uC640 \uBB34\uAD00\uD55C \uB3C4\uBC30\uC131 \uAD11\uACE0/\uC2A4\uD338\n\nflagged=false:\n- \uAC8C\uC2DC\uD310 \uC8FC\uC81C\uC5D0 \uB9DE\uB294 \uC815\uC0C1\uC801\uC778 \uAE00/\uB313\uAE00 (\uAC15\uD55C \uC758\uACAC\uC774\uB098 \uC815\uB2F9\uD55C \uBE44\uD310\uC740 \uD5C8\uC6A9)\n\nJSON\uB9CC \uCD9C\uB825:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true\uBA74 category\
|
|
1
|
+
export declare const MODERATION_SYSTEM = "\uB2F9\uC2E0\uC740 \uAC8C\uC2DC\uD310 \uCF58\uD150\uCE20\uB97C \uC2EC\uC0AC\uD558\uB294 \uC5C4\uACA9\uD55C \uBAA8\uB354\uB808\uC774\uD130\uC785\uB2C8\uB2E4.\n\uC544\uB798 \uD14D\uC2A4\uD2B8\uC5D0 \uC695\uC124\u00B7\uBE44\uC18D\uC5B4\u00B7\uC131\uD76C\uB871\u00B7\uD610\uC624\u00B7\uC704\uD611\u00B7\uC2A4\uD338\u00B7\uB3C4\uBC30\uC131 \uAD11\uACE0\uAC00 \uC788\uC73C\uBA74 \uBC18\uB4DC\uC2DC \uAC70\uBD80(flagged=true)\uD569\uB2C8\uB2E4.\n\nflagged=true \uC608:\n- \uC695\uC124/\uBE44\uC18D\uC5B4: \uC528\uBC1C, \uC2DC\uBC1C, \uBCD1\uC2E0, \uC9C0\uB784, \uC886, \uAC1C\uC0C8\uB07C, \uC874\uB098, fuck, shit, bitch \uB4F1\n- \uC131\uC801 \uBAA8\uC695, \uD610\uC624 \uBC1C\uC5B8, \uD611\uBC15\n- \uAC8C\uC2DC\uD310 \uC8FC\uC81C\uC640 \uBB34\uAD00\uD55C \uB3C4\uBC30\uC131 \uAD11\uACE0/\uC2A4\uD338\n\nflagged=false:\n- \uAC8C\uC2DC\uD310 \uC8FC\uC81C\uC5D0 \uB9DE\uB294 \uC815\uC0C1\uC801\uC778 \uAE00/\uB313\uAE00 (\uAC15\uD55C \uC758\uACAC\uC774\uB098 \uC815\uB2F9\uD55C \uBE44\uD310\uC740 \uD5C8\uC6A9)\n\nJSON\uB9CC \uCD9C\uB825:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true\uBA74 category\uB97C \uACE0\uB974\uACE0, reason\uC740 \uD55C\uAD6D\uC5B4\uB85C **\uBB34\uC5C7\uC774 \uBB38\uC81C\uC778\uC9C0** \uAD6C\uCCB4\uC801\uC73C\uB85C 1\uBB38\uC7A5\n (\uC608: \"\uC695\uC124(\uBE44\uC18D\uC5B4)\uC774 \uD3EC\uD568\uB418\uC5B4 \uAC8C\uC2DC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.\", \"\uAD11\uACE0\u00B7\uC2A4\uD338\uC131 \uBB38\uAD6C\uAC00 \uC788\uC5B4 \uAC8C\uC2DC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.\")\n \uC695\uC124 \uB2E8\uC5B4 \uC804\uCCB4\uB97C \uADF8\uB300\uB85C \uAE38\uAC8C \uC778\uC6A9\uD558\uC9C0 \uB9D0\uACE0, \uC720\uD615(\uC695\uC124/\uD610\uC624/\uC2A4\uD338 \uB4F1)\uC744 \uBD84\uBA85\uD788 \uBC1D\uD78C\uB2E4.\n- flagged=false\uBA74 category\uB294 \"none\", reason\uC740 \"\"";
|
|
2
|
+
/** 사용자 안내용 카테고리 라벨 */
|
|
3
|
+
export declare const MODERATION_CATEGORY_LABELS: Record<string, string>;
|
|
4
|
+
/** 차단 사유를 사용자에게 읽기 쉽게 정리 */
|
|
5
|
+
export declare function formatModerationUserReason(opts: {
|
|
6
|
+
reason?: string;
|
|
7
|
+
category?: string;
|
|
8
|
+
via?: string;
|
|
9
|
+
localHit?: string;
|
|
10
|
+
}): string;
|
|
2
11
|
/** API 없이도 1차로 걸러내는 한국어/영어 비속어 (부분 문자열 매칭) */
|
|
3
12
|
export declare const DEFAULT_LOCAL_BLOCKLIST: string[];
|
|
4
13
|
export declare function buildLocalBlockRegex(patterns: string[]): RegExp;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 게시판 검열 차단 누적 → 일시 이용 제한 (호스트 contentWarning 과 동일 모델).
|
|
3
|
+
* - 콘텐츠 차단(로컬/AI flagged) 시 +1
|
|
4
|
+
* - 유효 경고 ≥ threshold(기본 3) → 글/댓글 작성 불가
|
|
5
|
+
* - 24h마다 lazy 차감 (moderationBlockAt 기준)
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_BLOCK_BAN_THRESHOLD = 3;
|
|
8
|
+
export declare const DEFAULT_BLOCK_BAN_DECAY_MS: number;
|
|
9
|
+
export declare function effectiveModerationBlockCount(count: number | null | undefined, blockAtMs: number | null | undefined, nowMs?: number, decayMs?: number): number;
|
|
10
|
+
/** 제한 해제 시각(ms). 제한 없으면 null. */
|
|
11
|
+
export declare function moderationWriteRestrictedUntilMs(count: number | null | undefined, blockAtMs: number | null | undefined, nowMs?: number, threshold?: number, decayMs?: number): number | null;
|
|
12
|
+
export declare function isModerationWriteRestricted(count: number | null | undefined, blockAtMs: number | null | undefined, nowMs?: number, threshold?: number, decayMs?: number): boolean;
|
|
13
|
+
export declare function formatRestrictedUntilLabel(untilMs: number, nowMs?: number): string;
|
|
14
|
+
export declare function toBlockAtMs(value: unknown): number | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 게시글 상세용 가벼운 마크다운 → HTML (TipTap 없이).
|
|
3
|
+
* 목록 이동 시 UEditor destroy/recreate 로 나는
|
|
4
|
+
* "Adding different instances of a keyed plugin" 를 피한다.
|
|
5
|
+
* 신뢰된 작성자 콘텐츠 전제 — 기본 이스케이프 후 허용 패턴만 복원.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* GFM-ish 최소 변환. 코드 펜스·목록·제목·인용·hr·단락.
|
|
9
|
+
*/
|
|
10
|
+
export declare function renderMarkdownToHtml(markdown: string): string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Firebase 프로젝트 준비 (처음 설치하는 경우)
|
|
2
|
+
|
|
3
|
+
이 모듈을 처음 붙이는 프로젝트, 특히 Firebase를 아직 안 써본 프로젝트라면 README의 "설치" 섹션만으로는 부족하다. 여기 순서대로 하면 된다.
|
|
4
|
+
|
|
5
|
+
## 1. 사전 준비
|
|
6
|
+
|
|
7
|
+
- Node.js + pnpm (또는 npm/yarn)
|
|
8
|
+
- Nuxt **4.0 이상**, `@nuxt/ui` **4.0 이상**
|
|
9
|
+
- Firebase CLI: `npm i -g firebase-tools` 후 `firebase login`
|
|
10
|
+
|
|
11
|
+
## 2. Firebase 프로젝트 만들기 (아직 없다면)
|
|
12
|
+
|
|
13
|
+
1. [Firebase 콘솔](https://console.firebase.google.com)에서 새 프로젝트를 만든다.
|
|
14
|
+
2. **Firestore Database**를 사용 설정한다 (Native mode).
|
|
15
|
+
3. **Storage**를 사용 설정한다.
|
|
16
|
+
4. **Authentication**을 사용 설정하고, Sign-in method에서 쓸 provider(Google/Apple/이메일-비밀번호)를 켠다.
|
|
17
|
+
|
|
18
|
+
이미 있는 Firebase 프로젝트에 붙이는 거라면 이 단계는 건너뛰고, 위 세 서비스가 켜져 있는지만 확인한다.
|
|
19
|
+
|
|
20
|
+
## 3. `firebaseConfig` 값 가져오기
|
|
21
|
+
|
|
22
|
+
Firebase 콘솔 → 프로젝트 설정(톱니바퀴) → **일반** 탭 → "내 앱" 섹션 → 웹 앱이 없으면 추가 → "SDK 설정 및 구성"에서 `apiKey`/`authDomain`/`projectId`/`storageBucket`/`messagingSenderId`/`appId` 값을 그대로 복사해 `nuxt.config.ts`의 `vuefire.config`에 붙여넣는다(memi-board가 아니라 호스트의 nuxt-vuefire 설정이다 — README [설치](../README.md#설치) 참고).
|
|
23
|
+
|
|
24
|
+
## 4. 호스트 프로젝트를 Firebase CLI에 연결
|
|
25
|
+
|
|
26
|
+
프로젝트 루트에 아직 `firebase.json`/`.firebaserc`가 없다면:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
firebase init firestore storage
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
또는 수동으로 만든다:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
// .firebaserc
|
|
36
|
+
{ "projects": { "default": "<your-project-id>" } }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
// firebase.json
|
|
41
|
+
{
|
|
42
|
+
"firestore": { "rules": "firestore.rules" },
|
|
43
|
+
"storage": { "rules": "storage.rules" }
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
이미 Firebase CLI를 쓰고 있는 프로젝트라면, `firebase.json`에 `firestore.rules`/`storage.rules` 경로가 이미 지정돼 있는지만 확인하면 된다.
|
|
48
|
+
|
|
49
|
+
## 5. 게시판 Security Rules 병합 + 배포
|
|
50
|
+
|
|
51
|
+
README의 [Security Model](../README.md#security-model--반드시-읽어야-하는-부분) 섹션대로 `docs/firestore.rules.example`/`docs/storage.rules.example`을 프로젝트 규칙 파일에 병합한 뒤:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
firebase deploy --only firestore:rules,storage
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**병합만 하고 배포를 안 하면 글쓰기·댓글에서 `permission-denied`가 난다** — 가장 흔한 설치 실수다.
|
|
58
|
+
|
|
59
|
+
## 6. 로그인 공급자 설정
|
|
60
|
+
|
|
61
|
+
### Google
|
|
62
|
+
|
|
63
|
+
Firebase 콘솔 → Authentication → Sign-in method에서 Google을 활성화하기만 하면 된다. 추가 설정 없음.
|
|
64
|
+
|
|
65
|
+
### Apple
|
|
66
|
+
|
|
67
|
+
Google보다 손이 더 간다:
|
|
68
|
+
|
|
69
|
+
1. Firebase 콘솔에서 Apple 로그인을 활성화한다.
|
|
70
|
+
2. [Apple Developer](https://developer.apple.com/account) 계정에서 **Services ID**를 새로 만든다.
|
|
71
|
+
3. 그 Services ID의 "Web Authentication Configuration"에 콜백 도메인을 등록한다 — Domains에 `{authDomain}`(Firebase 프로젝트의 authDomain, 보통 `<project-id>.firebaseapp.com`), Return URL에 `https://{authDomain}/__/auth/handler`.
|
|
72
|
+
4. Apple Developer의 **Keys**에서 "Sign in with Apple" 키를 새로 만든다 (기존에 다른 용도 키가 있어도 별도로 새로 만들어야 한다).
|
|
73
|
+
5. Team ID, 방금 만든 키의 Key ID, 그 키의 개인 키(`.p8` 파일 내용)를 Firebase 콘솔의 Apple 로그인 설정 화면에 입력한다.
|
|
74
|
+
|
|
75
|
+
자세한 건 [Firebase 공식 가이드](https://firebase.google.com/docs/auth/web/apple)를 따른다. 이 설정 없이 Apple 로그인 버튼을 누르면 에러가 난다.
|
|
76
|
+
|
|
77
|
+
## 7. Firebase AI Logic(Gemini) 활성화 — AI 검열용
|
|
78
|
+
|
|
79
|
+
1. Firebase 콘솔 → **AI Logic** 에서 활성화하고, **Gemini Developer API** 를 사용하도록 설정한다.
|
|
80
|
+
2. **요금제**: Spark에서도 되는 경우가 있으나 제한·쿼터가 있을 수 있다. 부족하면 Blaze.
|
|
81
|
+
3. 모델: 호스트 `configureMemiBoard({ moderation: { model: 'gemini-3.5-flash-lite' } })` 권장.
|
|
82
|
+
`gemini-2.5-flash` 는 **신규 프로젝트에서 "no longer available to new users"** 로 404 날 수 있다.
|
|
83
|
+
4. `moderation.enabled` 기본이 `true` 이므로 활성화 후 글쓰기 시 자동 호출된다. 끄려면 `enabled: false`.
|
|
84
|
+
|
|
85
|
+
## 8. App Check 설정 (AI Logic Enforce 시 사실상 필수)
|
|
86
|
+
|
|
87
|
+
AI Logic / `firebaseml` 이 **Enforce** 이면 App Check 토큰 없이 검열 API가 실패한다. memi-board는 App Check를 초기화하지 않는다 — **호스트 책임**.
|
|
88
|
+
|
|
89
|
+
1. Firebase 콘솔 → App Check → **웹 앱**에 reCAPTCHA v3 등록 (사이트 키·시크릿).
|
|
90
|
+
등록하는 웹 앱의 **appId** 가 호스트 `vuefire.config.appId` 와 같아야 한다.
|
|
91
|
+
2. 호스트 클라이언트 플러그인에서 `initializeAppCheck` + `ReCaptchaV3Provider` (README App Check 절 예시).
|
|
92
|
+
`vuefire.appCheck` 옵션만 쓰는 방법도 있으나, AI Logic 연동 이슈 시 명시적 `initializeAppCheck` 를 쓴다.
|
|
93
|
+
3. **로컬:** `self.FIREBASE_APPCHECK_DEBUG_TOKEN = true`(콘솔에 UUID 출력 후 App Check → 디버그 토큰 등록) 또는 등록된 고정 UUID.
|
|
94
|
+
4. **프로덕션:** 디버그 토큰 사용 금지. reCAPTCHA 도메인에 실제 호스트 도메인 등록.
|
|
95
|
+
5. (선택) Firestore/Storage Enforce — 켜면 토큰 없는 요청 거부. AI Logic 과 별개 토글이다.
|
|
96
|
+
|
|
97
|
+
## 9. Firestore 복합 인덱스 (카테고리 필터 사용 시)
|
|
98
|
+
|
|
99
|
+
`MemiBoardList` 에 `category` prop 을 넘기면 `category + createdAt` 복합 인덱스가 필요하다.
|
|
100
|
+
콘솔 에러의 인덱스 생성 링크를 따르거나, 호스트 `firestore.indexes.json` 에 추가 후:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
firebase deploy --only firestore:indexes
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
컬렉션 ID 예: `collectionPrefix` 가 `board` 이면 `boardPosts`.
|