memi-board 0.4.12 → 0.4.13

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/dist/index.d.ts CHANGED
@@ -17,4 +17,5 @@ export type { Attachment, EditorImageEntry, ModerationStatus, PostModel, PostDet
17
17
  export { compressImage } from './utils/compressImage';
18
18
  export { extractEditorImageUrls } from './utils/extractEditorImageUrls';
19
19
  export { renderMarkdownToHtml } from './utils/renderMarkdown';
20
+ export { storagePathFromDownloadUrl, postNamespaceFromStoragePath } from './utils/storagePath';
20
21
  export { EDITOR_IMAGE_MAX_BYTES } from './composables/useMemiBoardStorage';
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { computed as e, reactive as t, ref as n, watch as r } from "vue";
2
2
  import { useCollection as i, useCurrentUser as a, useDocument as o, useFirebaseApp as s, useFirebaseAuth as c, useFirestore as l } from "vuefire";
3
3
  import { collection as u, deleteDoc as d, deleteField as f, doc as p, getDoc as m, getDocs as h, increment as g, limit as _, orderBy as v, query as y, serverTimestamp as b, setDoc as x, startAfter as S, updateDoc as C, where as ee, writeBatch as w } from "firebase/firestore";
4
- import { GoogleAuthProvider as T, OAuthProvider as te, createUserWithEmailAndPassword as ne, signInWithEmailAndPassword as re, signInWithPopup as E, signOut as ie, updateProfile as ae } from "firebase/auth";
5
- import { deleteObject as D, getDownloadURL as O, getStorage as k, listAll as A, ref as j, uploadBytes as M, uploadBytesResumable as oe } from "firebase/storage";
6
- import { GoogleAIBackend as N, Schema as P, getAI as F, getGenerativeModel as I } from "firebase/ai";
4
+ import { GoogleAuthProvider as T, OAuthProvider as te, createUserWithEmailAndPassword as ne, signInWithEmailAndPassword as re, signInWithPopup as ie, signOut as ae, updateProfile as oe } from "firebase/auth";
5
+ import { deleteObject as E, getDownloadURL as D, getStorage as O, listAll as k, ref as A, uploadBytes as j, uploadBytesResumable as M } from "firebase/storage";
6
+ import { GoogleAIBackend as N, Schema as P, getAI as se, getGenerativeModel as ce } from "firebase/ai";
7
7
  //#region src/config.ts
8
- var L = "__MEMI_BOARD_CONFIG__";
9
- function R() {
8
+ var F = "__MEMI_BOARD_CONFIG__";
9
+ function I() {
10
10
  let e = globalThis;
11
- return e[L] || (e[L] = t({
11
+ return e[F] || (e[F] = t({
12
12
  collectionPrefix: "memiBoard",
13
13
  auth: { providers: ["google", "apple"] },
14
14
  moderation: {
@@ -20,19 +20,19 @@ function R() {
20
20
  blockBanThreshold: 3,
21
21
  blockBanDecayMs: 864e5
22
22
  }
23
- })), e[L];
23
+ })), e[F];
24
24
  }
25
- function z(e) {
26
- let t = R();
25
+ function L(e) {
26
+ let t = I();
27
27
  e.collectionPrefix && (t.collectionPrefix = e.collectionPrefix), e.auth && Object.assign(t.auth, e.auth), e.moderation && Object.assign(t.moderation, e.moderation);
28
28
  }
29
- function B() {
30
- return R();
29
+ function R() {
30
+ return I();
31
31
  }
32
32
  //#endregion
33
33
  //#region src/utils/moderation-strike.ts
34
- var se = 3, V = 864e5;
35
- function H(e, t, n = Date.now(), r = V) {
34
+ var le = 3, z = 864e5;
35
+ function B(e, t, n = Date.now(), r = z) {
36
36
  let i = Math.max(0, Math.floor(Number(e) || 0));
37
37
  if (i <= 0) return 0;
38
38
  let a = typeof t == "number" && Number.isFinite(t) ? t : null;
@@ -40,7 +40,7 @@ function H(e, t, n = Date.now(), r = V) {
40
40
  let o = Math.floor((n - a) / r);
41
41
  return Math.max(0, i - o);
42
42
  }
43
- function U(e, t, n = Date.now(), r = 3, i = V) {
43
+ function V(e, t, n = Date.now(), r = 3, i = z) {
44
44
  let a = Math.max(0, Math.floor(Number(e) || 0));
45
45
  if (a < r) return null;
46
46
  let o = typeof t == "number" && Number.isFinite(t) ? t : null;
@@ -48,17 +48,17 @@ function U(e, t, n = Date.now(), r = 3, i = V) {
48
48
  let s = o + (a - (r - 1)) * i;
49
49
  return s > n ? s : null;
50
50
  }
51
- function W(e, t, n = Date.now(), r = 3, i = V) {
52
- return U(e, t, n, r, i) != null;
51
+ function H(e, t, n = Date.now(), r = 3, i = z) {
52
+ return V(e, t, n, r, i) != null;
53
53
  }
54
- function G(e, t = Date.now()) {
54
+ function U(e, t = Date.now()) {
55
55
  let n = Math.max(0, e - t), r = Math.ceil(n / 36e5);
56
56
  if (r <= 0) return "곧 해제";
57
57
  if (r < 24) return `약 ${r}시간 후 해제`;
58
58
  let i = Math.floor(r / 24), a = r % 24;
59
59
  return a === 0 ? `약 ${i}일 후 해제` : `약 ${i}일 ${a}시간 후 해제`;
60
60
  }
61
- function ce(e) {
61
+ function ue(e) {
62
62
  if (e == null) return null;
63
63
  if (typeof e == "number" && Number.isFinite(e)) return e;
64
64
  if (typeof e == "object" && e && "toMillis" in e && typeof e.toMillis == "function") return e.toMillis();
@@ -70,26 +70,26 @@ function ce(e) {
70
70
  }
71
71
  //#endregion
72
72
  //#region src/composables/useMemiBoardAuth.ts
73
- var K = "__MEMI_BOARD_AUTH_STATE__";
74
- function le() {
73
+ var W = "__MEMI_BOARD_AUTH_STATE__";
74
+ function de() {
75
75
  let e = globalThis;
76
- return e[K] || (e[K] = {
76
+ return e[W] || (e[W] = {
77
77
  role: n("user"),
78
78
  rolePending: n(!0),
79
79
  rawBlockCount: n(0),
80
80
  blockAtMs: n(null),
81
81
  watchStarted: !1
82
- }), e[K];
82
+ }), e[W];
83
83
  }
84
- function q() {
85
- let t = B(), n = le(), i = l(), o = c(), s = a(), { role: u, rolePending: d, rawBlockCount: f, blockAtMs: h } = n;
84
+ function G() {
85
+ let t = R(), n = de(), i = l(), o = c(), s = a(), { role: u, rolePending: d, rawBlockCount: f, blockAtMs: h } = n;
86
86
  function _() {
87
87
  let e = t.moderation?.blockBanThreshold;
88
88
  return e === 0 ? 0 : typeof e == "number" && e > 0 ? Math.floor(e) : 3;
89
89
  }
90
90
  function v() {
91
91
  let e = t.moderation?.blockBanDecayMs;
92
- return typeof e == "number" && e > 0 ? e : V;
92
+ return typeof e == "number" && e > 0 ? e : z;
93
93
  }
94
94
  function y(e) {
95
95
  return p(i, `${t.collectionPrefix}Users`, e);
@@ -99,7 +99,7 @@ function q() {
99
99
  f.value = 0, h.value = null;
100
100
  return;
101
101
  }
102
- f.value = Math.max(0, Math.floor(Number(e.moderationBlockCount) || 0)), h.value = ce(e.moderationBlockAt);
102
+ f.value = Math.max(0, Math.floor(Number(e.moderationBlockCount) || 0)), h.value = ue(e.moderationBlockAt);
103
103
  }
104
104
  async function ee(e) {
105
105
  let t = y(e.uid), n = await m(t);
@@ -136,20 +136,20 @@ function q() {
136
136
  d.value = !1;
137
137
  }
138
138
  }, { immediate: !0 }));
139
- let D = e(() => !!s.value), O = e(() => u.value === "admin"), k = e(() => H(f.value, h.value, Date.now(), v())), A = e(() => {
139
+ let E = e(() => !!s.value), D = e(() => u.value === "admin"), O = e(() => B(f.value, h.value, Date.now(), v())), k = e(() => {
140
140
  let e = _();
141
- return e <= 0 ? !1 : W(f.value, h.value, Date.now(), e, v());
142
- }), j = e(() => {
141
+ return e <= 0 ? !1 : H(f.value, h.value, Date.now(), e, v());
142
+ }), A = e(() => {
143
143
  let e = _();
144
144
  if (e <= 0) return null;
145
- let t = U(f.value, h.value, Date.now(), e, v());
146
- return t == null ? null : G(t);
147
- }), M = e(() => {
148
- if (!A.value) return null;
149
- let e = j.value;
145
+ let t = V(f.value, h.value, Date.now(), e, v());
146
+ return t == null ? null : U(t);
147
+ }), j = e(() => {
148
+ if (!k.value) return null;
149
+ let e = A.value;
150
150
  return e ? `콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요. (${e})` : "콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.";
151
151
  });
152
- async function oe() {
152
+ async function M() {
153
153
  let e = s.value?.uid, t = _();
154
154
  if (!e || t <= 0) return {
155
155
  effectiveCount: 0,
@@ -170,9 +170,9 @@ function q() {
170
170
  });
171
171
  let i = (r.exists() ? Math.max(0, Math.floor(Number(r.data().moderationBlockCount) || 0)) : 0) + 1, a = Date.now();
172
172
  f.value = i, h.value = a;
173
- let o = H(i, a, a, v()), c = W(i, a, a, t, v()), l = ` (경고 ${o}/${t})`;
173
+ let o = B(i, a, a, v()), c = H(i, a, a, t, v()), l = ` (경고 ${o}/${t})`;
174
174
  if (c) {
175
- let e = U(i, a, a, t, v()), n = e == null ? null : G(e, a);
175
+ let e = V(i, a, a, t, v()), n = e == null ? null : U(e, a);
176
176
  l = n ? ` 경고 ${o}회가 되어 글·댓글 작성이 제한됩니다 (${n}).` : ` 경고 ${o}회가 되어 글·댓글 작성이 제한됩니다.`;
177
177
  }
178
178
  return {
@@ -186,68 +186,94 @@ function q() {
186
186
  return o;
187
187
  }
188
188
  async function P() {
189
- return E(N(), new T());
189
+ return ie(N(), new T());
190
190
  }
191
- async function F() {
191
+ async function se() {
192
192
  let e = new te("apple.com");
193
- return e.addScope("email"), e.addScope("name"), E(N(), e);
193
+ return e.addScope("email"), e.addScope("name"), ie(N(), e);
194
194
  }
195
- async function I(e, t) {
195
+ async function ce(e, t) {
196
196
  return re(N(), e, t);
197
197
  }
198
- async function L(e, t, n) {
198
+ async function F(e, t, n) {
199
199
  let r = await ne(N(), e, t);
200
- return n && await ae(r.user, { displayName: n }), r;
200
+ return n && await oe(r.user, { displayName: n }), r;
201
201
  }
202
- async function R() {
203
- await ie(N());
202
+ async function I() {
203
+ await ae(N());
204
204
  }
205
- function z(e) {
206
- return D.value && (s.value?.uid === e.authorUid || O.value);
205
+ function L(e) {
206
+ return E.value && (s.value?.uid === e.authorUid || D.value);
207
207
  }
208
- function se(e) {
209
- return D.value && (s.value?.uid === e.authorUid || O.value);
208
+ function le(e) {
209
+ return E.value && (s.value?.uid === e.authorUid || D.value);
210
210
  }
211
211
  return {
212
212
  user: s,
213
- isSignedIn: D,
214
- isAdmin: O,
213
+ isSignedIn: E,
214
+ isAdmin: D,
215
215
  rolePending: d,
216
- moderationBlockCount: k,
217
- isWriteRestricted: A,
218
- restrictedUntilLabel: j,
219
- restrictedMessage: M,
216
+ moderationBlockCount: O,
217
+ isWriteRestricted: k,
218
+ restrictedUntilLabel: A,
219
+ restrictedMessage: j,
220
220
  signInWithGoogle: P,
221
- signInWithApple: F,
222
- signInWithEmail: I,
223
- signUpWithEmail: L,
224
- signOut: R,
225
- canEdit: z,
226
- canDelete: z,
227
- canDeleteComment: se,
228
- recordContentModerationBlock: oe,
221
+ signInWithApple: se,
222
+ signInWithEmail: ce,
223
+ signUpWithEmail: F,
224
+ signOut: I,
225
+ canEdit: L,
226
+ canDelete: L,
227
+ canDeleteComment: le,
228
+ recordContentModerationBlock: M,
229
229
  refreshBoardUser: w
230
230
  };
231
231
  }
232
232
  //#endregion
233
233
  //#region src/utils/slugify.ts
234
- function J(e) {
234
+ function K(e) {
235
235
  return e.trim().replace(/\s+/g, "-").replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
236
236
  }
237
- function ue(e, t = 60) {
237
+ function fe(e, t = 60) {
238
238
  return e.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, "-").slice(0, t);
239
239
  }
240
240
  //#endregion
241
+ //#region src/utils/extractEditorImageUrls.ts
242
+ function pe(e) {
243
+ if (!e) return [];
244
+ let t = /* @__PURE__ */ new Set();
245
+ for (let n of e.matchAll(/!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/g)) n[1] && t.add(n[1].replaceAll("&amp;", "&"));
246
+ for (let n of e.matchAll(/<img[^>]*\ssrc=["'](https?:\/\/[^"']+)["']/gi)) n[1] && t.add(n[1].replaceAll("&amp;", "&"));
247
+ return [...t];
248
+ }
249
+ //#endregion
250
+ //#region src/utils/storagePath.ts
251
+ function me(e) {
252
+ try {
253
+ let t = e.match(/\/o\/([^?]+)/);
254
+ return t?.[1] ? decodeURIComponent(t[1]) : null;
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+ function q(e) {
260
+ return e.match(/(?:^|\/)posts\/([^/]+)\//)?.[1] ?? null;
261
+ }
262
+ //#endregion
241
263
  //#region src/composables/useMemiBoardPosts.ts
242
- async function de(e) {
243
- let t = await A(e).catch(() => ({
264
+ async function J(e) {
265
+ let t = await k(e).catch(() => ({
244
266
  items: [],
245
267
  prefixes: []
246
268
  }));
247
- await Promise.all([...t.items.map((e) => D(e)), ...t.prefixes.map((e) => de(e))]);
269
+ await Promise.all([...t.items.map((e) => E(e)), ...t.prefixes.map((e) => J(e))]);
248
270
  }
249
- function fe() {
250
- let e = B(), t = l(), n = s(), r = () => e.collectionPrefix, i = () => u(t, `${r()}Posts`), a = (e) => p(t, `${r()}Posts`, e), o = (e) => p(t, `${r()}Posts`, e, "body", "main"), c = (e) => u(t, `${r()}Posts`, e, "comments");
271
+ async function he(e, t) {
272
+ let n = [...new Set([...t].filter(Boolean))];
273
+ await Promise.all(n.map((t) => E(A(e, t)).catch(() => {})));
274
+ }
275
+ function ge() {
276
+ let e = R(), t = l(), n = s(), r = () => e.collectionPrefix, i = () => u(t, `${r()}Posts`), a = (e) => p(t, `${r()}Posts`, e), o = (e) => p(t, `${r()}Posts`, e, "body", "main"), c = (e) => u(t, `${r()}Posts`, e, "comments");
251
277
  async function g(e = {}) {
252
278
  let t = e.pageSize ?? 20, n = [];
253
279
  e.category && n.push(ee("category", "==", e.category)), n.push(v("createdAt", "desc")), e.cursor && n.push(S(e.cursor)), n.push(_(t + 1));
@@ -270,7 +296,7 @@ function fe() {
270
296
  } : null;
271
297
  }
272
298
  async function te(e) {
273
- let t = J(e.title) || "post", n = t, r = 1;
299
+ let t = K(e.title) || "post", n = t, r = 1;
274
300
  for (; (await m(a(n))).exists();) r++, n = `${t}-${r}`;
275
301
  return await Promise.all([x(a(n), {
276
302
  title: e.title,
@@ -297,13 +323,33 @@ function fe() {
297
323
  }), x(o(e), { content: t.content }, { merge: !0 })]);
298
324
  }
299
325
  async function re(e) {
300
- let i = await h(c(e));
301
- for (let e = 0; e < i.docs.length; e += 450) {
326
+ let [i, s] = await Promise.all([m(a(e)), m(o(e))]);
327
+ if (!i.exists()) return;
328
+ let l = i.data(), u = s.exists() ? String(s.data().content ?? "") : "", f = await h(c(e));
329
+ for (let e = 0; e < f.docs.length; e += 450) {
302
330
  let n = w(t);
303
- i.docs.slice(e, e + 450).forEach((e) => n.delete(e.ref)), await n.commit();
331
+ f.docs.slice(e, e + 450).forEach((e) => n.delete(e.ref)), await n.commit();
332
+ }
333
+ let p = O(n), g = /* @__PURE__ */ new Set(), _ = /* @__PURE__ */ new Set();
334
+ for (let t of l.attachments ?? []) if (t?.path) {
335
+ g.add(t.path);
336
+ let n = q(t.path);
337
+ n && n !== e && _.add(n);
304
338
  }
305
- let s = k(n);
306
- await d(o(e)), await de(j(s, `${r()}/posts/${e}`)), await d(a(e));
339
+ for (let t of pe(u)) {
340
+ let n = me(t);
341
+ if (!n) continue;
342
+ g.add(n);
343
+ let r = n.replace(/\/images\/([^/]+)$/, "/images/thumbnails/$1").replace(/\.[^.]+$/, ".jpg");
344
+ r !== n && r.includes("/images/thumbnails/") && g.add(r);
345
+ let i = n.match(/^(.*\/images\/)([^/]+)\.[^.]+$/);
346
+ i && !n.includes("/thumbnails/") && g.add(`${i[1]}thumbnails/${i[2]}.jpg`);
347
+ let a = q(n);
348
+ a && a !== e && _.add(a);
349
+ }
350
+ await d(o(e)), await J(A(p, `${r()}/posts/${e}`));
351
+ for (let e of _) await J(A(p, `${r()}/posts/${e}`));
352
+ await he(p, g), await d(a(e));
307
353
  }
308
354
  return {
309
355
  getPosts: g,
@@ -315,8 +361,8 @@ function fe() {
315
361
  }
316
362
  //#endregion
317
363
  //#region src/composables/useMemiBoardComments.ts
318
- function pe(t) {
319
- let n = B(), r = l(), a = () => n.collectionPrefix, o = e(() => typeof t == "string" ? t : t.value), s = e(() => y(u(r, `${a()}Posts`, o.value, "comments"), v("createdAt", "asc"))), c = i(s);
364
+ function _e(t) {
365
+ let n = R(), r = l(), a = () => n.collectionPrefix, o = e(() => typeof t == "string" ? t : t.value), s = e(() => y(u(r, `${a()}Posts`, o.value, "comments"), v("createdAt", "asc"))), c = i(s);
320
366
  async function d(e) {
321
367
  let t = w(r), n = a(), i = p(u(r, `${n}Posts`, o.value, "comments"));
322
368
  t.set(i, {
@@ -341,7 +387,7 @@ function pe(t) {
341
387
  }
342
388
  //#endregion
343
389
  //#region src/utils/moderation-prompt.ts
344
- var me = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category를 고르고, reason은 한국어로 **무엇이 문제인지** 구체적으로 1문장\n (예: \"욕설(비속어)이 포함되어 게시할 수 없습니다.\", \"광고·스팸성 문구가 있어 게시할 수 없습니다.\")\n 욕설 단어 전체를 그대로 길게 인용하지 말고, 유형(욕설/혐오/스팸 등)을 분명히 밝힌다.\n- flagged=false면 category는 \"none\", reason은 \"\"", he = {
390
+ var ve = "당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category를 고르고, reason은 한국어로 **무엇이 문제인지** 구체적으로 1문장\n (예: \"욕설(비속어)이 포함되어 게시할 수 없습니다.\", \"광고·스팸성 문구가 있어 게시할 수 없습니다.\")\n 욕설 단어 전체를 그대로 길게 인용하지 말고, 유형(욕설/혐오/스팸 등)을 분명히 밝힌다.\n- flagged=false면 category는 \"none\", reason은 \"\"", ye = {
345
391
  abuse: "욕설·비속어·모욕",
346
392
  spam: "스팸·광고",
347
393
  adult: "선정적·성인 내용",
@@ -356,10 +402,10 @@ function Y(e) {
356
402
  }
357
403
  let t = (e.reason || "").trim();
358
404
  if (t) return t;
359
- let n = e.category && e.category !== "none" ? he[e.category] || e.category : "";
405
+ let n = e.category && e.category !== "none" ? ye[e.category] || e.category : "";
360
406
  return n ? `${n}이(가) 포함되어 게시할 수 없습니다. 내용을 수정해 주세요.` : "게시할 수 없는 내용이 포함되어 있습니다. 내용을 수정해 주세요.";
361
407
  }
362
- var ge = [
408
+ var be = [
363
409
  "씨발",
364
410
  "시발",
365
411
  "시이발",
@@ -385,10 +431,10 @@ var ge = [
385
431
  "bitch",
386
432
  "asshole"
387
433
  ];
388
- function _e(e) {
434
+ function xe(e) {
389
435
  return new RegExp(e.join("|"), "i");
390
436
  }
391
- var ve = /* @__PURE__ */ new Set([
437
+ var Se = /* @__PURE__ */ new Set([
392
438
  "none",
393
439
  "abuse",
394
440
  "spam",
@@ -396,7 +442,7 @@ var ve = /* @__PURE__ */ new Set([
396
442
  "violence",
397
443
  "other"
398
444
  ]);
399
- function ye(e) {
445
+ function Ce(e) {
400
446
  let t = e.trim();
401
447
  if (!t) return null;
402
448
  let n;
@@ -414,7 +460,7 @@ function ye(e) {
414
460
  if (typeof n != "object" || !n) return null;
415
461
  let r = n;
416
462
  if (typeof r.flagged != "boolean") return null;
417
- let i = typeof r.category == "string" && ve.has(r.category) ? r.category : r.flagged ? "other" : "none", a = typeof r.reason == "string" ? r.reason : "";
463
+ let i = typeof r.category == "string" && Se.has(r.category) ? r.category : r.flagged ? "other" : "none", a = typeof r.reason == "string" ? r.reason : "";
418
464
  return {
419
465
  flagged: r.flagged,
420
466
  category: i,
@@ -429,8 +475,8 @@ var X = {
429
475
  reason: "",
430
476
  via: "empty"
431
477
  };
432
- function be() {
433
- let e = B().moderation ?? {}, t = s(), { isWriteRestricted: n, restrictedMessage: r, recordContentModerationBlock: i } = q(), a = _e([...ge, ...e.localBlocklist ?? []]);
478
+ function we() {
479
+ let e = R().moderation ?? {}, t = s(), { isWriteRestricted: n, restrictedMessage: r, recordContentModerationBlock: i } = G(), a = xe([...be, ...e.localBlocklist ?? []]);
434
480
  function o(e) {
435
481
  let t = e.match(a);
436
482
  return t ? {
@@ -445,13 +491,13 @@ function be() {
445
491
  } : null;
446
492
  }
447
493
  function c() {
448
- let n = e.model ?? "gemini-3.5-flash-lite", r = e.useLimitedUseAppCheckTokens !== !1, i = F(t, {
494
+ let n = e.model ?? "gemini-3.5-flash-lite", r = e.useLimitedUseAppCheckTokens !== !1, i = se(t, {
449
495
  backend: new N(),
450
496
  useLimitedUseAppCheckTokens: r
451
497
  });
452
- return I(i, {
498
+ return ce(i, {
453
499
  model: n,
454
- systemInstruction: me,
500
+ systemInstruction: ve,
455
501
  generationConfig: {
456
502
  temperature: 0,
457
503
  maxOutputTokens: 256,
@@ -518,7 +564,7 @@ function be() {
518
564
  via: "disabled"
519
565
  };
520
566
  try {
521
- let e = ye((await c().generateContent(`심사할 텍스트:\n"""${i.slice(0, 4e3)}"""`)).response.text());
567
+ let e = Ce((await c().generateContent(`심사할 텍스트:\n"""${i.slice(0, 4e3)}"""`)).response.text());
522
568
  if (!e) return l();
523
569
  let t = {
524
570
  flagged: e.flagged,
@@ -550,7 +596,7 @@ function be() {
550
596
  let e = await new Promise((e, n) => {
551
597
  let r = new FileReader();
552
598
  r.onload = () => e(r.result.split(",")[1] ?? ""), r.onerror = n, r.readAsDataURL(t);
553
- }), n = ye((await c().generateContent([{ inlineData: {
599
+ }), n = Ce((await c().generateContent([{ inlineData: {
554
600
  mimeType: t.type || "image/jpeg",
555
601
  data: e
556
602
  } }, "이 이미지를 심사해 주세요."])).response.text());
@@ -577,7 +623,7 @@ function be() {
577
623
  }
578
624
  //#endregion
579
625
  //#region src/utils/compressImage.ts
580
- async function xe(e, { maxWidth: t = 400, quality: n = .8 } = {}) {
626
+ async function Te(e, { maxWidth: t = 400, quality: n = .8 } = {}) {
581
627
  return new Promise((r, i) => {
582
628
  let a = new Image(), o = URL.createObjectURL(e);
583
629
  a.onload = () => {
@@ -597,11 +643,11 @@ async function xe(e, { maxWidth: t = 400, quality: n = .8 } = {}) {
597
643
  }
598
644
  //#endregion
599
645
  //#region src/composables/useMemiBoardStorage.ts
600
- var Se = 5242880;
601
- function Ce() {
602
- let e = B(), t = s(), n = () => e.collectionPrefix;
646
+ var Ee = 5242880;
647
+ function De() {
648
+ let e = R(), t = s(), n = () => e.collectionPrefix;
603
649
  function r(e, r, i) {
604
- let a = k(t), o = `${n()}/posts/${r}/attachments/${Date.now()}-${ue(e.name)}`, s = j(a, o), c = oe(s, e, {
650
+ let a = O(t), o = `${n()}/posts/${r}/attachments/${Date.now()}-${fe(e.name)}`, s = A(a, o), c = M(s, e, {
605
651
  contentType: e.type || "application/octet-stream",
606
652
  contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(e.name)}`
607
653
  });
@@ -610,7 +656,7 @@ function Ce() {
610
656
  c.on("state_changed", (e) => {
611
657
  i && e.totalBytes > 0 && i(e.bytesTransferred / e.totalBytes);
612
658
  }, n, async () => {
613
- let n = await O(s);
659
+ let n = await D(s);
614
660
  t({
615
661
  name: e.name,
616
662
  url: n,
@@ -624,28 +670,28 @@ function Ce() {
624
670
  };
625
671
  }
626
672
  async function i(e) {
627
- let n = k(t);
628
- await D(j(n, e.path)).catch(() => {});
673
+ let n = O(t);
674
+ await E(A(n, e.path)).catch(() => {});
629
675
  }
630
676
  async function a(e, r) {
631
677
  if (!e.type.startsWith("image/")) throw Error("이미지 파일만 업로드할 수 있습니다.");
632
- if (e.size > 5242880) throw Error(`이미지는 ${Se / 1024 / 1024}MB 이하여야 합니다.`);
633
- let i = k(t), a = (e.name.split(".").pop() || e.type.split("/")[1] || "png").replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "png", o = ue(e.name.replace(/\.[^.]+$/, "") || "image", 40), s = `${Date.now()}-${o || "image"}`, c = r || `new-${Date.now()}`, l = `${n()}/posts/${c}/images/${s}.${a}`, u = j(i, l);
634
- await M(u, e, { contentType: e.type || "image/png" });
635
- let d = await O(u), f = await xe(e, {
678
+ if (e.size > 5242880) throw Error(`이미지는 ${Ee / 1024 / 1024}MB 이하여야 합니다.`);
679
+ let i = O(t), a = (e.name.split(".").pop() || e.type.split("/")[1] || "png").replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "png", o = fe(e.name.replace(/\.[^.]+$/, "") || "image", 40), s = `${Date.now()}-${o || "image"}`, c = r || `new-${Date.now()}`, l = `${n()}/posts/${c}/images/${s}.${a}`, u = A(i, l);
680
+ await j(u, e, { contentType: e.type || "image/png" });
681
+ let d = await D(u), f = await Te(e, {
636
682
  maxWidth: 400,
637
683
  quality: .8
638
- }), p = `${n()}/posts/${c}/images/thumbnails/${s}.jpg`, m = j(i, p);
639
- return await M(m, f, { contentType: "image/jpeg" }), {
684
+ }), p = `${n()}/posts/${c}/images/thumbnails/${s}.jpg`, m = A(i, p);
685
+ return await j(m, f, { contentType: "image/jpeg" }), {
640
686
  originalUrl: d,
641
687
  originalPath: l,
642
- thumbnailUrl: await O(m),
688
+ thumbnailUrl: await D(m),
643
689
  thumbnailPath: p
644
690
  };
645
691
  }
646
692
  async function o(e) {
647
- let n = k(t);
648
- await Promise.all([D(j(n, e.originalPath)).catch(() => {}), D(j(n, e.thumbnailPath)).catch(() => {})]);
693
+ let n = O(t);
694
+ await Promise.all([E(A(n, e.originalPath)).catch(() => {}), E(A(n, e.thumbnailPath)).catch(() => {})]);
649
695
  }
650
696
  return {
651
697
  uploadAttachment: r,
@@ -670,8 +716,8 @@ var Z = [
670
716
  label: "질문"
671
717
  }
672
718
  ];
673
- function we() {
674
- let t = B(), n = l(), { isSignedIn: i, isAdmin: a } = q(), s = e(() => p(n, `${t.collectionPrefix}Settings`, "config")), { data: c, pending: u } = o(s), d = e(() => {
719
+ function Oe() {
720
+ let t = R(), n = l(), { isSignedIn: i, isAdmin: a } = G(), s = e(() => p(n, `${t.collectionPrefix}Settings`, "config")), { data: c, pending: u } = o(s), d = e(() => {
675
721
  let e = c.value?.categories;
676
722
  return e && e.length > 0 ? e : Z;
677
723
  });
@@ -691,9 +737,9 @@ function we() {
691
737
  if (!t) throw Error("카테고리 이름을 입력해 주세요.");
692
738
  if (!i.value) throw Error("로그인이 필요합니다.");
693
739
  await h();
694
- let n = d.value.find((e) => e.label === t || e.id === J(t));
740
+ let n = d.value.find((e) => e.label === t || e.id === K(t));
695
741
  if (n) return n.id;
696
- let r = J(t) || `cat-${Date.now()}`, a = new Set(d.value.map((e) => e.id));
742
+ let r = K(t) || `cat-${Date.now()}`, a = new Set(d.value.map((e) => e.id));
697
743
  if (a.has(r)) {
698
744
  let e = 2;
699
745
  for (; a.has(`${r}-${e}`);) e++;
@@ -728,7 +774,7 @@ function we() {
728
774
  }
729
775
  //#endregion
730
776
  //#region src/data/versionHistory.ts
731
- var Te = [
777
+ var ke = [
732
778
  {
733
779
  version: "0.2.2",
734
780
  date: "2026-07-31",
@@ -755,19 +801,10 @@ var Te = [
755
801
  ];
756
802
  //#endregion
757
803
  //#region src/utils/formatDate.ts
758
- function Ee(e) {
804
+ function Ae(e) {
759
805
  return e?.toDate ? e.toDate().toLocaleDateString("ko-KR") : "";
760
806
  }
761
807
  //#endregion
762
- //#region src/utils/extractEditorImageUrls.ts
763
- function De(e) {
764
- if (!e) return [];
765
- let t = /* @__PURE__ */ new Set();
766
- for (let n of e.matchAll(/!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/g)) n[1] && t.add(n[1].replaceAll("&amp;", "&"));
767
- for (let n of e.matchAll(/<img[^>]*\ssrc=["'](https?:\/\/[^"']+)["']/gi)) n[1] && t.add(n[1].replaceAll("&amp;", "&"));
768
- return [...t];
769
- }
770
- //#endregion
771
808
  //#region src/utils/renderMarkdown.ts
772
809
  function Q(e) {
773
810
  return e.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -776,7 +813,7 @@ function $(e) {
776
813
  let t = e;
777
814
  return t = t.replace(/`([^`]+)`/g, "<code>$1</code>"), t = t.replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g, "<img src=\"$2\" alt=\"$1\" class=\"max-w-full h-auto rounded-lg my-3\" loading=\"lazy\" />"), t = t.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-primary underline\">$1</a>"), t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"), t = t.replace(/\*([^*]+)\*/g, "<em>$1</em>"), t = t.replace(/~~([^~]+)~~/g, "<del>$1</del>"), t;
778
815
  }
779
- function Oe(e) {
816
+ function je(e) {
780
817
  if (!e?.trim()) return "";
781
818
  let t = e.replace(/\r\n/g, "\n").split("\n"), n = [], r = 0, i = !1, a = [], o = null, s = [];
782
819
  function c() {
@@ -841,6 +878,6 @@ function Oe(e) {
841
878
  return c(), i && a.length && n.push(`<pre class="overflow-x-auto rounded-lg bg-muted p-3 text-sm"><code>${a.join("\n")}</code></pre>`), n.join("\n");
842
879
  }
843
880
  //#endregion
844
- export { V as DEFAULT_BLOCK_BAN_DECAY_MS, se as DEFAULT_BLOCK_BAN_THRESHOLD, Z as DEFAULT_CATEGORIES, Se as EDITOR_IMAGE_MAX_BYTES, xe as compressImage, z as configureMemiBoard, H as effectiveModerationBlockCount, De as extractEditorImageUrls, Ee as formatDate, G as formatRestrictedUntilLabel, W as isModerationWriteRestricted, U as moderationWriteRestrictedUntilMs, Oe as renderMarkdownToHtml, q as useMemiBoardAuth, pe as useMemiBoardComments, B as useMemiBoardConfig, be as useMemiBoardModeration, fe as useMemiBoardPosts, we as useMemiBoardSettings, Ce as useMemiBoardStorage, Te as versionHistory };
881
+ export { z as DEFAULT_BLOCK_BAN_DECAY_MS, le as DEFAULT_BLOCK_BAN_THRESHOLD, Z as DEFAULT_CATEGORIES, Ee as EDITOR_IMAGE_MAX_BYTES, Te as compressImage, L as configureMemiBoard, B as effectiveModerationBlockCount, pe as extractEditorImageUrls, Ae as formatDate, U as formatRestrictedUntilLabel, H as isModerationWriteRestricted, V as moderationWriteRestrictedUntilMs, q as postNamespaceFromStoragePath, je as renderMarkdownToHtml, me as storagePathFromDownloadUrl, G as useMemiBoardAuth, _e as useMemiBoardComments, R as useMemiBoardConfig, we as useMemiBoardModeration, ge as useMemiBoardPosts, Oe as useMemiBoardSettings, De as useMemiBoardStorage, ke as versionHistory };
845
882
 
846
883
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/config.ts","../src/utils/moderation-strike.ts","../src/composables/useMemiBoardAuth.ts","../src/utils/slugify.ts","../src/composables/useMemiBoardPosts.ts","../src/composables/useMemiBoardComments.ts","../src/utils/moderation-prompt.ts","../src/composables/useMemiBoardModeration.ts","../src/utils/compressImage.ts","../src/composables/useMemiBoardStorage.ts","../src/composables/useMemiBoardSettings.ts","../src/data/versionHistory.ts","../src/utils/formatDate.ts","../src/utils/extractEditorImageUrls.ts","../src/utils/renderMarkdown.ts"],"sourcesContent":["import { reactive } from 'vue'\n\nexport interface MemiBoardAuthOptions {\n providers?: Array<'google' | 'apple' | 'emailPassword'>\n}\n\nexport interface MemiBoardModerationOptions {\n enabled?: boolean\n model?: string\n localBlocklist?: string[]\n onError?: 'allow' | 'block'\n moderateImages?: boolean\n /** AI Logic App Check limited-use 토큰 (재생 보호). 기본 true */\n useLimitedUseAppCheckTokens?: boolean\n /**\n * 콘텐츠 검열 차단(로컬/AI flagged) 누적 횟수 이상이 되면 글·댓글 작성 제한.\n * 기본 3. 0 이하면 제한 기능 off.\n */\n blockBanThreshold?: number\n /** 경고 1회 차감 간격(ms). 기본 24시간 */\n blockBanDecayMs?: number\n}\n\nexport interface MemiBoardConfig {\n collectionPrefix: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n}\n\nconst GLOBAL_KEY = '__MEMI_BOARD_CONFIG__' as const\n\n/**\n * link:/file: 모노레포에서 Vite 가 dist 와 src 를 서로 다른 모듈 id 로 묶으면\n * 모듈 스코프 singleton 이 둘로 갈라져 configure 가 안 먹는 문제가 난다.\n * globalThis 에 한 번만 올려 플러그인·컴포넌트가 항상 같은 config 를 보게 한다.\n */\nfunction getSharedConfig(): MemiBoardConfig {\n const g = globalThis as typeof globalThis & {\n [GLOBAL_KEY]?: MemiBoardConfig\n }\n if (!g[GLOBAL_KEY]) {\n g[GLOBAL_KEY] = reactive<MemiBoardConfig>({\n collectionPrefix: 'memiBoard',\n auth: { providers: ['google', 'apple'] },\n moderation: {\n enabled: true,\n model: 'gemini-3.5-flash-lite',\n onError: 'allow',\n moderateImages: false,\n localBlocklist: [],\n blockBanThreshold: 3,\n blockBanDecayMs: 24 * 60 * 60 * 1000,\n },\n })\n }\n return g[GLOBAL_KEY]\n}\n\n/**\n * 앱 부팅 시(플러그인 등에서) 한 번 호출한다. 모든 요청/세션이 같은 값을 보므로\n * 서버 렌더링에서도 안전하다 — 사용자별/요청별로 달라지는 값이 아니다.\n */\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\n const config = getSharedConfig()\n if (options.collectionPrefix) config.collectionPrefix = options.collectionPrefix\n if (options.auth) Object.assign(config.auth, options.auth)\n if (options.moderation) Object.assign(config.moderation, options.moderation)\n}\n\nexport function useMemiBoardConfig(): MemiBoardConfig {\n return getSharedConfig()\n}\n","/**\n * 게시판 검열 차단 누적 → 일시 이용 제한 (호스트 contentWarning 과 동일 모델).\n * - 콘텐츠 차단(로컬/AI flagged) 시 +1\n * - 유효 경고 ≥ threshold(기본 3) → 글/댓글 작성 불가\n * - 24h마다 lazy 차감 (moderationBlockAt 기준)\n */\n\nexport const DEFAULT_BLOCK_BAN_THRESHOLD = 3\nexport const DEFAULT_BLOCK_BAN_DECAY_MS = 24 * 60 * 60 * 1000\n\nexport function effectiveModerationBlockCount(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): number {\n const c = Math.max(0, Math.floor(Number(count) || 0))\n if (c <= 0) return 0\n const at = typeof blockAtMs === 'number' && Number.isFinite(blockAtMs) ? blockAtMs : null\n if (at == null) return c\n if (nowMs <= at) return c\n const decayed = Math.floor((nowMs - at) / decayMs)\n return Math.max(0, c - decayed)\n}\n\n/** 제한 해제 시각(ms). 제한 없으면 null. */\nexport function moderationWriteRestrictedUntilMs(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n threshold: number = DEFAULT_BLOCK_BAN_THRESHOLD,\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): number | null {\n const c = Math.max(0, Math.floor(Number(count) || 0))\n if (c < threshold) return null\n const at = typeof blockAtMs === 'number' && Number.isFinite(blockAtMs) ? blockAtMs : null\n if (at == null) return nowMs\n // 유효 경고가 threshold-1 이하가 되는 시점\n const until = at + (c - (threshold - 1)) * decayMs\n return until > nowMs ? until : null\n}\n\nexport function isModerationWriteRestricted(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n threshold: number = DEFAULT_BLOCK_BAN_THRESHOLD,\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): boolean {\n return moderationWriteRestrictedUntilMs(count, blockAtMs, nowMs, threshold, decayMs) != null\n}\n\nexport function formatRestrictedUntilLabel(untilMs: number, nowMs: number = Date.now()): string {\n const left = Math.max(0, untilMs - nowMs)\n const hours = Math.ceil(left / (60 * 60 * 1000))\n if (hours <= 0) return '곧 해제'\n if (hours < 24) return `약 ${hours}시간 후 해제`\n const days = Math.floor(hours / 24)\n const remH = hours % 24\n if (remH === 0) return `약 ${days}일 후 해제`\n return `약 ${days}일 ${remH}시간 후 해제`\n}\n\nexport function toBlockAtMs(value: unknown): number | null {\n if (value == null) return null\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'object' && value !== null && 'toMillis' in value\n && typeof (value as { toMillis: () => number }).toMillis === 'function') {\n return (value as { toMillis: () => number }).toMillis()\n }\n if (typeof value === 'object' && value !== null && 'seconds' in value) {\n const s = Number((value as { seconds: number }).seconds)\n if (Number.isFinite(s)) return s * 1000\n }\n return null\n}\n","import { ref, computed, watch } from 'vue'\nimport type { Ref, ComputedRef } from 'vue'\nimport { useFirestore, useFirebaseAuth, useCurrentUser } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, increment, serverTimestamp } from 'firebase/firestore'\nimport {\n GoogleAuthProvider,\n OAuthProvider,\n signInWithPopup,\n signInWithEmailAndPassword,\n createUserWithEmailAndPassword,\n signOut as firebaseSignOut,\n updateProfile,\n} from 'firebase/auth'\nimport type { User } from 'firebase/auth'\nimport { useMemiBoardConfig } from '../config'\nimport type { PostModel, CommentModel } from '../types'\nimport {\n DEFAULT_BLOCK_BAN_DECAY_MS,\n DEFAULT_BLOCK_BAN_THRESHOLD,\n effectiveModerationBlockCount,\n formatRestrictedUntilLabel,\n isModerationWriteRestricted,\n moderationWriteRestrictedUntilMs,\n toBlockAtMs,\n} from '../utils/moderation-strike'\n\nexport interface UseMemiBoardAuthReturn {\n user: ReturnType<typeof useCurrentUser>\n isSignedIn: ComputedRef<boolean>\n isAdmin: ComputedRef<boolean>\n rolePending: Ref<boolean>\n /** 유효 검열 차단 누적 (lazy decay 반영) */\n moderationBlockCount: ComputedRef<number>\n /** 글·댓글 작성 제한 중 */\n isWriteRestricted: ComputedRef<boolean>\n restrictedUntilLabel: ComputedRef<string | null>\n restrictedMessage: ComputedRef<string | null>\n signInWithGoogle: () => ReturnType<typeof signInWithPopup>\n signInWithApple: () => ReturnType<typeof signInWithPopup>\n signInWithEmail: (email: string, password: string) => ReturnType<typeof signInWithEmailAndPassword>\n signUpWithEmail: (email: string, password: string, displayName?: string) => ReturnType<typeof createUserWithEmailAndPassword>\n signOut: () => Promise<void>\n canEdit: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDelete: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDeleteComment: (comment: Pick<CommentModel, 'authorUid'>) => boolean\n /** 콘텐츠 검열 차단 1회 기록 후 메시지 조각 반환 */\n recordContentModerationBlock: () => Promise<{\n effectiveCount: number\n restricted: boolean\n messageSuffix: string\n }>\n refreshBoardUser: () => Promise<void>\n}\n\nconst AUTH_STATE_KEY = '__MEMI_BOARD_AUTH_STATE__' as const\n\ntype BoardAuthShared = {\n role: Ref<'user' | 'admin'>\n rolePending: Ref<boolean>\n rawBlockCount: Ref<number>\n blockAtMs: Ref<number | null>\n watchStarted: boolean\n}\n\nfunction getSharedAuthState(): BoardAuthShared {\n const g = globalThis as typeof globalThis & { [AUTH_STATE_KEY]?: BoardAuthShared }\n if (!g[AUTH_STATE_KEY]) {\n g[AUTH_STATE_KEY] = {\n role: ref<'user' | 'admin'>('user'),\n rolePending: ref(true),\n rawBlockCount: ref(0),\n blockAtMs: ref<number | null>(null),\n watchStarted: false,\n }\n }\n return g[AUTH_STATE_KEY]\n}\n\n/**\n * 인증 상태 + 게시판 역할(role) + 검열 차단 누적.\n * role·경고 카운트는 globalThis 공유 (여러 composable 호출이 같은 상태).\n */\nexport function useMemiBoardAuth(): UseMemiBoardAuthReturn {\n const config = useMemiBoardConfig()\n const shared = getSharedAuthState()\n const db = useFirestore()\n const auth = useFirebaseAuth()\n const user = useCurrentUser()\n\n const { role, rolePending, rawBlockCount, blockAtMs } = shared\n\n function banThreshold(): number {\n const t = config.moderation?.blockBanThreshold\n if (t === 0) return 0\n return typeof t === 'number' && t > 0 ? Math.floor(t) : DEFAULT_BLOCK_BAN_THRESHOLD\n }\n\n function banDecayMs(): number {\n const d = config.moderation?.blockBanDecayMs\n return typeof d === 'number' && d > 0 ? d : DEFAULT_BLOCK_BAN_DECAY_MS\n }\n\n function roleDocRef(uid: string) {\n return doc(db, `${config.collectionPrefix}Users`, uid)\n }\n\n function applyUserData(data: Record<string, unknown> | undefined) {\n if (!data) {\n rawBlockCount.value = 0\n blockAtMs.value = null\n return\n }\n rawBlockCount.value = Math.max(0, Math.floor(Number(data.moderationBlockCount) || 0))\n blockAtMs.value = toBlockAtMs(data.moderationBlockAt)\n }\n\n async function ensureUserDoc(firebaseUser: User): Promise<'user' | 'admin'> {\n const ref = roleDocRef(firebaseUser.uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n applyUserData({ moderationBlockCount: 0 })\n return 'user'\n }\n const data = snap.data() as Record<string, unknown>\n applyUserData(data)\n await updateDoc(ref, {\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return (data.role as 'user' | 'admin') ?? 'user'\n }\n\n async function refreshBoardUser() {\n const uid = user.value?.uid\n if (!uid) {\n applyUserData(undefined)\n return\n }\n const snap = await getDoc(roleDocRef(uid))\n applyUserData(snap.exists() ? snap.data() as Record<string, unknown> : undefined)\n }\n\n if (!shared.watchStarted) {\n shared.watchStarted = true\n watch(\n user,\n async (current) => {\n rolePending.value = true\n if (!current) {\n role.value = 'user'\n applyUserData(undefined)\n rolePending.value = false\n return\n }\n try {\n role.value = await ensureUserDoc(current)\n }\n catch {\n role.value = 'user'\n }\n finally {\n rolePending.value = false\n }\n },\n { immediate: true },\n )\n }\n\n const isSignedIn = computed(() => !!user.value)\n const isAdmin = computed(() => role.value === 'admin')\n\n const moderationBlockCount = computed(() =>\n effectiveModerationBlockCount(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n banDecayMs(),\n ),\n )\n\n const isWriteRestricted = computed(() => {\n const th = banThreshold()\n if (th <= 0) return false\n return isModerationWriteRestricted(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n th,\n banDecayMs(),\n )\n })\n\n const restrictedUntilLabel = computed(() => {\n const th = banThreshold()\n if (th <= 0) return null\n const until = moderationWriteRestrictedUntilMs(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n th,\n banDecayMs(),\n )\n return until != null ? formatRestrictedUntilLabel(until) : null\n })\n\n const restrictedMessage = computed(() => {\n if (!isWriteRestricted.value) return null\n const until = restrictedUntilLabel.value\n return until\n ? `콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요. (${until})`\n : '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.'\n })\n\n async function recordContentModerationBlock() {\n const uid = user.value?.uid\n const threshold = banThreshold()\n if (!uid || threshold <= 0) {\n return { effectiveCount: 0, restricted: false, messageSuffix: '' }\n }\n\n const ref = roleDocRef(uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: user.value?.displayName ?? null,\n moderationBlockCount: 1,\n moderationBlockAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n })\n }\n else {\n await updateDoc(ref, {\n moderationBlockCount: increment(1),\n moderationBlockAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n })\n }\n\n // increment 직후 getDoc 이 로컬 캐시로 바로 반영됨\n const nextCount = (snap.exists()\n ? Math.max(0, Math.floor(Number((snap.data() as Record<string, unknown>).moderationBlockCount) || 0))\n : 0) + 1\n const nextAt = Date.now()\n rawBlockCount.value = nextCount\n blockAtMs.value = nextAt\n\n const effectiveCount = effectiveModerationBlockCount(\n nextCount,\n nextAt,\n nextAt,\n banDecayMs(),\n )\n const restricted = isModerationWriteRestricted(\n nextCount,\n nextAt,\n nextAt,\n threshold,\n banDecayMs(),\n )\n // 앞 사유 문장과 이어 붙일 접미 (공백·구분자 포함)\n let messageSuffix = ` (경고 ${effectiveCount}/${threshold})`\n if (restricted) {\n const until = moderationWriteRestrictedUntilMs(\n nextCount,\n nextAt,\n nextAt,\n threshold,\n banDecayMs(),\n )\n const untilLabel = until != null ? formatRestrictedUntilLabel(until, nextAt) : null\n messageSuffix = untilLabel\n ? ` 경고 ${effectiveCount}회가 되어 글·댓글 작성이 제한됩니다 (${untilLabel}).`\n : ` 경고 ${effectiveCount}회가 되어 글·댓글 작성이 제한됩니다.`\n }\n return { effectiveCount, restricted, messageSuffix }\n }\n\n function requireAuth() {\n if (!auth) throw new Error('[memi-board] Firebase Auth가 초기화되지 않았습니다.')\n return auth\n }\n\n async function signInWithGoogle() {\n return signInWithPopup(requireAuth(), new GoogleAuthProvider())\n }\n\n async function signInWithApple() {\n const provider = new OAuthProvider('apple.com')\n provider.addScope('email')\n provider.addScope('name')\n return signInWithPopup(requireAuth(), provider)\n }\n\n async function signInWithEmail(email: string, password: string) {\n return signInWithEmailAndPassword(requireAuth(), email, password)\n }\n\n async function signUpWithEmail(email: string, password: string, displayName?: string) {\n const credential = await createUserWithEmailAndPassword(requireAuth(), email, password)\n if (displayName) await updateProfile(credential.user, { displayName })\n return credential\n }\n\n async function signOut() {\n await firebaseSignOut(requireAuth())\n }\n\n function canEdit(post: Pick<PostModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === post.authorUid || isAdmin.value)\n }\n\n function canDeleteComment(comment: Pick<CommentModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === comment.authorUid || isAdmin.value)\n }\n\n return {\n user,\n isSignedIn,\n isAdmin,\n rolePending,\n moderationBlockCount,\n isWriteRestricted,\n restrictedUntilLabel,\n restrictedMessage,\n signInWithGoogle,\n signInWithApple,\n signInWithEmail,\n signUpWithEmail,\n signOut,\n canEdit,\n canDelete: canEdit,\n canDeleteComment,\n recordContentModerationBlock,\n refreshBoardUser,\n }\n}\n","/**\n * 제목 문자열을 URL-friendly slug로 변환\n * e.g. \"게시판 모듈 만들기!!\" → \"게시판-모듈-만들기\"\n */\nexport function slugify(text: string): string {\n return text\n .trim()\n .replace(/\\s+/g, '-') // 공백(연속 포함) → 하이픈\n .replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, '') // 허용 문자 외 제거\n .replace(/-+/g, '-') // 연속 하이픈 → 단일 하이픈\n .replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거\n}\n\n/** Storage 경로/파일명에 안전한 문자열로 변환 (한글은 유지) */\nexport function safeFileName(name: string, maxLength = 60): string {\n return name.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, '-').slice(0, maxLength)\n}\n","import {\n collection,\n doc,\n getDoc,\n getDocs,\n setDoc,\n updateDoc,\n deleteDoc,\n query,\n where,\n orderBy,\n limit as fbLimit,\n startAfter,\n serverTimestamp,\n writeBatch,\n deleteField,\n} from 'firebase/firestore'\nimport type { QueryConstraint } from 'firebase/firestore'\nimport type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'\nimport { getStorage, ref as storageRef, listAll, deleteObject } from 'firebase/storage'\nimport type { StorageReference } from 'firebase/storage'\nimport { useFirebaseApp, useFirestore } from 'vuefire'\nimport { slugify } from '../utils/slugify'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment, PostDetail, PostModel } from '../types'\n\nasync function deleteStorageFolder(folderRef: StorageReference): Promise<void> {\n const list = await listAll(folderRef).catch(() => ({ items: [], prefixes: [] }))\n await Promise.all([\n ...list.items.map(item => deleteObject(item)),\n ...list.prefixes.map(prefix => deleteStorageFolder(prefix)),\n ])\n}\n\nexport interface CreatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n moderationModel?: string\n}\n\nexport interface UpdatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n}\n\n/** 목록 조회는 posts/{id} 메타만 읽고, 본문(body/main)은 상세 조회 시에만 읽는다. */\nexport function useMemiBoardPosts() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const app = useFirebaseApp()\n // prefix 는 setup 시점에 고정하지 않는다 (configure 이전 호출·이중 인스턴스 대비)\n const prefix = () => config.collectionPrefix\n\n const postsCol = () => collection(db, `${prefix()}Posts`)\n const postDoc = (id: string) => doc(db, `${prefix()}Posts`, id)\n const bodyDoc = (id: string) => doc(db, `${prefix()}Posts`, id, 'body', 'main')\n const commentsCol = (id: string) => collection(db, `${prefix()}Posts`, id, 'comments')\n\n async function getPosts(opts: {\n pageSize?: number\n cursor?: QueryDocumentSnapshot<DocumentData>\n /** 지정 시 해당 카테고리 글만 (boardSettings 의 category id) */\n category?: string\n } = {}) {\n const pageSize = opts.pageSize ?? 20\n const constraints: QueryConstraint[] = []\n if (opts.category) {\n // 복합 인덱스: category ASC + createdAt DESC (호스트 firestore.indexes.json)\n constraints.push(where('category', '==', opts.category))\n }\n constraints.push(orderBy('createdAt', 'desc'))\n if (opts.cursor) constraints.push(startAfter(opts.cursor))\n constraints.push(fbLimit(pageSize + 1))\n\n const snapshot = await getDocs(query(postsCol(), ...constraints))\n const docs = snapshot.docs.slice(0, pageSize)\n return {\n posts: docs.map(d => ({ id: d.id, ...d.data() }) as PostModel),\n cursor: docs[docs.length - 1] as QueryDocumentSnapshot<DocumentData> | undefined,\n hasMore: snapshot.docs.length > pageSize,\n }\n }\n\n async function getPost(id: string): Promise<PostDetail | null> {\n const [metaSnap, bodySnap] = await Promise.all([getDoc(postDoc(id)), getDoc(bodyDoc(id))])\n if (!metaSnap.exists()) return null\n return {\n id: metaSnap.id,\n ...metaSnap.data(),\n content: bodySnap.exists() ? ((bodySnap.data() as { content: string }).content ?? '') : '',\n } as PostDetail\n }\n\n async function createPost(input: CreatePostInput): Promise<string> {\n const baseSlug = slugify(input.title) || 'post'\n let slug = baseSlug\n let counter = 1\n while ((await getDoc(postDoc(slug))).exists()) {\n counter++\n slug = `${baseSlug}-${counter}`\n }\n\n await Promise.all([\n setDoc(postDoc(slug), {\n title: input.title,\n tags: input.tags ?? [],\n ...(input.category ? { category: input.category } : {}),\n attachments: input.attachments ?? [],\n commentCount: 0,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n moderationModel: input.moderationModel ?? null,\n createdAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(slug), { content: input.content }),\n ])\n\n return slug\n }\n\n async function updatePost(id: string, input: UpdatePostInput): Promise<void> {\n // 메타·본문을 같이 갱신. category 미선택 시 필드 제거(부분 갱신으로 예전 값이 남는 것 방지).\n await Promise.all([\n updateDoc(postDoc(id), {\n title: input.title,\n tags: input.tags ?? [],\n category: input.category ? input.category : deleteField(),\n attachments: input.attachments ?? [],\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(id), { content: input.content }, { merge: true }),\n ])\n }\n\n /** 댓글 서브컬렉션 → 본문 → Storage 폴더 → 메타 순으로 삭제한다. */\n async function deletePost(id: string): Promise<void> {\n const commentsSnap = await getDocs(commentsCol(id))\n // Firestore write batch는 최대 500건. 여유를 두고 450건씩 나눠 삭제한다.\n for (let offset = 0; offset < commentsSnap.docs.length; offset += 450) {\n const batch = writeBatch(db)\n commentsSnap.docs.slice(offset, offset + 450).forEach(d => batch.delete(d.ref))\n await batch.commit()\n }\n\n const storage = getStorage(app)\n // Firestore/Storage 규칙이 부모 게시글의 소유권을 조회하므로 부모 문서는 마지막에 삭제한다.\n await deleteDoc(bodyDoc(id))\n await deleteStorageFolder(storageRef(storage, `${prefix()}/posts/${id}`))\n await deleteDoc(postDoc(id))\n }\n\n return { getPosts, getPost, createPost, updatePost, deletePost }\n}\n","import { computed } from 'vue'\nimport type { Ref } from 'vue'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { collection, doc, orderBy, query, serverTimestamp, writeBatch, increment } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport type { CommentModel } from '../types'\n\nexport interface AddCommentInput {\n body: string\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n}\n\n/** postId 서브컬렉션 댓글을 실시간(onSnapshot)으로 구독하고, 작성/삭제 시 부모 글의 commentCount를 같은 batch로 증감한다. */\nexport function useMemiBoardComments(postId: string | Ref<string>) {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const prefix = () => config.collectionPrefix\n\n const id = computed(() => (typeof postId === 'string' ? postId : postId.value))\n\n const commentsQuery = computed(() =>\n query(collection(db, `${prefix()}Posts`, id.value, 'comments'), orderBy('createdAt', 'asc')),\n )\n\n const comments = useCollection<CommentModel>(commentsQuery)\n\n async function addComment(input: AddCommentInput): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n const commentRef = doc(collection(db, `${p}Posts`, id.value, 'comments'))\n batch.set(commentRef, {\n postId: id.value,\n body: input.body,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n createdAt: serverTimestamp(),\n })\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(1) })\n await batch.commit()\n }\n\n async function deleteComment(commentId: string): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n batch.delete(doc(db, `${p}Posts`, id.value, 'comments', commentId))\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(-1) })\n await batch.commit()\n }\n\n return { comments, addComment, deleteComment }\n}\n","export const MODERATION_SYSTEM = `당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category를 고르고, reason은 한국어로 **무엇이 문제인지** 구체적으로 1문장\n (예: \"욕설(비속어)이 포함되어 게시할 수 없습니다.\", \"광고·스팸성 문구가 있어 게시할 수 없습니다.\")\n 욕설 단어 전체를 그대로 길게 인용하지 말고, 유형(욕설/혐오/스팸 등)을 분명히 밝힌다.\n- flagged=false면 category는 \"none\", reason은 \"\"`\n\n/** 사용자 안내용 카테고리 라벨 */\nexport const MODERATION_CATEGORY_LABELS: Record<string, string> = {\n abuse: '욕설·비속어·모욕',\n spam: '스팸·광고',\n adult: '선정적·성인 내용',\n violence: '폭력·위협',\n other: '부적절한 내용',\n none: '',\n}\n\n/** 차단 사유를 사용자에게 읽기 쉽게 정리 */\nexport function formatModerationUserReason(opts: {\n reason?: string\n category?: string\n via?: string\n localHit?: string\n}): string {\n if (opts.via === 'local' && opts.localHit) {\n const hit = opts.localHit.trim()\n // 짧은 금칙어만 표시 (너무 길면 유형만)\n if (hit.length > 0 && hit.length <= 12) {\n return `「${hit}」 같은 표현은 사용할 수 없습니다. 다른 말로 바꿔 주세요.`\n }\n return '욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.'\n }\n\n const raw = (opts.reason || '').trim()\n if (raw) return raw\n\n const cat = opts.category && opts.category !== 'none'\n ? MODERATION_CATEGORY_LABELS[opts.category] || opts.category\n : ''\n if (cat) return `${cat}이(가) 포함되어 게시할 수 없습니다. 내용을 수정해 주세요.`\n return '게시할 수 없는 내용이 포함되어 있습니다. 내용을 수정해 주세요.'\n}\n\n/** API 없이도 1차로 걸러내는 한국어/영어 비속어 (부분 문자열 매칭) */\nexport const DEFAULT_LOCAL_BLOCKLIST = [\n '씨발', '시발', '시이발', '씨팔', '시팔', '병신', '븅신', '지랄', '좆', '존나',\n '개새끼', '개새', '새끼', '꺼져', '닥쳐', '씹', '니미', '느금', '애미', '애비',\n 'fuck', 'shit', 'bitch', 'asshole',\n]\n\nexport function buildLocalBlockRegex(patterns: string[]): RegExp {\n return new RegExp(patterns.join('|'), 'i')\n}\n\nconst CATEGORIES = new Set(['none', 'abuse', 'spam', 'adult', 'violence', 'other'])\n\nexport function parseModerationJson(raw: string): { flagged: boolean, category: string, reason: string } | null {\n const trimmed = raw.trim()\n if (!trimmed) return null\n\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n }\n catch {\n const match = trimmed.match(/\\{[\\s\\S]*\\}/)\n if (!match) return null\n try {\n parsed = JSON.parse(match[0])\n }\n catch {\n return null\n }\n }\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n if (typeof obj.flagged !== 'boolean') return null\n\n const category = typeof obj.category === 'string' && CATEGORIES.has(obj.category) ? obj.category : (obj.flagged ? 'other' : 'none')\n const reason = typeof obj.reason === 'string' ? obj.reason : ''\n return { flagged: obj.flagged, category, reason }\n}\n","import { getAI, getGenerativeModel, GoogleAIBackend, Schema } from 'firebase/ai'\nimport { useFirebaseApp } from 'vuefire'\nimport {\n buildLocalBlockRegex,\n DEFAULT_LOCAL_BLOCKLIST,\n formatModerationUserReason,\n MODERATION_SYSTEM,\n parseModerationJson,\n} from '../utils/moderation-prompt'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport type { ModerationResult, ModerationVia } from '../types'\n\nconst APPROVED: ModerationResult = { flagged: false, category: 'none', reason: '', via: 'empty' }\n\n/**\n * 글쓰기 전 블로킹 검열.\n * 이용 제한 확인 → 로컬 비속어 → Firebase AI Logic(Gemini).\n * 콘텐츠 차단(local/ai) 시 boardUsers 경고 누적.\n */\nexport function useMemiBoardModeration() {\n const config = useMemiBoardConfig()\n const moderation = config.moderation ?? {}\n const app = useFirebaseApp()\n const {\n isWriteRestricted,\n restrictedMessage,\n recordContentModerationBlock,\n } = useMemiBoardAuth()\n\n const localBlockRe = buildLocalBlockRegex([...DEFAULT_LOCAL_BLOCKLIST, ...(moderation.localBlocklist ?? [])])\n\n function localCheck(text: string): ModerationResult | null {\n const match = text.match(localBlockRe)\n if (match) {\n const hit = match[0] || ''\n return {\n flagged: true,\n category: 'abuse',\n reason: formatModerationUserReason({ via: 'local', localHit: hit, category: 'abuse' }),\n via: 'local',\n }\n }\n return null\n }\n\n function getModerationModel() {\n const modelName = moderation.model ?? 'gemini-3.5-flash-lite'\n const useLimited = moderation.useLimitedUseAppCheckTokens !== false\n const ai = getAI(app, {\n backend: new GoogleAIBackend(),\n useLimitedUseAppCheckTokens: useLimited,\n })\n return getGenerativeModel(ai, {\n model: modelName,\n systemInstruction: MODERATION_SYSTEM,\n generationConfig: {\n temperature: 0,\n maxOutputTokens: 256,\n responseMimeType: 'application/json',\n responseSchema: Schema.object({\n properties: {\n flagged: Schema.boolean(),\n category: Schema.string(),\n reason: Schema.string(),\n },\n }),\n },\n })\n }\n\n function errorResult(): ModerationResult {\n if (moderation.onError === 'block') {\n return {\n flagged: true,\n category: 'other',\n reason: '내용 검토에 실패해 글을 등록할 수 없습니다. 잠시 후 다시 시도해 주세요.',\n error: true,\n via: 'ai-error-block',\n }\n }\n return {\n flagged: false,\n category: 'none',\n reason: '',\n error: true,\n via: 'ai-error-allow',\n }\n }\n\n /** 사유 + 경고 횟수를 한 문장으로 */\n function withUserFacingReason(result: ModerationResult, messageSuffix = ''): ModerationResult {\n const why = formatModerationUserReason({\n reason: result.reason,\n category: result.category,\n via: result.via,\n })\n // 예: 「시발」 같은 표현은… (경고 2/3)\n const reason = messageSuffix ? `${why}${messageSuffix}` : why\n return { ...result, reason }\n }\n\n /** 콘텐츠 위반(local/ai)만 경고 누적. API 오류·이용제한 자체는 제외. */\n async function applyStrikeIfContentBlock(result: ModerationResult): Promise<ModerationResult> {\n if (!result.flagged || result.error) return result\n if (result.via !== 'local' && result.via !== 'ai') return result\n try {\n const { messageSuffix } = await recordContentModerationBlock()\n return withUserFacingReason(result, messageSuffix)\n }\n catch (e) {\n console.warn('[memi-board] record moderation strike failed', e instanceof Error ? e.message : e)\n return withUserFacingReason(result)\n }\n }\n\n async function checkText(text: string): Promise<ModerationResult> {\n if (isWriteRestricted.value) {\n return {\n flagged: true,\n category: 'other',\n reason: restrictedMessage.value\n || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.',\n via: 'restricted',\n }\n }\n\n const trimmed = text.trim()\n if (!trimmed) {\n return { ...APPROVED, via: 'empty' as ModerationVia }\n }\n\n const local = localCheck(trimmed)\n if (local) return applyStrikeIfContentBlock(local)\n\n if (moderation.enabled === false) {\n return { ...APPROVED, via: 'disabled' }\n }\n\n try {\n const model = getModerationModel()\n const result = await model.generateContent(`심사할 텍스트:\\n\"\"\"${trimmed.slice(0, 4000)}\"\"\"`)\n const raw = result.response.text()\n const parsed = parseModerationJson(raw)\n if (!parsed) return errorResult()\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: formatModerationUserReason({\n reason: parsed.reason,\n category: parsed.category,\n via: 'ai',\n }),\n via: 'ai',\n }\n if (out.flagged) return applyStrikeIfContentBlock(out)\n return out\n }\n catch (e) {\n console.warn('[memi-board] moderation AI failed', e instanceof Error ? e.message : e)\n return errorResult()\n }\n }\n\n async function checkImage(file: File): Promise<ModerationResult> {\n if (isWriteRestricted.value) {\n return {\n flagged: true,\n category: 'other',\n reason: restrictedMessage.value\n || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.',\n via: 'restricted',\n }\n }\n\n if (moderation.enabled === false || !moderation.moderateImages) {\n return { ...APPROVED, via: 'disabled' }\n }\n\n try {\n const base64 = await new Promise<string>((resolve, reject) => {\n const reader = new FileReader()\n reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '')\n reader.onerror = reject\n reader.readAsDataURL(file)\n })\n\n const model = getModerationModel()\n const result = await model.generateContent([\n { inlineData: { mimeType: file.type || 'image/jpeg', data: base64 } },\n '이 이미지를 심사해 주세요.',\n ])\n const raw = result.response.text()\n const parsed = parseModerationJson(raw)\n if (!parsed) return errorResult()\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: formatModerationUserReason({\n reason: parsed.reason,\n category: parsed.category,\n via: 'ai',\n }),\n via: 'ai',\n }\n if (out.flagged) return applyStrikeIfContentBlock(out)\n return out\n }\n catch (e) {\n console.warn('[memi-board] moderation AI image failed', e instanceof Error ? e.message : e)\n return errorResult()\n }\n }\n\n return { checkText, checkImage }\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): 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('Canvas를 사용할 수 없습니다'))\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('압축 실패'))),\n 'image/jpeg',\n quality,\n )\n }\n\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n reject(new Error('이미지 로드 실패'))\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 { useMemiBoardConfig } from '../config'\nimport type { Attachment, EditorImageEntry } from '../types'\n\n/** 에디터 이미지 최대 크기 (바이트) */\nexport const EDITOR_IMAGE_MAX_BYTES = 5 * 1024 * 1024\n\nexport function useMemiBoardStorage() {\n const config = useMemiBoardConfig()\n const app = useFirebaseApp()\n const prefix = () => config.collectionPrefix\n\n /** postId는 아직 저장되지 않은 새 글이면 임시 id(예: 'new-<timestamp>')를 넘겨도 된다 — 경로 네임스페이스일 뿐이다. */\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 = `${prefix()}/posts/${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 썸네일 (shineb 동일).\n * Storage:\n * `{prefix}/posts/{postId}/images/{ts}-{name}.ext`\n * `{prefix}/posts/{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('이미지 파일만 업로드할 수 있습니다.')\n }\n if (file.size > EDITOR_IMAGE_MAX_BYTES) {\n throw new Error(`이미지는 ${EDITOR_IMAGE_MAX_BYTES / 1024 / 1024}MB 이하여야 합니다.`)\n }\n\n const storage = getStorage(app)\n const ext = (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\n const originalPath = `${prefix()}/posts/${ns}/images/${baseName}.${ext}`\n const originalRef = storageRef(storage, originalPath)\n await uploadBytes(originalRef, file, {\n contentType: file.type || 'image/png',\n })\n const originalUrl = await getDownloadURL(originalRef)\n\n const thumbBlob = await compressImage(file, { maxWidth: 400, quality: 0.8 })\n const thumbnailPath = `${prefix()}/posts/${ns}/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 return {\n uploadAttachment,\n deleteAttachment,\n uploadEditorImage,\n deleteEditorImage,\n }\n}\n","import { computed, watch } from 'vue'\nimport { useFirestore, useDocument } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport { slugify } from '../utils/slugify'\nimport type { BoardCategory, BoardSettingsModel } from '../types'\n\n/** 설정 문서가 없을 때 UI·시드에 쓰는 기본 카테고리. */\nexport const DEFAULT_CATEGORIES: BoardCategory[] = [\n { id: 'free', label: '자유' },\n { id: 'notice', label: '공지' },\n { id: 'question', label: '질문' },\n]\n\n/**\n * 게시판 설정({prefix}Settings/config) — 카테고리 옵션 목록.\n *\n * - 읽기: 전체 공개 (useDocument 실시간)\n * - 문서 없음: DEFAULT 로 보여 주고, 로그인 사용자가 처음 쓰거나 추가할 때 시드 생성\n * - 카테고리 추가: 로그인 사용자 (글쓰기 폼에서 즉시 추가)\n */\nexport function useMemiBoardSettings() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const { isSignedIn, isAdmin } = useMemiBoardAuth()\n\n const settingsRef = computed(() => doc(db, `${config.collectionPrefix}Settings`, 'config'))\n const { data: settingsDoc, pending: settingsPending } = useDocument<BoardSettingsModel>(settingsRef)\n\n const categories = computed<BoardCategory[]>(() => {\n const list = settingsDoc.value?.categories\n return list && list.length > 0 ? list : DEFAULT_CATEGORIES\n })\n\n function categoryLabel(id: string | undefined): string | undefined {\n if (!id) return undefined\n return categories.value.find(c => c.id === id)?.label ?? id\n }\n\n /** 설정 문서가 없으면 기본 카테고리로 한 번 생성. 로그인 필요. */\n async function ensureSettings(): Promise<void> {\n if (!isSignedIn.value) return\n const ref = settingsRef.value\n const snap = await getDoc(ref).catch(() => null)\n if (!snap || snap.exists()) return\n await setDoc(ref, {\n categories: DEFAULT_CATEGORIES,\n updatedAt: serverTimestamp(),\n })\n }\n\n /**\n * 카테고리 추가. label 로 id(slug) 생성, 중복 id 면 접미사.\n * @returns 추가된(또는 기존 동일 label 의) category id\n */\n async function addCategory(label: string): Promise<string> {\n const trimmed = label.trim()\n if (!trimmed) throw new Error('카테고리 이름을 입력해 주세요.')\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n\n await ensureSettings()\n\n const existing = categories.value.find(\n c => c.label === trimmed || c.id === slugify(trimmed),\n )\n if (existing) return existing.id\n\n let id = slugify(trimmed) || `cat-${Date.now()}`\n const used = new Set(categories.value.map(c => c.id))\n if (used.has(id)) {\n let n = 2\n while (used.has(`${id}-${n}`)) n++\n id = `${id}-${n}`\n }\n\n const next: BoardCategory[] = [...categories.value, { id, label: trimmed }]\n const ref = settingsRef.value\n const snap = await getDoc(ref)\n if (snap.exists()) {\n await updateDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n else {\n await setDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n return id\n }\n\n // 관리자 방문 시 문서 없으면 시드 (기존 동작 유지 + 로그인 일반 사용자 ensure 와 병행)\n watch(\n [isAdmin, isSignedIn, settingsPending],\n async ([admin, signedIn, pending]) => {\n if (pending || settingsDoc.value) return\n if (!admin && !signedIn) return\n await ensureSettings().catch(() => {})\n },\n { immediate: true },\n )\n\n return {\n categories,\n categoryLabel,\n settingsPending,\n ensureSettings,\n addCategory,\n DEFAULT_CATEGORIES,\n }\n}\n","export interface VersionHistoryEntry {\n version: string\n date: string\n highlights: string[]\n note?: string\n}\n\n/**\n * MemiBoardVersionHistory 컴포넌트가 그대로 보여주는 데이터.\n * 코드 수준의 자세한 변경 내역은 이 레포의 CHANGELOG.md를 참고 — 새 버전을 릴리스할 때 이 배열도 함께 갱신한다.\n */\nexport const versionHistory: VersionHistoryEntry[] = [\n {\n version: '0.2.2',\n date: '2026-07-31',\n highlights: [\n '카테고리 — 글을 주제별로 분류해서 올릴 수 있어요',\n ],\n },\n {\n version: '0.2.0',\n date: '2026-07-31',\n highlights: [\n '글쓰기 / 수정 / 삭제 — 제목, 본문, 태그를 자유롭게 작성',\n '파일 첨부 — 이미지·문서를 게시글에 첨부, 이미지는 미리보기 제공',\n '댓글 — 실시간으로 댓글을 달고 지울 수 있음',\n '로그인 / 권한 — 로그인해야 글을 쓸 수 있고, 본인 글만 수정·삭제 가능(관리자는 전체 관리)',\n 'AI 자동 검열 — 욕설·부적절한 내용이 담긴 글/댓글은 AI가 자동으로 걸러 게시를 막음',\n '한 줄 설정으로 바로 시작 — pages: true 옵션 하나로 게시판 화면이 바로 만들어짐',\n ],\n note: '서버 없이 동작하는 모듈이라, 실제 보안은 Firebase 프로젝트에 규칙 파일을 등록해야 완성됩니다. README의 Security Model 안내를 꼭 따라주세요.',\n },\n {\n version: '0.1.0',\n date: '2026-07-27',\n highlights: [\n '프로젝트 최초 생성 (아직 게시판 기능은 없는 빈 Nuxt 4 프로젝트)',\n ],\n },\n]\n","import type { Timestamp } from 'firebase/firestore'\n\nexport function formatDate(ts: Timestamp | undefined): string {\n return ts?.toDate ? ts.toDate().toLocaleDateString('ko-KR') : ''\n}\n","/**\n * 에디터 본문(markdown 또는 html)에서 이미지 URL 추출.\n * 고아 파일 정리 스케줄러·저장 시 참조 비교에 사용.\n */\nexport function extractEditorImageUrls(content: string): string[] {\n if (!content) return []\n const urls = new Set<string>()\n\n for (const m of content.matchAll(/!\\[[^\\]]*]\\((https?:\\/\\/[^)\\s]+)\\)/g)) {\n if (m[1]) urls.add(m[1].replaceAll('&amp;', '&'))\n }\n for (const m of content.matchAll(/<img[^>]*\\ssrc=[\"'](https?:\\/\\/[^\"']+)[\"']/gi)) {\n if (m[1]) urls.add(m[1].replaceAll('&amp;', '&'))\n }\n\n return [...urls]\n}\n","/**\n * 게시글 상세용 가벼운 마크다운 → HTML (TipTap 없이).\n * 목록 이동 시 UEditor destroy/recreate 로 나는\n * \"Adding different instances of a keyed plugin\" 를 피한다.\n * 신뢰된 작성자 콘텐츠 전제 — 기본 이스케이프 후 허용 패턴만 복원.\n */\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n}\n\n/** 인라인 마크다운 (이미 escape 된 문자열에 적용) */\nfunction formatInline(s: string): string {\n let out = s\n // code\n out = out.replace(/`([^`]+)`/g, '<code>$1</code>')\n // images ![alt](url) — url 은 http(s) 만\n out = out.replace(\n /!\\[([^\\]]*)\\]\\((https?:\\/\\/[^)\\s]+)\\)/g,\n '<img src=\"$2\" alt=\"$1\" class=\"max-w-full h-auto rounded-lg my-3\" loading=\"lazy\" />',\n )\n // links [text](url)\n out = out.replace(\n /\\[([^\\]]+)\\]\\((https?:\\/\\/[^)\\s]+)\\)/g,\n '<a href=\"$2\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-primary underline\">$1</a>',\n )\n // bold ** ** 먼저\n out = out.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')\n // italic * * (bold 처리 후 남은 단일 *)\n out = out.replace(/\\*([^*]+)\\*/g, '<em>$1</em>')\n // strikethrough ~~\n out = out.replace(/~~([^~]+)~~/g, '<del>$1</del>')\n return out\n}\n\n/**\n * GFM-ish 최소 변환. 코드 펜스·목록·제목·인용·hr·단락.\n */\nexport function renderMarkdownToHtml(markdown: string): string {\n if (!markdown?.trim()) return ''\n\n const lines = markdown.replace(/\\r\\n/g, '\\n').split('\\n')\n const blocks: string[] = []\n let i = 0\n let inCode = false\n let codeLang = ''\n let codeBuf: string[] = []\n let listType: 'ul' | 'ol' | null = null\n let listItems: string[] = []\n\n function flushList() {\n if (!listType || !listItems.length) {\n listType = null\n listItems = []\n return\n }\n const tag = listType\n blocks.push(`<${tag}>${listItems.map(li => `<li>${formatInline(li)}</li>`).join('')}</${tag}>`)\n listType = null\n listItems = []\n }\n\n while (i < lines.length) {\n const line = lines[i] ?? ''\n\n // fenced code\n const fence = line.match(/^```(\\w*)\\s*$/)\n if (fence) {\n if (inCode) {\n blocks.push(\n `<pre class=\"overflow-x-auto rounded-lg bg-muted p-3 text-sm\"><code>${codeBuf.join('\\n')}</code></pre>`,\n )\n codeBuf = []\n inCode = false\n codeLang = ''\n }\n else {\n flushList()\n inCode = true\n codeLang = fence[1] || ''\n void codeLang\n }\n i++\n continue\n }\n if (inCode) {\n codeBuf.push(escapeHtml(line))\n i++\n continue\n }\n\n // hr\n if (/^(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {\n flushList()\n blocks.push('<hr class=\"my-4 border-default\" />')\n i++\n continue\n }\n\n // heading\n const h = line.match(/^(#{1,3})\\s+(.+)$/)\n if (h) {\n flushList()\n const level = h[1]!.length\n blocks.push(`<h${level} class=\"font-semibold mt-4 mb-2\">${formatInline(escapeHtml(h[2]!))}</h${level}>`)\n i++\n continue\n }\n\n // blockquote\n if (line.startsWith('> ')) {\n flushList()\n const quoteLines: string[] = []\n while (i < lines.length && (lines[i] ?? '').startsWith('> ')) {\n quoteLines.push(escapeHtml((lines[i] ?? '').slice(2)))\n i++\n }\n blocks.push(\n `<blockquote class=\"border-l-4 border-default pl-3 text-muted my-3\">${formatInline(quoteLines.join('<br />'))}</blockquote>`,\n )\n continue\n }\n\n // unordered list\n const ul = line.match(/^[-*+]\\s+(.+)$/)\n if (ul) {\n if (listType && listType !== 'ul') flushList()\n listType = 'ul'\n listItems.push(escapeHtml(ul[1]!))\n i++\n continue\n }\n\n // ordered list\n const ol = line.match(/^\\d+\\.\\s+(.+)$/)\n if (ol) {\n if (listType && listType !== 'ol') flushList()\n listType = 'ol'\n listItems.push(escapeHtml(ol[1]!))\n i++\n continue\n }\n\n // empty line\n if (!line.trim()) {\n flushList()\n i++\n continue\n }\n\n // paragraph (merge consecutive non-empty)\n flushList()\n const para: string[] = []\n while (i < lines.length) {\n const l = lines[i] ?? ''\n if (!l.trim()) break\n if (/^(#{1,3}\\s|```|[-*+]\\s|\\d+\\.\\s|> |(-{3,}|\\*{3,})\\s*$)/.test(l)) break\n para.push(escapeHtml(l))\n i++\n }\n blocks.push(`<p class=\"my-2 leading-relaxed\">${formatInline(para.join('<br />'))}</p>`)\n }\n\n flushList()\n if (inCode && codeBuf.length) {\n blocks.push(\n `<pre class=\"overflow-x-auto rounded-lg bg-muted p-3 text-sm\"><code>${codeBuf.join('\\n')}</code></pre>`,\n )\n }\n\n return blocks.join('\\n')\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/config.ts","../src/utils/moderation-strike.ts","../src/composables/useMemiBoardAuth.ts","../src/utils/slugify.ts","../src/utils/extractEditorImageUrls.ts","../src/utils/storagePath.ts","../src/composables/useMemiBoardPosts.ts","../src/composables/useMemiBoardComments.ts","../src/utils/moderation-prompt.ts","../src/composables/useMemiBoardModeration.ts","../src/utils/compressImage.ts","../src/composables/useMemiBoardStorage.ts","../src/composables/useMemiBoardSettings.ts","../src/data/versionHistory.ts","../src/utils/formatDate.ts","../src/utils/renderMarkdown.ts"],"sourcesContent":["import { reactive } from 'vue'\n\nexport interface MemiBoardAuthOptions {\n providers?: Array<'google' | 'apple' | 'emailPassword'>\n}\n\nexport interface MemiBoardModerationOptions {\n enabled?: boolean\n model?: string\n localBlocklist?: string[]\n onError?: 'allow' | 'block'\n moderateImages?: boolean\n /** AI Logic App Check limited-use 토큰 (재생 보호). 기본 true */\n useLimitedUseAppCheckTokens?: boolean\n /**\n * 콘텐츠 검열 차단(로컬/AI flagged) 누적 횟수 이상이 되면 글·댓글 작성 제한.\n * 기본 3. 0 이하면 제한 기능 off.\n */\n blockBanThreshold?: number\n /** 경고 1회 차감 간격(ms). 기본 24시간 */\n blockBanDecayMs?: number\n}\n\nexport interface MemiBoardConfig {\n collectionPrefix: string\n auth: MemiBoardAuthOptions\n moderation: MemiBoardModerationOptions\n}\n\nconst GLOBAL_KEY = '__MEMI_BOARD_CONFIG__' as const\n\n/**\n * link:/file: 모노레포에서 Vite 가 dist 와 src 를 서로 다른 모듈 id 로 묶으면\n * 모듈 스코프 singleton 이 둘로 갈라져 configure 가 안 먹는 문제가 난다.\n * globalThis 에 한 번만 올려 플러그인·컴포넌트가 항상 같은 config 를 보게 한다.\n */\nfunction getSharedConfig(): MemiBoardConfig {\n const g = globalThis as typeof globalThis & {\n [GLOBAL_KEY]?: MemiBoardConfig\n }\n if (!g[GLOBAL_KEY]) {\n g[GLOBAL_KEY] = reactive<MemiBoardConfig>({\n collectionPrefix: 'memiBoard',\n auth: { providers: ['google', 'apple'] },\n moderation: {\n enabled: true,\n model: 'gemini-3.5-flash-lite',\n onError: 'allow',\n moderateImages: false,\n localBlocklist: [],\n blockBanThreshold: 3,\n blockBanDecayMs: 24 * 60 * 60 * 1000,\n },\n })\n }\n return g[GLOBAL_KEY]\n}\n\n/**\n * 앱 부팅 시(플러그인 등에서) 한 번 호출한다. 모든 요청/세션이 같은 값을 보므로\n * 서버 렌더링에서도 안전하다 — 사용자별/요청별로 달라지는 값이 아니다.\n */\nexport function configureMemiBoard(options: Partial<MemiBoardConfig>): void {\n const config = getSharedConfig()\n if (options.collectionPrefix) config.collectionPrefix = options.collectionPrefix\n if (options.auth) Object.assign(config.auth, options.auth)\n if (options.moderation) Object.assign(config.moderation, options.moderation)\n}\n\nexport function useMemiBoardConfig(): MemiBoardConfig {\n return getSharedConfig()\n}\n","/**\n * 게시판 검열 차단 누적 → 일시 이용 제한 (호스트 contentWarning 과 동일 모델).\n * - 콘텐츠 차단(로컬/AI flagged) 시 +1\n * - 유효 경고 ≥ threshold(기본 3) → 글/댓글 작성 불가\n * - 24h마다 lazy 차감 (moderationBlockAt 기준)\n */\n\nexport const DEFAULT_BLOCK_BAN_THRESHOLD = 3\nexport const DEFAULT_BLOCK_BAN_DECAY_MS = 24 * 60 * 60 * 1000\n\nexport function effectiveModerationBlockCount(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): number {\n const c = Math.max(0, Math.floor(Number(count) || 0))\n if (c <= 0) return 0\n const at = typeof blockAtMs === 'number' && Number.isFinite(blockAtMs) ? blockAtMs : null\n if (at == null) return c\n if (nowMs <= at) return c\n const decayed = Math.floor((nowMs - at) / decayMs)\n return Math.max(0, c - decayed)\n}\n\n/** 제한 해제 시각(ms). 제한 없으면 null. */\nexport function moderationWriteRestrictedUntilMs(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n threshold: number = DEFAULT_BLOCK_BAN_THRESHOLD,\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): number | null {\n const c = Math.max(0, Math.floor(Number(count) || 0))\n if (c < threshold) return null\n const at = typeof blockAtMs === 'number' && Number.isFinite(blockAtMs) ? blockAtMs : null\n if (at == null) return nowMs\n // 유효 경고가 threshold-1 이하가 되는 시점\n const until = at + (c - (threshold - 1)) * decayMs\n return until > nowMs ? until : null\n}\n\nexport function isModerationWriteRestricted(\n count: number | null | undefined,\n blockAtMs: number | null | undefined,\n nowMs: number = Date.now(),\n threshold: number = DEFAULT_BLOCK_BAN_THRESHOLD,\n decayMs: number = DEFAULT_BLOCK_BAN_DECAY_MS,\n): boolean {\n return moderationWriteRestrictedUntilMs(count, blockAtMs, nowMs, threshold, decayMs) != null\n}\n\nexport function formatRestrictedUntilLabel(untilMs: number, nowMs: number = Date.now()): string {\n const left = Math.max(0, untilMs - nowMs)\n const hours = Math.ceil(left / (60 * 60 * 1000))\n if (hours <= 0) return '곧 해제'\n if (hours < 24) return `약 ${hours}시간 후 해제`\n const days = Math.floor(hours / 24)\n const remH = hours % 24\n if (remH === 0) return `약 ${days}일 후 해제`\n return `약 ${days}일 ${remH}시간 후 해제`\n}\n\nexport function toBlockAtMs(value: unknown): number | null {\n if (value == null) return null\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'object' && value !== null && 'toMillis' in value\n && typeof (value as { toMillis: () => number }).toMillis === 'function') {\n return (value as { toMillis: () => number }).toMillis()\n }\n if (typeof value === 'object' && value !== null && 'seconds' in value) {\n const s = Number((value as { seconds: number }).seconds)\n if (Number.isFinite(s)) return s * 1000\n }\n return null\n}\n","import { ref, computed, watch } from 'vue'\nimport type { Ref, ComputedRef } from 'vue'\nimport { useFirestore, useFirebaseAuth, useCurrentUser } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, increment, serverTimestamp } from 'firebase/firestore'\nimport {\n GoogleAuthProvider,\n OAuthProvider,\n signInWithPopup,\n signInWithEmailAndPassword,\n createUserWithEmailAndPassword,\n signOut as firebaseSignOut,\n updateProfile,\n} from 'firebase/auth'\nimport type { User } from 'firebase/auth'\nimport { useMemiBoardConfig } from '../config'\nimport type { PostModel, CommentModel } from '../types'\nimport {\n DEFAULT_BLOCK_BAN_DECAY_MS,\n DEFAULT_BLOCK_BAN_THRESHOLD,\n effectiveModerationBlockCount,\n formatRestrictedUntilLabel,\n isModerationWriteRestricted,\n moderationWriteRestrictedUntilMs,\n toBlockAtMs,\n} from '../utils/moderation-strike'\n\nexport interface UseMemiBoardAuthReturn {\n user: ReturnType<typeof useCurrentUser>\n isSignedIn: ComputedRef<boolean>\n isAdmin: ComputedRef<boolean>\n rolePending: Ref<boolean>\n /** 유효 검열 차단 누적 (lazy decay 반영) */\n moderationBlockCount: ComputedRef<number>\n /** 글·댓글 작성 제한 중 */\n isWriteRestricted: ComputedRef<boolean>\n restrictedUntilLabel: ComputedRef<string | null>\n restrictedMessage: ComputedRef<string | null>\n signInWithGoogle: () => ReturnType<typeof signInWithPopup>\n signInWithApple: () => ReturnType<typeof signInWithPopup>\n signInWithEmail: (email: string, password: string) => ReturnType<typeof signInWithEmailAndPassword>\n signUpWithEmail: (email: string, password: string, displayName?: string) => ReturnType<typeof createUserWithEmailAndPassword>\n signOut: () => Promise<void>\n canEdit: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDelete: (post: Pick<PostModel, 'authorUid'>) => boolean\n canDeleteComment: (comment: Pick<CommentModel, 'authorUid'>) => boolean\n /** 콘텐츠 검열 차단 1회 기록 후 메시지 조각 반환 */\n recordContentModerationBlock: () => Promise<{\n effectiveCount: number\n restricted: boolean\n messageSuffix: string\n }>\n refreshBoardUser: () => Promise<void>\n}\n\nconst AUTH_STATE_KEY = '__MEMI_BOARD_AUTH_STATE__' as const\n\ntype BoardAuthShared = {\n role: Ref<'user' | 'admin'>\n rolePending: Ref<boolean>\n rawBlockCount: Ref<number>\n blockAtMs: Ref<number | null>\n watchStarted: boolean\n}\n\nfunction getSharedAuthState(): BoardAuthShared {\n const g = globalThis as typeof globalThis & { [AUTH_STATE_KEY]?: BoardAuthShared }\n if (!g[AUTH_STATE_KEY]) {\n g[AUTH_STATE_KEY] = {\n role: ref<'user' | 'admin'>('user'),\n rolePending: ref(true),\n rawBlockCount: ref(0),\n blockAtMs: ref<number | null>(null),\n watchStarted: false,\n }\n }\n return g[AUTH_STATE_KEY]\n}\n\n/**\n * 인증 상태 + 게시판 역할(role) + 검열 차단 누적.\n * role·경고 카운트는 globalThis 공유 (여러 composable 호출이 같은 상태).\n */\nexport function useMemiBoardAuth(): UseMemiBoardAuthReturn {\n const config = useMemiBoardConfig()\n const shared = getSharedAuthState()\n const db = useFirestore()\n const auth = useFirebaseAuth()\n const user = useCurrentUser()\n\n const { role, rolePending, rawBlockCount, blockAtMs } = shared\n\n function banThreshold(): number {\n const t = config.moderation?.blockBanThreshold\n if (t === 0) return 0\n return typeof t === 'number' && t > 0 ? Math.floor(t) : DEFAULT_BLOCK_BAN_THRESHOLD\n }\n\n function banDecayMs(): number {\n const d = config.moderation?.blockBanDecayMs\n return typeof d === 'number' && d > 0 ? d : DEFAULT_BLOCK_BAN_DECAY_MS\n }\n\n function roleDocRef(uid: string) {\n return doc(db, `${config.collectionPrefix}Users`, uid)\n }\n\n function applyUserData(data: Record<string, unknown> | undefined) {\n if (!data) {\n rawBlockCount.value = 0\n blockAtMs.value = null\n return\n }\n rawBlockCount.value = Math.max(0, Math.floor(Number(data.moderationBlockCount) || 0))\n blockAtMs.value = toBlockAtMs(data.moderationBlockAt)\n }\n\n async function ensureUserDoc(firebaseUser: User): Promise<'user' | 'admin'> {\n const ref = roleDocRef(firebaseUser.uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n applyUserData({ moderationBlockCount: 0 })\n return 'user'\n }\n const data = snap.data() as Record<string, unknown>\n applyUserData(data)\n await updateDoc(ref, {\n displayName: firebaseUser.displayName,\n updatedAt: serverTimestamp(),\n })\n return (data.role as 'user' | 'admin') ?? 'user'\n }\n\n async function refreshBoardUser() {\n const uid = user.value?.uid\n if (!uid) {\n applyUserData(undefined)\n return\n }\n const snap = await getDoc(roleDocRef(uid))\n applyUserData(snap.exists() ? snap.data() as Record<string, unknown> : undefined)\n }\n\n if (!shared.watchStarted) {\n shared.watchStarted = true\n watch(\n user,\n async (current) => {\n rolePending.value = true\n if (!current) {\n role.value = 'user'\n applyUserData(undefined)\n rolePending.value = false\n return\n }\n try {\n role.value = await ensureUserDoc(current)\n }\n catch {\n role.value = 'user'\n }\n finally {\n rolePending.value = false\n }\n },\n { immediate: true },\n )\n }\n\n const isSignedIn = computed(() => !!user.value)\n const isAdmin = computed(() => role.value === 'admin')\n\n const moderationBlockCount = computed(() =>\n effectiveModerationBlockCount(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n banDecayMs(),\n ),\n )\n\n const isWriteRestricted = computed(() => {\n const th = banThreshold()\n if (th <= 0) return false\n return isModerationWriteRestricted(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n th,\n banDecayMs(),\n )\n })\n\n const restrictedUntilLabel = computed(() => {\n const th = banThreshold()\n if (th <= 0) return null\n const until = moderationWriteRestrictedUntilMs(\n rawBlockCount.value,\n blockAtMs.value,\n Date.now(),\n th,\n banDecayMs(),\n )\n return until != null ? formatRestrictedUntilLabel(until) : null\n })\n\n const restrictedMessage = computed(() => {\n if (!isWriteRestricted.value) return null\n const until = restrictedUntilLabel.value\n return until\n ? `콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요. (${until})`\n : '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.'\n })\n\n async function recordContentModerationBlock() {\n const uid = user.value?.uid\n const threshold = banThreshold()\n if (!uid || threshold <= 0) {\n return { effectiveCount: 0, restricted: false, messageSuffix: '' }\n }\n\n const ref = roleDocRef(uid)\n const snap = await getDoc(ref)\n if (!snap.exists()) {\n await setDoc(ref, {\n role: 'user',\n displayName: user.value?.displayName ?? null,\n moderationBlockCount: 1,\n moderationBlockAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n })\n }\n else {\n await updateDoc(ref, {\n moderationBlockCount: increment(1),\n moderationBlockAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n })\n }\n\n // increment 직후 getDoc 이 로컬 캐시로 바로 반영됨\n const nextCount = (snap.exists()\n ? Math.max(0, Math.floor(Number((snap.data() as Record<string, unknown>).moderationBlockCount) || 0))\n : 0) + 1\n const nextAt = Date.now()\n rawBlockCount.value = nextCount\n blockAtMs.value = nextAt\n\n const effectiveCount = effectiveModerationBlockCount(\n nextCount,\n nextAt,\n nextAt,\n banDecayMs(),\n )\n const restricted = isModerationWriteRestricted(\n nextCount,\n nextAt,\n nextAt,\n threshold,\n banDecayMs(),\n )\n // 앞 사유 문장과 이어 붙일 접미 (공백·구분자 포함)\n let messageSuffix = ` (경고 ${effectiveCount}/${threshold})`\n if (restricted) {\n const until = moderationWriteRestrictedUntilMs(\n nextCount,\n nextAt,\n nextAt,\n threshold,\n banDecayMs(),\n )\n const untilLabel = until != null ? formatRestrictedUntilLabel(until, nextAt) : null\n messageSuffix = untilLabel\n ? ` 경고 ${effectiveCount}회가 되어 글·댓글 작성이 제한됩니다 (${untilLabel}).`\n : ` 경고 ${effectiveCount}회가 되어 글·댓글 작성이 제한됩니다.`\n }\n return { effectiveCount, restricted, messageSuffix }\n }\n\n function requireAuth() {\n if (!auth) throw new Error('[memi-board] Firebase Auth가 초기화되지 않았습니다.')\n return auth\n }\n\n async function signInWithGoogle() {\n return signInWithPopup(requireAuth(), new GoogleAuthProvider())\n }\n\n async function signInWithApple() {\n const provider = new OAuthProvider('apple.com')\n provider.addScope('email')\n provider.addScope('name')\n return signInWithPopup(requireAuth(), provider)\n }\n\n async function signInWithEmail(email: string, password: string) {\n return signInWithEmailAndPassword(requireAuth(), email, password)\n }\n\n async function signUpWithEmail(email: string, password: string, displayName?: string) {\n const credential = await createUserWithEmailAndPassword(requireAuth(), email, password)\n if (displayName) await updateProfile(credential.user, { displayName })\n return credential\n }\n\n async function signOut() {\n await firebaseSignOut(requireAuth())\n }\n\n function canEdit(post: Pick<PostModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === post.authorUid || isAdmin.value)\n }\n\n function canDeleteComment(comment: Pick<CommentModel, 'authorUid'>) {\n return isSignedIn.value && (user.value?.uid === comment.authorUid || isAdmin.value)\n }\n\n return {\n user,\n isSignedIn,\n isAdmin,\n rolePending,\n moderationBlockCount,\n isWriteRestricted,\n restrictedUntilLabel,\n restrictedMessage,\n signInWithGoogle,\n signInWithApple,\n signInWithEmail,\n signUpWithEmail,\n signOut,\n canEdit,\n canDelete: canEdit,\n canDeleteComment,\n recordContentModerationBlock,\n refreshBoardUser,\n }\n}\n","/**\n * 제목 문자열을 URL-friendly slug로 변환\n * e.g. \"게시판 모듈 만들기!!\" → \"게시판-모듈-만들기\"\n */\nexport function slugify(text: string): string {\n return text\n .trim()\n .replace(/\\s+/g, '-') // 공백(연속 포함) → 하이픈\n .replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ-]/g, '') // 허용 문자 외 제거\n .replace(/-+/g, '-') // 연속 하이픈 → 단일 하이픈\n .replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거\n}\n\n/** Storage 경로/파일명에 안전한 문자열로 변환 (한글은 유지) */\nexport function safeFileName(name: string, maxLength = 60): string {\n return name.replace(/[^a-zA-Z0-9가-힣ㄱ-ㅎ._-]/g, '-').slice(0, maxLength)\n}\n","/**\n * 에디터 본문(markdown 또는 html)에서 이미지 URL 추출.\n * 고아 파일 정리 스케줄러·저장 시 참조 비교에 사용.\n */\nexport function extractEditorImageUrls(content: string): string[] {\n if (!content) return []\n const urls = new Set<string>()\n\n for (const m of content.matchAll(/!\\[[^\\]]*]\\((https?:\\/\\/[^)\\s]+)\\)/g)) {\n if (m[1]) urls.add(m[1].replaceAll('&amp;', '&'))\n }\n for (const m of content.matchAll(/<img[^>]*\\ssrc=[\"'](https?:\\/\\/[^\"']+)[\"']/gi)) {\n if (m[1]) urls.add(m[1].replaceAll('&amp;', '&'))\n }\n\n return [...urls]\n}\n","/**\n * Firebase Storage download URL → object path.\n * 예: .../o/board%2Fposts%2Fid%2Fimages%2Fx.png?alt=media → board/posts/id/images/x.png\n */\nexport function storagePathFromDownloadUrl(url: string): string | null {\n try {\n const m = url.match(/\\/o\\/([^?]+)/)\n if (!m?.[1]) return null\n return decodeURIComponent(m[1])\n }\n catch {\n return null\n }\n}\n\n/** path 에서 posts/{ns}/... 의 ns 추출 */\nexport function postNamespaceFromStoragePath(path: string): string | null {\n const m = path.match(/(?:^|\\/)posts\\/([^/]+)\\//)\n return m?.[1] ?? null\n}\n","import {\n collection,\n doc,\n getDoc,\n getDocs,\n setDoc,\n updateDoc,\n deleteDoc,\n query,\n where,\n orderBy,\n limit as fbLimit,\n startAfter,\n serverTimestamp,\n writeBatch,\n deleteField,\n} from 'firebase/firestore'\nimport type { QueryConstraint } from 'firebase/firestore'\nimport type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore'\nimport { getStorage, ref as storageRef, listAll, deleteObject } from 'firebase/storage'\nimport type { StorageReference } from 'firebase/storage'\nimport { useFirebaseApp, useFirestore } from 'vuefire'\nimport { slugify } from '../utils/slugify'\nimport { extractEditorImageUrls } from '../utils/extractEditorImageUrls'\nimport { postNamespaceFromStoragePath, storagePathFromDownloadUrl } from '../utils/storagePath'\nimport { useMemiBoardConfig } from '../config'\nimport type { Attachment, PostDetail, PostModel } from '../types'\n\nasync function deleteStorageFolder(folderRef: StorageReference): Promise<void> {\n const list = await listAll(folderRef).catch(() => ({ items: [], prefixes: [] }))\n await Promise.all([\n ...list.items.map(item => deleteObject(item)),\n ...list.prefixes.map(prefix => deleteStorageFolder(prefix)),\n ])\n}\n\nasync function deleteStoragePaths(storage: ReturnType<typeof getStorage>, paths: Iterable<string>): Promise<void> {\n const unique = [...new Set([...paths].filter(Boolean))]\n await Promise.all(\n unique.map(path => deleteObject(storageRef(storage, path)).catch(() => {})),\n )\n}\n\nexport interface CreatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n moderationModel?: string\n}\n\nexport interface UpdatePostInput {\n title: string\n content: string\n tags?: string[]\n category?: string\n attachments?: Attachment[]\n}\n\n/** 목록 조회는 posts/{id} 메타만 읽고, 본문(body/main)은 상세 조회 시에만 읽는다. */\nexport function useMemiBoardPosts() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const app = useFirebaseApp()\n // prefix 는 setup 시점에 고정하지 않는다 (configure 이전 호출·이중 인스턴스 대비)\n const prefix = () => config.collectionPrefix\n\n const postsCol = () => collection(db, `${prefix()}Posts`)\n const postDoc = (id: string) => doc(db, `${prefix()}Posts`, id)\n const bodyDoc = (id: string) => doc(db, `${prefix()}Posts`, id, 'body', 'main')\n const commentsCol = (id: string) => collection(db, `${prefix()}Posts`, id, 'comments')\n\n async function getPosts(opts: {\n pageSize?: number\n cursor?: QueryDocumentSnapshot<DocumentData>\n /** 지정 시 해당 카테고리 글만 (boardSettings 의 category id) */\n category?: string\n } = {}) {\n const pageSize = opts.pageSize ?? 20\n const constraints: QueryConstraint[] = []\n if (opts.category) {\n // 복합 인덱스: category ASC + createdAt DESC (호스트 firestore.indexes.json)\n constraints.push(where('category', '==', opts.category))\n }\n constraints.push(orderBy('createdAt', 'desc'))\n if (opts.cursor) constraints.push(startAfter(opts.cursor))\n constraints.push(fbLimit(pageSize + 1))\n\n const snapshot = await getDocs(query(postsCol(), ...constraints))\n const docs = snapshot.docs.slice(0, pageSize)\n return {\n posts: docs.map(d => ({ id: d.id, ...d.data() }) as PostModel),\n cursor: docs[docs.length - 1] as QueryDocumentSnapshot<DocumentData> | undefined,\n hasMore: snapshot.docs.length > pageSize,\n }\n }\n\n async function getPost(id: string): Promise<PostDetail | null> {\n const [metaSnap, bodySnap] = await Promise.all([getDoc(postDoc(id)), getDoc(bodyDoc(id))])\n if (!metaSnap.exists()) return null\n return {\n id: metaSnap.id,\n ...metaSnap.data(),\n content: bodySnap.exists() ? ((bodySnap.data() as { content: string }).content ?? '') : '',\n } as PostDetail\n }\n\n async function createPost(input: CreatePostInput): Promise<string> {\n const baseSlug = slugify(input.title) || 'post'\n let slug = baseSlug\n let counter = 1\n while ((await getDoc(postDoc(slug))).exists()) {\n counter++\n slug = `${baseSlug}-${counter}`\n }\n\n await Promise.all([\n setDoc(postDoc(slug), {\n title: input.title,\n tags: input.tags ?? [],\n ...(input.category ? { category: input.category } : {}),\n attachments: input.attachments ?? [],\n commentCount: 0,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n moderationModel: input.moderationModel ?? null,\n createdAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(slug), { content: input.content }),\n ])\n\n return slug\n }\n\n async function updatePost(id: string, input: UpdatePostInput): Promise<void> {\n // 메타·본문을 같이 갱신. category 미선택 시 필드 제거(부분 갱신으로 예전 값이 남는 것 방지).\n await Promise.all([\n updateDoc(postDoc(id), {\n title: input.title,\n tags: input.tags ?? [],\n category: input.category ? input.category : deleteField(),\n attachments: input.attachments ?? [],\n updatedAt: serverTimestamp(),\n }),\n setDoc(bodyDoc(id), { content: input.content }, { merge: true }),\n ])\n }\n\n /**\n * 글 삭제 순서 (규칙: Storage 삭제는 부모 post 문서가 있을 때 소유권 검사 가능)\n * 1) 메타·본문 읽어 첨부 path / 본문 이미지 URL 수집\n * 2) 댓글 전부\n * 3) 본문 body\n * 4) Storage: posts/{id}/** (images + attachments)\n * 5) Storage: 임시 네임스페이스(new-*) 등 첨부·본문에 남은 경로\n * 6) 메타 post 문서\n */\n async function deletePost(id: string): Promise<void> {\n const [metaSnap, bodySnap] = await Promise.all([\n getDoc(postDoc(id)),\n getDoc(bodyDoc(id)),\n ])\n if (!metaSnap.exists()) return\n\n const meta = metaSnap.data() as PostModel\n const bodyContent = bodySnap.exists()\n ? String((bodySnap.data() as { content?: string }).content ?? '')\n : ''\n\n // ── 댓글 ──────────────────────────────────────────\n const commentsSnap = await getDocs(commentsCol(id))\n for (let offset = 0; offset < commentsSnap.docs.length; offset += 450) {\n const batch = writeBatch(db)\n commentsSnap.docs.slice(offset, offset + 450).forEach(d => batch.delete(d.ref))\n await batch.commit()\n }\n\n // ── Storage 정리용 path 수집 (post 문서 삭제 전) ──\n const storage = getStorage(app)\n const extraPaths = new Set<string>()\n const extraNamespaces = new Set<string>()\n\n for (const att of meta.attachments ?? []) {\n if (att?.path) {\n extraPaths.add(att.path)\n const ns = postNamespaceFromStoragePath(att.path)\n if (ns && ns !== id) extraNamespaces.add(ns)\n }\n }\n\n for (const url of extractEditorImageUrls(bodyContent)) {\n const path = storagePathFromDownloadUrl(url)\n if (!path) continue\n extraPaths.add(path)\n // 썸네일 쌍 (images/foo.png → images/thumbnails/foo.jpg 추정)\n const thumbGuess = path\n .replace(/\\/images\\/([^/]+)$/, '/images/thumbnails/$1')\n .replace(/\\.[^.]+$/, '.jpg')\n if (thumbGuess !== path && thumbGuess.includes('/images/thumbnails/')) {\n extraPaths.add(thumbGuess)\n }\n // 원본이 thumbnails 가 아니고 basename 이 다른 확장자인 경우 동일 basename .jpg\n const m = path.match(/^(.*\\/images\\/)([^/]+)\\.[^.]+$/)\n if (m && !path.includes('/thumbnails/')) {\n extraPaths.add(`${m[1]}thumbnails/${m[2]}.jpg`)\n }\n const ns = postNamespaceFromStoragePath(path)\n if (ns && ns !== id) extraNamespaces.add(ns)\n }\n\n // ── 본문 (규칙: body write 는 부모 post 소유권) ──\n await deleteDoc(bodyDoc(id))\n\n // ── Storage: 최종 id 폴더 전체 (images/**, attachments/**) ──\n await deleteStorageFolder(storageRef(storage, `${prefix()}/posts/${id}`))\n\n // ── 임시 네임스페이스 폴더 (작성 중 new-ts 에 올린 파일) ──\n for (const ns of extraNamespaces) {\n await deleteStorageFolder(storageRef(storage, `${prefix()}/posts/${ns}`))\n }\n\n // ── 폴더 삭제에 안 잡힌 개별 path (다른 prefix 등) ──\n await deleteStoragePaths(storage, extraPaths)\n\n // ── 메타 마지막 ──\n await deleteDoc(postDoc(id))\n }\n\n return { getPosts, getPost, createPost, updatePost, deletePost }\n}\n","import { computed } from 'vue'\nimport type { Ref } from 'vue'\nimport { useCollection, useFirestore } from 'vuefire'\nimport { collection, doc, orderBy, query, serverTimestamp, writeBatch, increment } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport type { CommentModel } from '../types'\n\nexport interface AddCommentInput {\n body: string\n authorUid: string\n authorName: string | null\n authorPhoto: string | null\n}\n\n/** postId 서브컬렉션 댓글을 실시간(onSnapshot)으로 구독하고, 작성/삭제 시 부모 글의 commentCount를 같은 batch로 증감한다. */\nexport function useMemiBoardComments(postId: string | Ref<string>) {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const prefix = () => config.collectionPrefix\n\n const id = computed(() => (typeof postId === 'string' ? postId : postId.value))\n\n const commentsQuery = computed(() =>\n query(collection(db, `${prefix()}Posts`, id.value, 'comments'), orderBy('createdAt', 'asc')),\n )\n\n const comments = useCollection<CommentModel>(commentsQuery)\n\n async function addComment(input: AddCommentInput): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n const commentRef = doc(collection(db, `${p}Posts`, id.value, 'comments'))\n batch.set(commentRef, {\n postId: id.value,\n body: input.body,\n authorUid: input.authorUid,\n authorName: input.authorName,\n authorPhoto: input.authorPhoto,\n moderationStatus: 'approved',\n createdAt: serverTimestamp(),\n })\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(1) })\n await batch.commit()\n }\n\n async function deleteComment(commentId: string): Promise<void> {\n const batch = writeBatch(db)\n const p = prefix()\n batch.delete(doc(db, `${p}Posts`, id.value, 'comments', commentId))\n batch.update(doc(db, `${p}Posts`, id.value), { commentCount: increment(-1) })\n await batch.commit()\n }\n\n return { comments, addComment, deleteComment }\n}\n","export const MODERATION_SYSTEM = `당신은 게시판 콘텐츠를 심사하는 엄격한 모더레이터입니다.\n아래 텍스트에 욕설·비속어·성희롱·혐오·위협·스팸·도배성 광고가 있으면 반드시 거부(flagged=true)합니다.\n\nflagged=true 예:\n- 욕설/비속어: 씨발, 시발, 병신, 지랄, 좆, 개새끼, 존나, fuck, shit, bitch 등\n- 성적 모욕, 혐오 발언, 협박\n- 게시판 주제와 무관한 도배성 광고/스팸\n\nflagged=false:\n- 게시판 주제에 맞는 정상적인 글/댓글 (강한 의견이나 정당한 비판은 허용)\n\nJSON만 출력:\n{\"flagged\":boolean,\"category\":\"none\"|\"abuse\"|\"spam\"|\"adult\"|\"violence\"|\"other\",\"reason\":string}\n- flagged=true면 category를 고르고, reason은 한국어로 **무엇이 문제인지** 구체적으로 1문장\n (예: \"욕설(비속어)이 포함되어 게시할 수 없습니다.\", \"광고·스팸성 문구가 있어 게시할 수 없습니다.\")\n 욕설 단어 전체를 그대로 길게 인용하지 말고, 유형(욕설/혐오/스팸 등)을 분명히 밝힌다.\n- flagged=false면 category는 \"none\", reason은 \"\"`\n\n/** 사용자 안내용 카테고리 라벨 */\nexport const MODERATION_CATEGORY_LABELS: Record<string, string> = {\n abuse: '욕설·비속어·모욕',\n spam: '스팸·광고',\n adult: '선정적·성인 내용',\n violence: '폭력·위협',\n other: '부적절한 내용',\n none: '',\n}\n\n/** 차단 사유를 사용자에게 읽기 쉽게 정리 */\nexport function formatModerationUserReason(opts: {\n reason?: string\n category?: string\n via?: string\n localHit?: string\n}): string {\n if (opts.via === 'local' && opts.localHit) {\n const hit = opts.localHit.trim()\n // 짧은 금칙어만 표시 (너무 길면 유형만)\n if (hit.length > 0 && hit.length <= 12) {\n return `「${hit}」 같은 표현은 사용할 수 없습니다. 다른 말로 바꿔 주세요.`\n }\n return '욕설·비속어가 포함되어 게시할 수 없습니다. 표현을 바꿔 주세요.'\n }\n\n const raw = (opts.reason || '').trim()\n if (raw) return raw\n\n const cat = opts.category && opts.category !== 'none'\n ? MODERATION_CATEGORY_LABELS[opts.category] || opts.category\n : ''\n if (cat) return `${cat}이(가) 포함되어 게시할 수 없습니다. 내용을 수정해 주세요.`\n return '게시할 수 없는 내용이 포함되어 있습니다. 내용을 수정해 주세요.'\n}\n\n/** API 없이도 1차로 걸러내는 한국어/영어 비속어 (부분 문자열 매칭) */\nexport const DEFAULT_LOCAL_BLOCKLIST = [\n '씨발', '시발', '시이발', '씨팔', '시팔', '병신', '븅신', '지랄', '좆', '존나',\n '개새끼', '개새', '새끼', '꺼져', '닥쳐', '씹', '니미', '느금', '애미', '애비',\n 'fuck', 'shit', 'bitch', 'asshole',\n]\n\nexport function buildLocalBlockRegex(patterns: string[]): RegExp {\n return new RegExp(patterns.join('|'), 'i')\n}\n\nconst CATEGORIES = new Set(['none', 'abuse', 'spam', 'adult', 'violence', 'other'])\n\nexport function parseModerationJson(raw: string): { flagged: boolean, category: string, reason: string } | null {\n const trimmed = raw.trim()\n if (!trimmed) return null\n\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n }\n catch {\n const match = trimmed.match(/\\{[\\s\\S]*\\}/)\n if (!match) return null\n try {\n parsed = JSON.parse(match[0])\n }\n catch {\n return null\n }\n }\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n if (typeof obj.flagged !== 'boolean') return null\n\n const category = typeof obj.category === 'string' && CATEGORIES.has(obj.category) ? obj.category : (obj.flagged ? 'other' : 'none')\n const reason = typeof obj.reason === 'string' ? obj.reason : ''\n return { flagged: obj.flagged, category, reason }\n}\n","import { getAI, getGenerativeModel, GoogleAIBackend, Schema } from 'firebase/ai'\nimport { useFirebaseApp } from 'vuefire'\nimport {\n buildLocalBlockRegex,\n DEFAULT_LOCAL_BLOCKLIST,\n formatModerationUserReason,\n MODERATION_SYSTEM,\n parseModerationJson,\n} from '../utils/moderation-prompt'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport type { ModerationResult, ModerationVia } from '../types'\n\nconst APPROVED: ModerationResult = { flagged: false, category: 'none', reason: '', via: 'empty' }\n\n/**\n * 글쓰기 전 블로킹 검열.\n * 이용 제한 확인 → 로컬 비속어 → Firebase AI Logic(Gemini).\n * 콘텐츠 차단(local/ai) 시 boardUsers 경고 누적.\n */\nexport function useMemiBoardModeration() {\n const config = useMemiBoardConfig()\n const moderation = config.moderation ?? {}\n const app = useFirebaseApp()\n const {\n isWriteRestricted,\n restrictedMessage,\n recordContentModerationBlock,\n } = useMemiBoardAuth()\n\n const localBlockRe = buildLocalBlockRegex([...DEFAULT_LOCAL_BLOCKLIST, ...(moderation.localBlocklist ?? [])])\n\n function localCheck(text: string): ModerationResult | null {\n const match = text.match(localBlockRe)\n if (match) {\n const hit = match[0] || ''\n return {\n flagged: true,\n category: 'abuse',\n reason: formatModerationUserReason({ via: 'local', localHit: hit, category: 'abuse' }),\n via: 'local',\n }\n }\n return null\n }\n\n function getModerationModel() {\n const modelName = moderation.model ?? 'gemini-3.5-flash-lite'\n const useLimited = moderation.useLimitedUseAppCheckTokens !== false\n const ai = getAI(app, {\n backend: new GoogleAIBackend(),\n useLimitedUseAppCheckTokens: useLimited,\n })\n return getGenerativeModel(ai, {\n model: modelName,\n systemInstruction: MODERATION_SYSTEM,\n generationConfig: {\n temperature: 0,\n maxOutputTokens: 256,\n responseMimeType: 'application/json',\n responseSchema: Schema.object({\n properties: {\n flagged: Schema.boolean(),\n category: Schema.string(),\n reason: Schema.string(),\n },\n }),\n },\n })\n }\n\n function errorResult(): ModerationResult {\n if (moderation.onError === 'block') {\n return {\n flagged: true,\n category: 'other',\n reason: '내용 검토에 실패해 글을 등록할 수 없습니다. 잠시 후 다시 시도해 주세요.',\n error: true,\n via: 'ai-error-block',\n }\n }\n return {\n flagged: false,\n category: 'none',\n reason: '',\n error: true,\n via: 'ai-error-allow',\n }\n }\n\n /** 사유 + 경고 횟수를 한 문장으로 */\n function withUserFacingReason(result: ModerationResult, messageSuffix = ''): ModerationResult {\n const why = formatModerationUserReason({\n reason: result.reason,\n category: result.category,\n via: result.via,\n })\n // 예: 「시발」 같은 표현은… (경고 2/3)\n const reason = messageSuffix ? `${why}${messageSuffix}` : why\n return { ...result, reason }\n }\n\n /** 콘텐츠 위반(local/ai)만 경고 누적. API 오류·이용제한 자체는 제외. */\n async function applyStrikeIfContentBlock(result: ModerationResult): Promise<ModerationResult> {\n if (!result.flagged || result.error) return result\n if (result.via !== 'local' && result.via !== 'ai') return result\n try {\n const { messageSuffix } = await recordContentModerationBlock()\n return withUserFacingReason(result, messageSuffix)\n }\n catch (e) {\n console.warn('[memi-board] record moderation strike failed', e instanceof Error ? e.message : e)\n return withUserFacingReason(result)\n }\n }\n\n async function checkText(text: string): Promise<ModerationResult> {\n if (isWriteRestricted.value) {\n return {\n flagged: true,\n category: 'other',\n reason: restrictedMessage.value\n || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.',\n via: 'restricted',\n }\n }\n\n const trimmed = text.trim()\n if (!trimmed) {\n return { ...APPROVED, via: 'empty' as ModerationVia }\n }\n\n const local = localCheck(trimmed)\n if (local) return applyStrikeIfContentBlock(local)\n\n if (moderation.enabled === false) {\n return { ...APPROVED, via: 'disabled' }\n }\n\n try {\n const model = getModerationModel()\n const result = await model.generateContent(`심사할 텍스트:\\n\"\"\"${trimmed.slice(0, 4000)}\"\"\"`)\n const raw = result.response.text()\n const parsed = parseModerationJson(raw)\n if (!parsed) return errorResult()\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: formatModerationUserReason({\n reason: parsed.reason,\n category: parsed.category,\n via: 'ai',\n }),\n via: 'ai',\n }\n if (out.flagged) return applyStrikeIfContentBlock(out)\n return out\n }\n catch (e) {\n console.warn('[memi-board] moderation AI failed', e instanceof Error ? e.message : e)\n return errorResult()\n }\n }\n\n async function checkImage(file: File): Promise<ModerationResult> {\n if (isWriteRestricted.value) {\n return {\n flagged: true,\n category: 'other',\n reason: restrictedMessage.value\n || '콘텐츠 경고가 누적되어 글·댓글 작성이 잠시 제한됐어요.',\n via: 'restricted',\n }\n }\n\n if (moderation.enabled === false || !moderation.moderateImages) {\n return { ...APPROVED, via: 'disabled' }\n }\n\n try {\n const base64 = await new Promise<string>((resolve, reject) => {\n const reader = new FileReader()\n reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '')\n reader.onerror = reject\n reader.readAsDataURL(file)\n })\n\n const model = getModerationModel()\n const result = await model.generateContent([\n { inlineData: { mimeType: file.type || 'image/jpeg', data: base64 } },\n '이 이미지를 심사해 주세요.',\n ])\n const raw = result.response.text()\n const parsed = parseModerationJson(raw)\n if (!parsed) return errorResult()\n const out: ModerationResult = {\n flagged: parsed.flagged,\n category: parsed.category as ModerationResult['category'],\n reason: formatModerationUserReason({\n reason: parsed.reason,\n category: parsed.category,\n via: 'ai',\n }),\n via: 'ai',\n }\n if (out.flagged) return applyStrikeIfContentBlock(out)\n return out\n }\n catch (e) {\n console.warn('[memi-board] moderation AI image failed', e instanceof Error ? e.message : e)\n return errorResult()\n }\n }\n\n return { checkText, checkImage }\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): 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('Canvas를 사용할 수 없습니다'))\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('압축 실패'))),\n 'image/jpeg',\n quality,\n )\n }\n\n img.onerror = () => {\n URL.revokeObjectURL(objectUrl)\n reject(new Error('이미지 로드 실패'))\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 { useMemiBoardConfig } from '../config'\nimport type { Attachment, EditorImageEntry } from '../types'\n\n/** 에디터 이미지 최대 크기 (바이트) */\nexport const EDITOR_IMAGE_MAX_BYTES = 5 * 1024 * 1024\n\nexport function useMemiBoardStorage() {\n const config = useMemiBoardConfig()\n const app = useFirebaseApp()\n const prefix = () => config.collectionPrefix\n\n /** postId는 아직 저장되지 않은 새 글이면 임시 id(예: 'new-<timestamp>')를 넘겨도 된다 — 경로 네임스페이스일 뿐이다. */\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 = `${prefix()}/posts/${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 썸네일 (shineb 동일).\n * Storage:\n * `{prefix}/posts/{postId}/images/{ts}-{name}.ext`\n * `{prefix}/posts/{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('이미지 파일만 업로드할 수 있습니다.')\n }\n if (file.size > EDITOR_IMAGE_MAX_BYTES) {\n throw new Error(`이미지는 ${EDITOR_IMAGE_MAX_BYTES / 1024 / 1024}MB 이하여야 합니다.`)\n }\n\n const storage = getStorage(app)\n const ext = (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\n const originalPath = `${prefix()}/posts/${ns}/images/${baseName}.${ext}`\n const originalRef = storageRef(storage, originalPath)\n await uploadBytes(originalRef, file, {\n contentType: file.type || 'image/png',\n })\n const originalUrl = await getDownloadURL(originalRef)\n\n const thumbBlob = await compressImage(file, { maxWidth: 400, quality: 0.8 })\n const thumbnailPath = `${prefix()}/posts/${ns}/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 return {\n uploadAttachment,\n deleteAttachment,\n uploadEditorImage,\n deleteEditorImage,\n }\n}\n","import { computed, watch } from 'vue'\nimport { useFirestore, useDocument } from 'vuefire'\nimport { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'\nimport { useMemiBoardConfig } from '../config'\nimport { useMemiBoardAuth } from './useMemiBoardAuth'\nimport { slugify } from '../utils/slugify'\nimport type { BoardCategory, BoardSettingsModel } from '../types'\n\n/** 설정 문서가 없을 때 UI·시드에 쓰는 기본 카테고리. */\nexport const DEFAULT_CATEGORIES: BoardCategory[] = [\n { id: 'free', label: '자유' },\n { id: 'notice', label: '공지' },\n { id: 'question', label: '질문' },\n]\n\n/**\n * 게시판 설정({prefix}Settings/config) — 카테고리 옵션 목록.\n *\n * - 읽기: 전체 공개 (useDocument 실시간)\n * - 문서 없음: DEFAULT 로 보여 주고, 로그인 사용자가 처음 쓰거나 추가할 때 시드 생성\n * - 카테고리 추가: 로그인 사용자 (글쓰기 폼에서 즉시 추가)\n */\nexport function useMemiBoardSettings() {\n const config = useMemiBoardConfig()\n const db = useFirestore()\n const { isSignedIn, isAdmin } = useMemiBoardAuth()\n\n const settingsRef = computed(() => doc(db, `${config.collectionPrefix}Settings`, 'config'))\n const { data: settingsDoc, pending: settingsPending } = useDocument<BoardSettingsModel>(settingsRef)\n\n const categories = computed<BoardCategory[]>(() => {\n const list = settingsDoc.value?.categories\n return list && list.length > 0 ? list : DEFAULT_CATEGORIES\n })\n\n function categoryLabel(id: string | undefined): string | undefined {\n if (!id) return undefined\n return categories.value.find(c => c.id === id)?.label ?? id\n }\n\n /** 설정 문서가 없으면 기본 카테고리로 한 번 생성. 로그인 필요. */\n async function ensureSettings(): Promise<void> {\n if (!isSignedIn.value) return\n const ref = settingsRef.value\n const snap = await getDoc(ref).catch(() => null)\n if (!snap || snap.exists()) return\n await setDoc(ref, {\n categories: DEFAULT_CATEGORIES,\n updatedAt: serverTimestamp(),\n })\n }\n\n /**\n * 카테고리 추가. label 로 id(slug) 생성, 중복 id 면 접미사.\n * @returns 추가된(또는 기존 동일 label 의) category id\n */\n async function addCategory(label: string): Promise<string> {\n const trimmed = label.trim()\n if (!trimmed) throw new Error('카테고리 이름을 입력해 주세요.')\n if (!isSignedIn.value) throw new Error('로그인이 필요합니다.')\n\n await ensureSettings()\n\n const existing = categories.value.find(\n c => c.label === trimmed || c.id === slugify(trimmed),\n )\n if (existing) return existing.id\n\n let id = slugify(trimmed) || `cat-${Date.now()}`\n const used = new Set(categories.value.map(c => c.id))\n if (used.has(id)) {\n let n = 2\n while (used.has(`${id}-${n}`)) n++\n id = `${id}-${n}`\n }\n\n const next: BoardCategory[] = [...categories.value, { id, label: trimmed }]\n const ref = settingsRef.value\n const snap = await getDoc(ref)\n if (snap.exists()) {\n await updateDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n else {\n await setDoc(ref, {\n categories: next,\n updatedAt: serverTimestamp(),\n })\n }\n return id\n }\n\n // 관리자 방문 시 문서 없으면 시드 (기존 동작 유지 + 로그인 일반 사용자 ensure 와 병행)\n watch(\n [isAdmin, isSignedIn, settingsPending],\n async ([admin, signedIn, pending]) => {\n if (pending || settingsDoc.value) return\n if (!admin && !signedIn) return\n await ensureSettings().catch(() => {})\n },\n { immediate: true },\n )\n\n return {\n categories,\n categoryLabel,\n settingsPending,\n ensureSettings,\n addCategory,\n DEFAULT_CATEGORIES,\n }\n}\n","export interface VersionHistoryEntry {\n version: string\n date: string\n highlights: string[]\n note?: string\n}\n\n/**\n * MemiBoardVersionHistory 컴포넌트가 그대로 보여주는 데이터.\n * 코드 수준의 자세한 변경 내역은 이 레포의 CHANGELOG.md를 참고 — 새 버전을 릴리스할 때 이 배열도 함께 갱신한다.\n */\nexport const versionHistory: VersionHistoryEntry[] = [\n {\n version: '0.2.2',\n date: '2026-07-31',\n highlights: [\n '카테고리 — 글을 주제별로 분류해서 올릴 수 있어요',\n ],\n },\n {\n version: '0.2.0',\n date: '2026-07-31',\n highlights: [\n '글쓰기 / 수정 / 삭제 — 제목, 본문, 태그를 자유롭게 작성',\n '파일 첨부 — 이미지·문서를 게시글에 첨부, 이미지는 미리보기 제공',\n '댓글 — 실시간으로 댓글을 달고 지울 수 있음',\n '로그인 / 권한 — 로그인해야 글을 쓸 수 있고, 본인 글만 수정·삭제 가능(관리자는 전체 관리)',\n 'AI 자동 검열 — 욕설·부적절한 내용이 담긴 글/댓글은 AI가 자동으로 걸러 게시를 막음',\n '한 줄 설정으로 바로 시작 — pages: true 옵션 하나로 게시판 화면이 바로 만들어짐',\n ],\n note: '서버 없이 동작하는 모듈이라, 실제 보안은 Firebase 프로젝트에 규칙 파일을 등록해야 완성됩니다. README의 Security Model 안내를 꼭 따라주세요.',\n },\n {\n version: '0.1.0',\n date: '2026-07-27',\n highlights: [\n '프로젝트 최초 생성 (아직 게시판 기능은 없는 빈 Nuxt 4 프로젝트)',\n ],\n },\n]\n","import type { Timestamp } from 'firebase/firestore'\n\nexport function formatDate(ts: Timestamp | undefined): string {\n return ts?.toDate ? ts.toDate().toLocaleDateString('ko-KR') : ''\n}\n","/**\n * 게시글 상세용 가벼운 마크다운 → HTML (TipTap 없이).\n * 목록 이동 시 UEditor destroy/recreate 로 나는\n * \"Adding different instances of a keyed plugin\" 를 피한다.\n * 신뢰된 작성자 콘텐츠 전제 — 기본 이스케이프 후 허용 패턴만 복원.\n */\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n}\n\n/** 인라인 마크다운 (이미 escape 된 문자열에 적용) */\nfunction formatInline(s: string): string {\n let out = s\n // code\n out = out.replace(/`([^`]+)`/g, '<code>$1</code>')\n // images ![alt](url) — url 은 http(s) 만\n out = out.replace(\n /!\\[([^\\]]*)\\]\\((https?:\\/\\/[^)\\s]+)\\)/g,\n '<img src=\"$2\" alt=\"$1\" class=\"max-w-full h-auto rounded-lg my-3\" loading=\"lazy\" />',\n )\n // links [text](url)\n out = out.replace(\n /\\[([^\\]]+)\\]\\((https?:\\/\\/[^)\\s]+)\\)/g,\n '<a href=\"$2\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-primary underline\">$1</a>',\n )\n // bold ** ** 먼저\n out = out.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')\n // italic * * (bold 처리 후 남은 단일 *)\n out = out.replace(/\\*([^*]+)\\*/g, '<em>$1</em>')\n // strikethrough ~~\n out = out.replace(/~~([^~]+)~~/g, '<del>$1</del>')\n return out\n}\n\n/**\n * GFM-ish 최소 변환. 코드 펜스·목록·제목·인용·hr·단락.\n */\nexport function renderMarkdownToHtml(markdown: string): string {\n if (!markdown?.trim()) return ''\n\n const lines = markdown.replace(/\\r\\n/g, '\\n').split('\\n')\n const blocks: string[] = []\n let i = 0\n let inCode = false\n let codeLang = ''\n let codeBuf: string[] = []\n let listType: 'ul' | 'ol' | null = null\n let listItems: string[] = []\n\n function flushList() {\n if (!listType || !listItems.length) {\n listType = null\n listItems = []\n return\n }\n const tag = listType\n blocks.push(`<${tag}>${listItems.map(li => `<li>${formatInline(li)}</li>`).join('')}</${tag}>`)\n listType = null\n listItems = []\n }\n\n while (i < lines.length) {\n const line = lines[i] ?? ''\n\n // fenced code\n const fence = line.match(/^```(\\w*)\\s*$/)\n if (fence) {\n if (inCode) {\n blocks.push(\n `<pre class=\"overflow-x-auto rounded-lg bg-muted p-3 text-sm\"><code>${codeBuf.join('\\n')}</code></pre>`,\n )\n codeBuf = []\n inCode = false\n codeLang = ''\n }\n else {\n flushList()\n inCode = true\n codeLang = fence[1] || ''\n void codeLang\n }\n i++\n continue\n }\n if (inCode) {\n codeBuf.push(escapeHtml(line))\n i++\n continue\n }\n\n // hr\n if (/^(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {\n flushList()\n blocks.push('<hr class=\"my-4 border-default\" />')\n i++\n continue\n }\n\n // heading\n const h = line.match(/^(#{1,3})\\s+(.+)$/)\n if (h) {\n flushList()\n const level = h[1]!.length\n blocks.push(`<h${level} class=\"font-semibold mt-4 mb-2\">${formatInline(escapeHtml(h[2]!))}</h${level}>`)\n i++\n continue\n }\n\n // blockquote\n if (line.startsWith('> ')) {\n flushList()\n const quoteLines: string[] = []\n while (i < lines.length && (lines[i] ?? '').startsWith('> ')) {\n quoteLines.push(escapeHtml((lines[i] ?? '').slice(2)))\n i++\n }\n blocks.push(\n `<blockquote class=\"border-l-4 border-default pl-3 text-muted my-3\">${formatInline(quoteLines.join('<br />'))}</blockquote>`,\n )\n continue\n }\n\n // unordered list\n const ul = line.match(/^[-*+]\\s+(.+)$/)\n if (ul) {\n if (listType && listType !== 'ul') flushList()\n listType = 'ul'\n listItems.push(escapeHtml(ul[1]!))\n i++\n continue\n }\n\n // ordered list\n const ol = line.match(/^\\d+\\.\\s+(.+)$/)\n if (ol) {\n if (listType && listType !== 'ol') flushList()\n listType = 'ol'\n listItems.push(escapeHtml(ol[1]!))\n i++\n continue\n }\n\n // empty line\n if (!line.trim()) {\n flushList()\n i++\n continue\n }\n\n // paragraph (merge consecutive non-empty)\n flushList()\n const para: string[] = []\n while (i < lines.length) {\n const l = lines[i] ?? ''\n if (!l.trim()) break\n if (/^(#{1,3}\\s|```|[-*+]\\s|\\d+\\.\\s|> |(-{3,}|\\*{3,})\\s*$)/.test(l)) break\n para.push(escapeHtml(l))\n i++\n }\n blocks.push(`<p class=\"my-2 leading-relaxed\">${formatInline(para.join('<br />'))}</p>`)\n }\n\n flushList()\n if (inCode && codeBuf.length) {\n blocks.push(\n `<pre class=\"overflow-x-auto rounded-lg bg-muted p-3 text-sm\"><code>${codeBuf.join('\\n')}</code></pre>`,\n )\n }\n\n return blocks.join('\\n')\n}\n"],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Firebase Storage download URL → object path.
3
+ * 예: .../o/board%2Fposts%2Fid%2Fimages%2Fx.png?alt=media → board/posts/id/images/x.png
4
+ */
5
+ export declare function storagePathFromDownloadUrl(url: string): string | null;
6
+ /** path 에서 posts/{ns}/... 의 ns 추출 */
7
+ export declare function postNamespaceFromStoragePath(path: string): string | null;
@@ -26,21 +26,18 @@ service firebase.storage {
26
26
 
27
27
  match /memiBoard/posts/{postId}/attachments/{fileName} {
28
28
  allow read: if true;
29
-
30
- // 새 글 작성 중에는 아직 postId 문서가 없으므로(임시 네임스페이스) 소유권을 확인할 수 없다 —
31
- // 로그인 여부만 확인한다. 저장된 글의 첨부파일 교체/삭제는 아래 delete 규칙으로 보호된다.
32
- allow create: if isSignedIn();
33
-
29
+ // uploadBytesResumable → create + 청크 update 필요 (write)
30
+ allow write: if isSignedIn()
31
+ && request.resource.size < 20 * 1024 * 1024;
34
32
  allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isAdmin());
35
33
  }
36
34
 
37
35
  // 에디터 본문 이미지 + 썸네일
38
36
  // 원본: memiBoard/posts/{postId}/images/{file}
39
37
  // 썸네일: memiBoard/posts/{postId}/images/thumbnails/{file}
40
- // 수정 중 본문에서 빠진 파일은 호스트 스케줄러로 정리 (본문 URL 미참조 path).
41
38
  match /memiBoard/posts/{postId}/images/{allPaths=**} {
42
39
  allow read: if true;
43
- allow create: if isSignedIn()
40
+ allow write: if isSignedIn()
44
41
  && request.resource.size < 5 * 1024 * 1024
45
42
  && request.resource.contentType.matches('image/(jpeg|png|webp|gif|jpg)');
46
43
  allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isAdmin());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "type": "module",
5
5
  "description": "Firebase 게시판 — core composable + thin Nuxt module(UI SFC). Nuxt 4 + Nuxt UI 호스트에 붙임",
6
6
  "license": "MIT",