memi-board 0.56.2 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +55 -9
  2. package/dist/{ar-CQhBdHu3.js → ar-GNguZBN4.js} +45 -4
  3. package/dist/ar-GNguZBN4.js.map +1 -0
  4. package/dist/{commentImages-DsW2yCqu.js → commentImages-CRK_2uVi.js} +195 -70
  5. package/dist/commentImages-CRK_2uVi.js.map +1 -0
  6. package/dist/composables/useMemiBoardModeration.d.ts +2 -0
  7. package/dist/composables/useMemiBoardPosts.d.ts +1 -1
  8. package/dist/composables/useMemiBoardReports.d.ts +102 -3
  9. package/dist/composables/useMemiBoardUserDirectory.d.ts +20 -0
  10. package/dist/composables/useMemiBoardUserProfile.d.ts +1 -1
  11. package/dist/composables/useMemiBoardUsers.d.ts +1 -1
  12. package/dist/{de-B4GZqxQj.js → de-CS0iRLqe.js} +45 -4
  13. package/dist/de-CS0iRLqe.js.map +1 -0
  14. package/dist/{en-BDkfivhc.js → en-DeExi-DR.js} +129 -4
  15. package/dist/en-DeExi-DR.js.map +1 -0
  16. package/dist/{es-DzFDaLwo.js → es-dlEFrox-.js} +45 -4
  17. package/dist/es-dlEFrox-.js.map +1 -0
  18. package/dist/{fr-Zf5mcs8m.js → fr-B9sjKJF4.js} +45 -4
  19. package/dist/fr-B9sjKJF4.js.map +1 -0
  20. package/dist/{id-CaWN8212.js → id-Ca7skouh.js} +45 -4
  21. package/dist/id-Ca7skouh.js.map +1 -0
  22. package/dist/index.d.ts +3 -1
  23. package/dist/index.js +1089 -829
  24. package/dist/index.js.map +1 -1
  25. package/dist/{ja-xDttH9CE.js → ja-ByVLGIDd.js} +45 -4
  26. package/dist/ja-ByVLGIDd.js.map +1 -0
  27. package/dist/module.mjs +4 -0
  28. package/dist/module.mjs.map +1 -1
  29. package/dist/{pt-WIfxYYWQ.js → pt-COQL_Q7A.js} +45 -4
  30. package/dist/pt-COQL_Q7A.js.map +1 -0
  31. package/dist/runtime/components/AccountDeletion.vue +64 -0
  32. package/dist/runtime/components/AuthorMenu.vue +4 -4
  33. package/dist/runtime/components/CommentFeed.vue +17 -13
  34. package/dist/runtime/components/CommentForm.vue +58 -56
  35. package/dist/runtime/components/CommentImageField.vue +18 -13
  36. package/dist/runtime/components/CommentItem.vue +95 -60
  37. package/dist/runtime/components/CommentList.vue +10 -1
  38. package/dist/runtime/components/CommentThread.vue +16 -2
  39. package/dist/runtime/components/CustomFieldEditor.vue +10 -8
  40. package/dist/runtime/components/Detail.vue +18 -10
  41. package/dist/runtime/components/Editor.vue +1 -5
  42. package/dist/runtime/components/ImageLightbox.vue +126 -58
  43. package/dist/runtime/components/LikeButton.vue +14 -11
  44. package/dist/runtime/components/ListViewSwitch.vue +10 -10
  45. package/dist/runtime/components/Profile.vue +14 -2
  46. package/dist/runtime/components/ProfileEditModal.vue +20 -3
  47. package/dist/runtime/components/ReportButton.vue +22 -13
  48. package/dist/runtime/components/ReportList.vue +77 -93
  49. package/dist/runtime/components/UserListControls.vue +29 -0
  50. package/dist/runtime/components/UserListPagination.vue +17 -0
  51. package/dist/runtime/components/UserManagementEmail.vue +21 -0
  52. package/dist/runtime/components/UserRoleGuide.vue +38 -0
  53. package/dist/runtime/components/Users.vue +21 -59
  54. package/dist/runtime/components/UsersSettings.vue +119 -0
  55. package/dist/storage.js +1 -1
  56. package/dist/types.d.ts +5 -0
  57. package/dist/utils/postSlug.d.ts +12 -0
  58. package/dist/utils/profileNameModeration.d.ts +8 -0
  59. package/dist/utils/userDirectoryPage.d.ts +19 -0
  60. package/dist/{zh-CI6Sd8PD.js → zh-DBjX9qAS.js} +45 -4
  61. package/dist/zh-DBjX9qAS.js.map +1 -0
  62. package/docs/account-deletion.md +60 -0
  63. package/docs/firebase-setup.md +77 -0
  64. package/docs/firestore.rules.example +78 -4
  65. package/docs/host-integration.md +85 -11
  66. package/docs/storage.rules.example +7 -2
  67. package/package.json +3 -2
  68. package/scripts/backfill-post-slugs.mjs +38 -0
  69. package/dist/ar-CQhBdHu3.js.map +0 -1
  70. package/dist/commentImages-DsW2yCqu.js.map +0 -1
  71. package/dist/de-B4GZqxQj.js.map +0 -1
  72. package/dist/en-BDkfivhc.js.map +0 -1
  73. package/dist/es-DzFDaLwo.js.map +0 -1
  74. package/dist/fr-Zf5mcs8m.js.map +0 -1
  75. package/dist/id-CaWN8212.js.map +0 -1
  76. package/dist/ja-xDttH9CE.js.map +0 -1
  77. package/dist/pt-WIfxYYWQ.js.map +0 -1
  78. package/dist/zh-CI6Sd8PD.js.map +0 -1
@@ -101,6 +101,10 @@ var e = {
101
101
  },
102
102
  common: {
103
103
  action: {
104
+ zoomIn: "放大",
105
+ zoomOut: "缩小",
106
+ fitImage: "适应屏幕",
107
+ originalImage: "原始大小 (100%)",
104
108
  cancel: "取消",
105
109
  save: "保存",
106
110
  saved: "已保存",
@@ -124,6 +128,7 @@ var e = {
124
128
  toTop: "顶部"
125
129
  },
126
130
  label: {
131
+ imageControls: "图片缩放控件",
127
132
  anonymous: "匿名",
128
133
  user: "用户",
129
134
  image: "图片",
@@ -458,7 +463,22 @@ var e = {
458
463
  done: "举报已提交,工作人员将进行审核。",
459
464
  reason: "原因",
460
465
  detailPlaceholder: "详细说明(选填)",
461
- submit: "举报"
466
+ submit: "举报",
467
+ status: {
468
+ open: "已受理",
469
+ dismissed: "已驳回",
470
+ actioned: "审核完成"
471
+ },
472
+ resultTitle: "举报处理状态",
473
+ result: {
474
+ open: "举报已受理,正在等待审核。",
475
+ dismissed: "审核未发现违规。",
476
+ actioned: "审核已完成,请查看下方处理理由。"
477
+ },
478
+ reviewedAt: "处理时间",
479
+ reviewNote: "处理理由",
480
+ noNote: "此历史记录未填写处理理由。",
481
+ detailRequired: "详细说明(其他原因必填)"
462
482
  },
463
483
  reportList: {
464
484
  staffOnly: "仅工作人员可查看举报列表。",
@@ -467,7 +487,22 @@ var e = {
467
487
  untitled: "无标题帖子",
468
488
  author: "作者 {name}",
469
489
  dismiss: "驳回",
470
- confirm: "已处理"
490
+ tabs: {
491
+ open: "待处理",
492
+ processed: "处理记录"
493
+ },
494
+ filterLabel: "筛选举报状态",
495
+ orderHint: "按受理时间倒序排列。",
496
+ historyEmpty: "暂无处理记录。",
497
+ confirm: "标记审核完成",
498
+ dismissTitle: "驳回举报",
499
+ resolveTitle: "完成审核",
500
+ actionHint: "仅更改举报状态,不会隐藏或删除帖子。",
501
+ publicNote: "举报者也可以看到此理由。",
502
+ reviewer: "处理人",
503
+ reviewNote: "处理理由",
504
+ unknownReviewer: "处理人未知",
505
+ actionFailed: "处理失败,请重试。"
471
506
  },
472
507
  reports: {
473
508
  reason: {
@@ -481,7 +516,13 @@ var e = {
481
516
  signInRequired: "请先登录。",
482
517
  cannotReportOwn: "不能举报自己的帖子。",
483
518
  reasonRequired: "请选择举报原因。",
484
- detailTooLong: "详细说明最多 {max} 个字。"
519
+ detailTooLong: "详细说明最多 {max} 个字。",
520
+ otherDetailRequired: "选择其他原因时请填写详细说明。",
521
+ reviewNoteRequired: "请填写处理理由。",
522
+ alreadyReviewed: "此举报已处理,请查看列表。",
523
+ manageRequired: "你无权处理此举报。",
524
+ alreadyReported: "你已举报过此帖子。",
525
+ statusLoadFailed: "无法加载举报状态,请刷新页面。"
485
526
  },
486
527
  sectionUtil: {
487
528
  kind: {
@@ -759,4 +800,4 @@ var e = {
759
800
  //#endregion
760
801
  export { e as default };
761
802
 
762
- //# sourceMappingURL=zh-CI6Sd8PD.js.map
803
+ //# sourceMappingURL=zh-DBjX9qAS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zh-DBjX9qAS.js","names":[],"sources":["../src/i18n/messages/zh.ts"],"sourcesContent":["import type { MemiBoardMessages } from '../translate'\n\n/** 짧은 화면 문구만 번역한다. 빠진 키는 en → ko 순으로 폴백된다. */\nconst messages: MemiBoardMessages = {\n attachments: {\n attach: '添加附件({count} / {max})',\n dropOrClick: '拖放文件或点击添加',\n uploading: '正在上传文件…',\n maxFiles: '最多可添加 {max} 个附件。',\n },\n auth: {\n writeRestricted: '由于内容警告累计,发帖和评论功能已被暂时限制。',\n strikeSuffix: '(警告 {count}/{threshold})',\n strikeRestricted: '警告已达 {count} 次,发帖和评论功能将被限制。',\n notInitialized: '[memi-board] Firebase Auth 尚未初始化。',\n },\n authorMemo: {\n tooLong: '备注最多 {max} 个字。',\n },\n authorMenu: {\n viewProfile: '查看资料',\n viewPosts: '查看帖子',\n viewComments: '查看评论',\n memoAriaLabel: '关于该作者的备注',\n memoTitle: '仅自己可见的备注',\n memoPlaceholder: '记下关于该作者的内容…',\n bad: '差',\n good: '好',\n },\n avatarCropModal: {\n title: '裁剪头像',\n description: '将以正方形(1:1)显示,请调整区域。',\n confirm: '裁剪并上传',\n },\n boardSettings: {\n signInRequired: '请先登录。',\n idAndNameRequired: '请输入版块 ID 和名称。',\n deleteIdRequired: '需要提供要删除的版块 ID。',\n nameRequired: '请输入版块名称。',\n },\n boardUsers: {\n unsupportedRole: '不支持该角色。',\n roleDescription: {\n admin: '管理用户角色、版块设置及所有内容。',\n user: '可发帖和评论,仅能编辑或删除自己的内容。',\n },\n },\n commentFeed: {\n empty: '暂无评论。',\n emptyLiked: '暂无被点赞的评论。',\n loadFailed: '无法加载列表:{error}',\n blinded: '该评论已被管理员屏蔽。',\n },\n commentForm: {\n translationSkipped: '评论已发布,但未翻译。你可以稍后在编辑表单中翻译。',\n hiddenBoardDenied: '隐藏版块仅限负责的工作人员评论。',\n denied: '你没有在此版块评论的权限。',\n signInRequired: '请先登录。',\n writeRestricted: '由于内容警告累计,发帖和评论功能已被暂时限制。',\n moderationBlocked: '包含无法发布的内容。',\n replyTo: '回复 {name}',\n placeholder: '输入评论',\n submitReply: '回复',\n submit: '发表评论',\n signInToComment: '登录后才能发表评论。',\n attachHint: '将图片拖到这里,或点击选择。',\n attachNote: 'jpg、png、gif、webp。过大的图片会缩小到 5MB 以下。',\n removeImage: '移除附件图片',\n imageOnly: '只能附加 jpg、png、gif、webp 图片。',\n uploadFailed: '图片上传失败。',\n uploading: '正在上传图片…',\n },\n commentItem: {\n editRestricted: '评论编辑功能已被暂时限制。',\n moderationBlocked: '包含无法发布的内容。',\n editFailed: '无法修改评论。',\n blindFailed: '无法更改屏蔽状态。',\n likeFailed: '点赞失败。',\n blinded: '该评论已被管理员屏蔽。',\n attachedImage: '评论中的图片',\n edit: '编辑评论',\n blind: '屏蔽评论',\n unblind: '取消屏蔽评论',\n delete: '删除评论',\n },\n commentList: {\n loading: '正在加载评论',\n empty: '暂无评论。',\n loadMore: '更多评论',\n loadingMore: '正在加载更多评论',\n },\n commentThread: {\n deleteConfirm: '确定要删除这条评论吗?',\n deleteFailed: '无法删除评论。',\n hideReplies: '收起回复',\n showReplies: '{count} 条回复',\n loadMoreReplies: '更多回复',\n },\n comments: {\n bodyRequired: '请输入评论内容。',\n bodyTooLong: '评论最多 {max} 个字。',\n replyTargetNotFound: '找不到要回复的评论。',\n threadNotFound: '找不到该评论串。',\n hasRepliesCannotDelete: '有回复的评论目前无法删除。',\n },\n common: {\n action: {\n zoomIn: \"放大\",\n zoomOut: \"缩小\",\n fitImage: \"适应屏幕\",\n originalImage: \"原始大小 (100%)\",\n cancel: '取消',\n save: '保存',\n saved: '已保存',\n edit: '编辑',\n delete: '删除',\n confirm: '确定',\n close: '关闭',\n add: '添加',\n more: '更多',\n prev: '上一篇',\n next: '下一篇',\n retry: '重试',\n reply: '回复',\n like: '点赞',\n unlike: '取消点赞',\n share: '分享',\n report: '举报',\n signIn: '登录',\n signOut: '退出登录',\n write: '发帖',\n toTop: '顶部',\n },\n label: {\n imageControls: \"图片缩放控件\",\n anonymous: '匿名',\n user: '用户',\n image: '图片',\n draft: '草稿',\n loading: '加载中…',\n },\n role: {\n admin: '管理员',\n staff: '工作人员',\n user: '用户',\n },\n date: {\n justNow: '刚刚',\n fewSecondsAgo: '几秒前',\n checking: '正在确认时间',\n createdAt: '创建时间:{time}',\n updatedAt: '修改时间:{time}',\n },\n },\n detail: {\n deleteConfirm: '确定要删除这篇帖子吗?此操作无法撤销。',\n notFound: '找不到该帖子。',\n draftPreview: '未发布预览',\n draftNotice: '尚未发布。发布前仅作者本人可见。',\n publish: '发布',\n notice: '公告',\n pin: '设为公告',\n unpin: '取消公告',\n backToList: '返回列表',\n postNav: '帖子导航',\n list: '列表',\n comments: '评论',\n },\n editor: {\n titleOptionalPlaceholder: '标题(留空则由 AI 生成)',\n titleAiNotice: 'AI 已根据正文生成标题。满意请保存,也可以先修改。',\n translateView: {\n title: '翻译预览:{language}',\n notSaved: '尚未保存,仅为预览。',\n },\n titlePlaceholder: '标题',\n contentPlaceholder: '输入正文… 输入 / 可打开块菜单。',\n imageContentPlaceholder: '输入内容…',\n tagsPlaceholder: '输入标签后按 Enter',\n pinNotice: '设为公告',\n submitEdit: '完成修改',\n submitNext: '下一步:预览',\n apply: '应用',\n imageOptimizing: '正在优化并上传图片…',\n cover: {\n imageLink: '图片链接',\n addPhoto: '添加照片',\n alt: '照片 {n}',\n representative: '封面',\n setRepresentative: '设为封面',\n setRepresentativeAria: '将照片 {n} 设为封面',\n remove: '移除照片',\n add: '添加',\n uploading: '上传中…',\n dropOrClick: '拖放照片或点击添加',\n emptyHint: '可多选 · 上传后选择封面 · 拖动调整顺序',\n uploadTitle: '上传',\n },\n uploadFailed: '上传失败',\n aiAssistant: 'AI 写作助手',\n insertBlock: '插入块',\n blockMenu: '块菜单',\n imageDialog: {\n title: '添加图片',\n chooseFile: '从设备中选择图片',\n orLink: '或使用图片链接',\n urlLabel: '图片 URL',\n previewAlt: '外部图片预览',\n addLink: '添加链接',\n },\n customDialog: {\n title: '改写为自定义风格',\n label: '想怎么改写?',\n help: '例如:用东北话风格 / 用正式书面语 / 改成简洁的要点',\n placeholder: '用东北话风格',\n },\n aiPreview: {\n title: '确认 AI 结果',\n currentTitle: '当前标题',\n newTitle: '新标题',\n currentContent: '当前内容',\n appendedContent: '将添加的内容',\n before: '修改前',\n after: '修改后',\n },\n menu: {\n ai: 'AI',\n aiContinue: '用 AI 续写',\n style: '样式',\n insert: '插入',\n paragraph: '正文',\n heading1: '标题 1',\n heading2: '标题 2',\n heading3: '标题 3',\n bulletList: '无序列表',\n orderedList: '有序列表',\n blockquote: '引用',\n codeBlock: '代码块',\n horizontalRule: '分隔线',\n image: '图片',\n turnInto: '转换为',\n resetFormatting: '重置格式',\n duplicate: '复制',\n moveUp: '上移',\n moveDown: '下移',\n delete: '删除',\n },\n toolbar: {\n link: '链接',\n image: '图片',\n format: '格式',\n bold: '粗体',\n italic: '斜体',\n underline: '下划线',\n strike: '删除线',\n title: '标题',\n heading: '大标题',\n subheading: '小标题',\n body: '正文',\n mono: '等宽',\n bulletList: '无序列表',\n orderedList: '有序列表',\n blockquote: '块引用',\n codeBlock: '代码块',\n horizontalRule: '分隔线',\n clearFormatting: '清除格式',\n undo: '撤销',\n redo: '重做',\n },\n ai: {\n translateView: '翻译预览',\n translateAll: '翻译并保存为{count}种语言',\n proofread: '拼写检查',\n polish: '润色文字',\n continue: '自然续写',\n summarize: '简短总结',\n translateEn: '翻译成英语',\n translateKo: '翻译成韩语',\n translateEmpty: '请先输入要翻译的标题或正文。',\n title: '根据正文生成标题',\n custom: '改写为自定义风格…',\n failed: 'AI 处理失败,请重试。',\n },\n youtubePrompt: '请输入 YouTube 链接。',\n errors: {\n coverMax: '最多可上传 {max} 张照片。',\n imageUrlRequired: '请输入图片地址。',\n imageUrlInvalid: '请输入有效的图片地址。',\n imageUrlHttps: '仅支持 HTTPS 图片地址。',\n imageUrlCredentials: '不能使用包含登录信息的地址。',\n imageTimeout: '图片加载时间过长。',\n imageNotDisplayable: '无法显示该图片。',\n editorNotReady: '编辑器未准备就绪,请重试。',\n imageLinkFailed: '无法添加图片链接。',\n imageOnly: '只能上传图片文件。',\n imageTooLarge: '原始图片不能超过 {mb}MB。',\n imageUploadFailed: '图片上传失败。',\n pasteAsFile: '请将照片以文件形式粘贴或拖到上方区域。',\n postNotFound: '找不到该帖子。',\n loadPermission: '没有加载该帖子的权限,请检查登录状态。',\n loadFailed: '无法加载帖子:{message}',\n titleRequired: '请输入标题。',\n photoRequired: '请至少上传一张照片。',\n bodyRequired: '请在正文中输入文字或添加图片。',\n signInRequired: '请先登录。',\n boardMissing: '未指定版块。',\n writeRestricted: '由于内容警告累计,发帖和评论功能已被暂时限制。',\n moderationBlocked: '包含无法发布的内容。',\n },\n hint: {\n titling: '正在生成标题…',\n reviewing: '正在审核内容…',\n saving: '正在保存…',\n },\n },\n footer: {\n introduction: \"memi-board 基于 Nuxt 4、Vue 3 和 TypeScript 构建,借助 Nuxt UI 和 Firebase,让帖子、评论与交流自然相连。\",\n eyebrow: \"让交流发生的空间\",\n tagline: \"小小的社区,共同的故事。\",\n detail: \"详细介绍\",\n links: \"链接与联系\",\n email: \"电子邮件\",\n credit: \"由 memi 制作\",\n feedback: \"发送反馈\",\n feedbackSubject: \"memi-board 反馈\",\n feedbackBody: \"意见:\\n\",\n siteEyebrow: \"使用 memi-board 构建\",\n changelog: \"更新日志\",\n info: {\n title: \"memi-board\",\n about: \"关于 memi-board\",\n sites: \"应用案例\",\n history: '版本历史',\n },\n sites: {\n description: \"这些服务使用 memi-board 连接帖子与交流。\",\n memi: \"分享开发经验与日常故事的空间\",\n waiting: \"扫码即可使用的便捷排队服务\",\n track: \"记录出行路线的 GPS 轨迹追踪\",\n loop: \"车辆行驶记录与传感器解决方案\",\n safe: \"踏板误操作防护设备的连接与设置\",\n inviteTitle: \"你也在用吗?\",\n invite: \"把网站地址告诉我们吧。\",\n inviteSite: \"留在 memi.dev\",\n inviteOr: \"或\",\n inviteSubject: \"我们在使用 memi-board\",\n inviteBody: \"网站地址:\\n\",\n },\n stack: {\n ui: '界面与体验',\n data: '数据与登录',\n writing: '写作与便捷功能',\n },\n },\n likes: {\n signInRequired: '请先登录。',\n commentNotFound: '找不到该评论。',\n },\n list: {\n boardInfo: {\n creator: '创建者',\n managers: '负责经理',\n staff: '负责员工',\n },\n commentCount: '{count} 条评论',\n settings: '设置',\n loadFailed: '无法加载列表:{error}',\n empty: '暂无帖子。',\n notice: '公告',\n photo: '照片',\n likeCount: '点赞 {count}',\n info: {\n title: '版块信息',\n about: '关于本版块',\n history: '版本历史',\n },\n stack: {\n framework: '框架与语言',\n ui: 'UI 与样式',\n backend: '后端与数据',\n },\n tech: {\n nuxt: '基于 Vue 的 SSR 框架',\n vue: 'Composition API · script setup',\n typescript: '类型安全的数据与组件',\n nuxtUi: '版块、表单与弹窗 UI',\n tailwind: '响应式 · 浅色/深色样式',\n lucide: '轻量且风格统一的图标',\n dayjs: '易读的日期与相对时间',\n firestore: '用于帖子和评论的实时数据库',\n auth: '安全的用户登录与权限',\n storage: '图片与附件存储',\n vuefire: 'Nuxt 与 Firebase 实时连接',\n },\n },\n listViewSwitch: {\n card: '卡片',\n default: '默认',\n dense: '紧凑',\n video: '视频',\n },\n miniComments: {\n empty: '暂无评论。',\n blinded: '该评论已被管理员屏蔽。',\n },\n miniList: {\n empty: '暂无帖子。',\n },\n miniPicker: {\n selectedCount: '已选 {count} / {max}',\n layoutHint: '2 篇起为轮播,4 篇为网格',\n searchPlaceholder: '搜索标题',\n loading: '正在加载帖子…',\n noMore: '没有更多可选的帖子了。',\n },\n miniPost: {\n empty: '未选择帖子。',\n },\n moderation: {\n writeRestricted: '由于内容警告累计,发帖和评论功能已被暂时限制。',\n },\n moderationPrompt: {\n localHit: '不能使用“{hit}”之类的表达,请换种说法。',\n localGeneric: '内容包含脏话或粗俗用语,无法发布,请修改措辞。',\n genericBlocked: '包含无法发布的内容,请修改后再试。',\n categories: {\n abuse: '脏话、粗俗用语或侮辱',\n spam: '垃圾信息或广告',\n adult: '色情或成人内容',\n violence: '暴力或威胁',\n other: '不当内容',\n },\n },\n moderationStrike: {\n liftSoon: '即将解除',\n liftInHours: '约 {hours} 小时后解除',\n liftInDays: '约 {days} 天后解除',\n liftInDaysHours: '约 {days} 天 {hours} 小时后解除',\n },\n posts: {\n boardIdRequired: '[memi-board] 需要提供 boardId。',\n photoRequired: '请上传照片。',\n bodyRequired: '请在正文中输入文字或添加图片。',\n titleRequired: '请输入标题。',\n },\n profile: {\n stats: {\n posts: '{count} 篇帖子',\n comments: '{count} 条评论',\n aiUsage: 'AI 已用 {used} / {limit}',\n aiUsageResets: 'AI 已用 {used} / {limit} · {hours} 小时后重置',\n warnings: '{count} 次警告',\n },\n notFound: '找不到该用户。',\n noName: '未命名',\n joined: '{time} 加入',\n lastVisit: '最近访问 {time}',\n },\n profileEditModal: {\n imageOnly: '只能选择图片文件。',\n nameRequired: '请输入名称。',\n saveFailed: '无法保存个人资料。',\n title: '编辑个人资料',\n changePhoto: '更换照片',\n name: '名称',\n bio: '个人简介',\n bioPlaceholder: '介绍一下自己吧',\n },\n reportButton: {\n failed: '举报失败。',\n reported: '已举报',\n title: '举报帖子',\n done: '举报已提交,工作人员将进行审核。',\n reason: '原因',\n detailPlaceholder: '详细说明(选填)',\n submit: '举报',\n\n \"status\": {\n \"open\": \"已受理\",\n \"dismissed\": \"已驳回\",\n \"actioned\": \"审核完成\"\n },\n \"resultTitle\": \"举报处理状态\",\n \"result\": {\n \"open\": \"举报已受理,正在等待审核。\",\n \"dismissed\": \"审核未发现违规。\",\n \"actioned\": \"审核已完成,请查看下方处理理由。\"\n },\n \"reviewedAt\": \"处理时间\",\n \"reviewNote\": \"处理理由\",\n \"noNote\": \"此历史记录未填写处理理由。\",\n \"detailRequired\": \"详细说明(其他原因必填)\",\n },\n reportList: {\n staffOnly: '仅工作人员可查看举报列表。',\n loadFailed: '无法加载列表:{error}',\n empty: '暂无待处理的举报。',\n untitled: '无标题帖子',\n author: '作者 {name}',\n dismiss: '驳回',\n\n \"tabs\": {\n \"open\": \"待处理\",\n \"processed\": \"处理记录\"\n },\n \"filterLabel\": \"筛选举报状态\",\n \"orderHint\": \"按受理时间倒序排列。\",\n \"historyEmpty\": \"暂无处理记录。\",\n \"confirm\": \"标记审核完成\",\n \"dismissTitle\": \"驳回举报\",\n \"resolveTitle\": \"完成审核\",\n \"actionHint\": \"仅更改举报状态,不会隐藏或删除帖子。\",\n \"publicNote\": \"举报者也可以看到此理由。\",\n \"reviewer\": \"处理人\",\n \"reviewNote\": \"处理理由\",\n \"unknownReviewer\": \"处理人未知\",\n \"actionFailed\": \"处理失败,请重试。\",\n },\n reports: {\n reason: {\n spam: '垃圾信息/广告',\n abuse: '辱骂/仇恨/骚扰',\n adult: '色情内容',\n illegal: '违法信息',\n other: '其他',\n },\n fallbackLabel: '举报',\n signInRequired: '请先登录。',\n cannotReportOwn: '不能举报自己的帖子。',\n reasonRequired: '请选择举报原因。',\n detailTooLong: '详细说明最多 {max} 个字。',\n\n \"otherDetailRequired\": \"选择其他原因时请填写详细说明。\",\n \"reviewNoteRequired\": \"请填写处理理由。\",\n \"alreadyReviewed\": \"此举报已处理,请查看列表。\",\n \"manageRequired\": \"你无权处理此举报。\",\n \"alreadyReported\": \"你已举报过此帖子。\",\n \"statusLoadFailed\": \"无法加载举报状态,请刷新页面。\",\n },\n sectionUtil: {\n kind: {\n list: '帖子列表',\n post: '帖子',\n comments: '评论合集',\n },\n sort: {\n latest: '最新',\n likes: '最多点赞',\n },\n untitledPost: '帖子',\n view: {\n media: '媒体',\n list: '列表',\n },\n },\n sections: {\n defaultTitle: '区块 {n}',\n },\n seo: {\n allCategory: '全部',\n boardDescription: '{name} 版块',\n allDescription: '版块 — 查看公告和社区动态。',\n postFallbackTitle: '帖子 | {site}',\n postFallbackDescription: '版块',\n photo: '照片',\n postDescriptionFallback: '版块帖子',\n listTitle: '{name} 版块 | {site}',\n listTitleAll: '版块 | {site}',\n listDescription: '{name} 版块 — 查看最新动态。',\n listDescriptionAll: '版块 — 查看公告和话题。',\n },\n settings: {\n aiTitle: {\n label: '用 AI 自动生成标题',\n help: '新帖子的标题留空时,AI 会根据正文(没有正文则根据第一张照片)生成标题。照片仅在生成标题时发送给 Google AI。关闭后必须自己填写标题,也不会发送照片。',\n },\n site: {\n apply: '应用',\n applied: '已应用。',\n title: '全站设置',\n help: '适用于整个网站。优先于宿主应用的设置,修改后立即生效。',\n ai: { label: '使用 AI 功能', help: '因费用等原因无法使用 AI 时请关闭。关闭后,翻译、写作助手、AI 标题和 AI 审核都会停用,照片也不会再发送给 AI。违禁词检查仍然有效。' },\n i18n: { label: '启用国际化', help: '关闭后,所有页面固定为下方选择的一种语言,翻译功能也会关闭。已保存的翻译不会被删除。' },\n locales: { label: '可用语言', help: '仅翻译所选的 {count} 种语言,并显示在语言菜单中。未选择的语言不会生成新的翻译,已保存的翻译不会被删除。' },\n locale: { label: '默认语言', help: '宿主应用未指定语言时使用的语言。', fixedLabel: '固定语言', fixedHelp: '所有页面都以此语言显示。' },\n },\n i18nBoard: {\n label: '国际化',\n help: '开启后,版块名称、说明以及新帖子和评论会自动翻译成 {count} 种语言并一并保存。关闭后只显示原文。',\n existing: '同时翻译已有帖子',\n },\n about: {\n label: '显示 About memi-board',\n help: '在每个版块页面底部显示 About memi-board 链接和弹窗。关闭后将在整个网站隐藏。',\n },\n postTranslations: {\n label: '翻译已有帖子',\n help: '查找缺少翻译或与原文不一致的帖子,并一次性翻译保存。适用于关闭了自动翻译的版块,或补齐漏翻的帖子。不消耗每日 AI 次数。',\n scan: '查找需要翻译的帖子',\n run: '翻译 {count} 篇帖子',\n confirm: '将翻译并保存 {count} 篇帖子,每篇都会调用一次 AI。是否继续?',\n progress: '翻译中… 已完成 {done},失败 {failed}',\n allDone: '全部 {total} 篇已发布帖子的翻译都是最新的。',\n found: '{total} 篇已发布帖子中有 {count} 篇需要翻译。',\n failed: '有 {count} 篇帖子翻译失败。请重新查找后再试。',\n },\n loading: '正在加载设置。',\n noAccess: '没有访问权限',\n notFound: {\n title: '版块不存在',\n checkAddress: '请检查地址是否正确。',\n checkBoardAddress: '请检查版块地址是否正确。',\n },\n newBoard: {\n title: '该版块尚未创建',\n description: '填写以下信息并保存后,将以此 ID 新建版块。',\n },\n noLabel: '无标签',\n option: {\n default: '默认',\n },\n id: {\n label: '分类 ID',\n help: '用于网址和数据的标识',\n },\n writeRole: {\n label: '发帖权限',\n help: '可在此分类发帖的最低角色',\n helpShort: '可发帖的最低角色',\n options: {\n user: '普通用户及以上',\n staff: '工作人员及以上',\n admin: '仅管理员',\n },\n },\n commentWriteRole: {\n label: '评论权限',\n help: '可在此分类评论的最低角色',\n helpShort: '可评论的最低角色',\n hiddenFixed: '仅负责的工作人员(隐藏版块固定)',\n },\n staff: {\n label: '负责的工作人员',\n help: '仅指定的工作人员可设置和管理此版块。留空则仅限管理员',\n placeholder: '仅管理员(未指定工作人员)',\n },\n label: {\n label: '显示名称',\n help: '用户看到的名称',\n },\n description: {\n label: '说明',\n help: '显示在版块顶部等位置的提示文字',\n placeholder: '关于此版块的简短说明(选填)',\n },\n visibility: {\n label: '可见范围',\n options: {\n public: {\n label: '可见',\n description: '显示在全部列表和筛选中',\n },\n hidden: {\n label: '隐藏',\n description: '日记、私密用途。仅负责的工作人员可评论',\n },\n },\n },\n listView: {\n label: '列表视图',\n help: '帖子列表的显示方式',\n options: {\n card: '卡片',\n dense: '紧凑',\n video: '视频',\n },\n },\n editorType: {\n label: '输入表单',\n help: '发帖页面的编写方式',\n options: {\n default: '填写标题和正文',\n image: '上传多张照片并设置封面',\n },\n },\n bulk: {\n title: '应用到所有版块',\n apply: '应用到 {count} 个版块',\n applyShort: '应用',\n confirmTitle: '应用到所有版块',\n translateUntranslated: '翻译未翻译的版块 ({count})',\n },\n translationLocales: {\n label: '翻译语言',\n },\n locale: {\n label: '版块语言',\n auto: '自动(跟随网站语言)',\n },\n autoTranslate: {\n action: \"翻译为{count}种语言\",\n manualHelp: \"一起翻译显示名称和说明,翻译后请点击保存以应用。\",\n pending: \"{count}种语言的翻译已就绪,请点击保存以应用。\",\n manualFailed: \"翻译失败,输入内容已保留,请重试。\",\n label: '保存时翻译成{count}种语言',\n help: '打开后,保存时会把名称和说明翻译成{count}种语言。关闭时不翻译。文字没变就不会再次翻译。',\n failed: '设置已保存,但翻译成{count}种语言失败了。请再次保存。',\n ready: '名称和说明已保存为{count}种语言。',\n },\n action: {\n moveDown: '下移',\n goToBoard: '前往版块',\n deleteBoard: '删除版块',\n create: '创建',\n },\n newCategoryPlaceholder: '新分类名称',\n savedCategory: '已保存“{label}”分类。',\n savedSettings: '版块设置已保存。',\n created: '版块已创建。',\n error: {\n orderFailed: '无法保存分类顺序。',\n deleteFailed: '无法删除版块及其关联数据。',\n saveFailed: '无法保存版块设置。',\n deleteAllFailed: '无法删除版块数据。',\n },\n danger: {\n title: '危险区域',\n confirmLabel: '请输入“{phrase}”以确认',\n phrase: '删除版块数据',\n button: '删除所有数据',\n done: '已删除所有版块数据。',\n },\n },\n shareButton: {\n copied: '链接已复制',\n },\n signIn: {\n withGoogle: '使用 Google 登录',\n withApple: '使用 Apple 登录',\n or: '或',\n name: '名称',\n email: '邮箱',\n password: '密码',\n signUp: '注册',\n toSignUp: '还没有账号?注册',\n toSignIn: '已有账号?登录',\n },\n storage: {\n sourceTooLarge: '原始图片不能超过 {max}MB。',\n imageOnly: '只能上传图片文件。',\n canvasUnavailable: '无法使用 Canvas',\n compressFailed: '压缩失败',\n loadFailed: '图片加载失败',\n },\n swipeHint: {\n title: '试试左右滑动',\n description: '向左滑动查看下一篇,向右滑动查看上一篇。',\n },\n userCommentList: {\n loadFailed: '无法加载列表:{error}',\n empty: '暂无评论。',\n blinded: '该评论已被管理员屏蔽。',\n likes: '点赞 {count}',\n },\n userPostList: {\n loadFailed: '无法加载列表:{error}',\n empty: '暂无帖子。',\n },\n users: {\n changeRoleFailed: '无法更改角色。',\n loading: '正在加载用户列表。',\n noPermission: {\n title: '没有权限',\n description: '仅版块管理员可管理用户角色。',\n },\n empty: '暂无登录过此版块的用户。',\n noName: '未命名',\n moderationWarnings: '内容警告 {count} 次',\n },\n writingAssistant: {\n dailyLimit: 'AI 写作助手每天最多可使用 {limit} 次。请在约 {hours} 小时后再试。',\n loginRequired: '登录后才能使用 AI 写作助手。',\n userMissing: '请确认用户信息后重试。',\n contentRequired: '请先输入要让 AI 润色的内容。',\n customRequired: '请输入改写要求。',\n emptyResult: 'AI 未能生成结果,请重试。',\n translateEmpty: '请先输入要翻译的标题或正文。',\n translateTooLong: '正文超过{limit}字时无法保存为{count}种语言。请缩短后再试。',\n translateIncomplete: '部分语言的翻译是空的。请重试。',\n failed: 'AI 处理失败,请重试。',\n actions: {\n proofread: '拼写检查',\n polish: '润色文字',\n continue: '自然续写',\n summarize: '简短总结',\n translateEn: '翻译成英语',\n translateKo: '翻译成韩语',\n translateAll: '翻译并保存为{count}种语言',\n translateSaved: '已保存为{count}种语言。用其他语言查看时会看到译文。',\n translatePending: '已准备好{count}种语言的翻译。保存文章时会一并记录。',\n translateEmpty: '请先输入要翻译的标题或正文。',\n title: '根据正文生成标题',\n custom: '改写为自定义风格…',\n },\n },\n}\n\nexport default messages\n"],"mappings":"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V,SAAS;GACT,OAAO;GACP,QAAQ;EACV;EACA,SAAS;EACT,UAAU;EACV,UAAU;GACR,OAAO;GACP,cAAc;GACd,mBAAmB;EACrB;EACA,UAAU;GACR,OAAO;GACP,aAAa;EACf;EACA,SAAS;EACT,QAAQ,EACN,SAAS,KACX;EACA,IAAI;GACF,OAAO;GACP,MAAM;EACR;EACA,WAAW;GACT,OAAO;GACP,MAAM;GACN,WAAW;GACX,SAAS;IACP,MAAM;IACN,OAAO;IACP,OAAO;GACT;EACF;EACA,kBAAkB;GAChB,OAAO;GACP,MAAM;GACN,WAAW;GACX,aAAa;EACf;EACA,OAAO;GACL,OAAO;GACP,MAAM;GACN,aAAa;EACf;EACA,OAAO;GACL,OAAO;GACP,MAAM;EACR;EACA,aAAa;GACX,OAAO;GACP,MAAM;GACN,aAAa;EACf;EACA,YAAY;GACV,OAAO;GACP,SAAS;IACP,QAAQ;KACN,OAAO;KACP,aAAa;IACf;IACA,QAAQ;KACN,OAAO;KACP,aAAa;IACf;GACF;EACF;EACA,UAAU;GACR,OAAO;GACP,MAAM;GACN,SAAS;IACP,MAAM;IACN,OAAO;IACP,OAAO;GACT;EACF;EACA,YAAY;GACV,OAAO;GACP,MAAM;GACN,SAAS;IACP,SAAS;IACT,OAAO;GACT;EACF;EACA,MAAM;GACJ,OAAO;GACP,OAAO;GACP,YAAY;GACZ,cAAc;GACd,uBAAuB;EACzB;EACA,oBAAoB,EAClB,OAAO,OACT;EACA,QAAQ;GACN,OAAO;GACP,MAAM;EACR;EACA,eAAe;GACb,QAAQ;GACR,YAAY;GACZ,SAAS;GACT,cAAc;GACd,OAAO;GACP,MAAM;GACN,QAAQ;GACR,OAAO;EACT;EACA,QAAQ;GACN,UAAU;GACV,WAAW;GACX,aAAa;GACb,QAAQ;EACV;EACA,wBAAwB;EACxB,eAAe;EACf,eAAe;EACf,SAAS;EACT,OAAO;GACL,aAAa;GACb,cAAc;GACd,YAAY;GACZ,iBAAiB;EACnB;EACA,QAAQ;GACN,OAAO;GACP,cAAc;GACd,QAAQ;GACR,QAAQ;GACR,MAAM;EACR;CACF;CACA,aAAa,EACX,QAAQ,QACV;CACA,QAAQ;EACN,YAAY;EACZ,WAAW;EACX,IAAI;EACJ,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;EACR,UAAU;EACV,UAAU;CACZ;CACA,SAAS;EACP,gBAAgB;EAChB,WAAW;EACX,mBAAmB;EACnB,gBAAgB;EAChB,YAAY;CACd;CACA,WAAW;EACT,OAAO;EACP,aAAa;CACf;CACA,iBAAiB;EACf,YAAY;EACZ,OAAO;EACP,SAAS;EACT,OAAO;CACT;CACA,cAAc;EACZ,YAAY;EACZ,OAAO;CACT;CACA,OAAO;EACL,kBAAkB;EAClB,SAAS;EACT,cAAc;GACZ,OAAO;GACP,aAAa;EACf;EACA,OAAO;EACP,QAAQ;EACR,oBAAoB;CACtB;CACA,kBAAkB;EAChB,YAAY;EACZ,eAAe;EACf,aAAa;EACb,iBAAiB;EACjB,gBAAgB;EAChB,aAAa;EACb,gBAAgB;EAChB,kBAAkB;EAClB,qBAAqB;EACrB,QAAQ;EACR,SAAS;GACP,WAAW;GACX,QAAQ;GACR,UAAU;GACV,WAAW;GACX,aAAa;GACb,aAAa;GACb,cAAc;GACd,gBAAgB;GAChB,kBAAkB;GAClB,gBAAgB;GAChB,OAAO;GACP,QAAQ;EACV;CACF;AACF"}
@@ -0,0 +1,60 @@
1
+ # Account deletion in a host application
2
+
3
+ `MemiBoardAccountDeletion` provides confirmation and progress UI. The host owns reauthentication, server processing, and Firebase Auth deletion. The package never deletes a shared host account just because a profile component is mounted.
4
+
5
+ Place this feature on a sign-in-protected, `noindex` `/board-account` page. Do not place it in a public profile or admin user directory. This flow deletes the **whole Firebase sign-in account**, including access to other host features sharing that identity. A host offering board-only withdrawal needs a separate flow and matching explanations.
6
+
7
+ ```vue
8
+ <MemiBoardAccountDeletion
9
+ :state="deletionState"
10
+ :processed="processedItems"
11
+ :error="deletionError"
12
+ :is-owner="role === 'owner'"
13
+ :account-label="user.email"
14
+ :locked-policy="previouslyConfirmedPolicy"
15
+ @confirm="({ deleteContent }) => deleteOwnAccount({ deleteContent })"
16
+ />
17
+ ```
18
+
19
+ `state` is `idle`, `authenticating`, `working`, or `error`. `processed` is a monotonically increasing work count, not an estimated percentage or a total. `lockedPolicy` is `null` before starting and the previously confirmed boolean when resuming. Opening the dialog performs no deletion. The confirm action requires an acknowledgement and exact `DELETE` input; controls and dismissal are disabled while processing. The host must also reject duplicate calls.
20
+
21
+ ## User-selected content handling
22
+
23
+ - **Unchecked, by default:** keep posts, comments, and content files; remove author names, photos, account links, reply-target names, and file ownership metadata. Information the user wrote into retained content can remain, and the dialog explains this.
24
+ - **Checked:** delete the user's posts, body documents, comments, and uploaded files. Deleting a post also removes other people's comments and files beneath it; disclose this in the confirmation. On other people's posts, retain content-free anonymous placeholders only where needed to preserve their reply threads, and remove translated copies and images from those placeholders.
25
+ - In either mode, remove profile/email/provider information, avatar files, private memos, likes, reporter details, and board assignments. Update affected like/comment/reply counters atomically with deletions. Scrub author/moderator/creator references. Host-specific and legacy account data require an explicit host cleanup stage.
26
+
27
+ ## Required server safeguards
28
+
29
+ 1. Reauthenticate with the current sign-in provider. The server must verify the ID token with revocation checking, require a recent `auth_time`, reject anonymous users, and derive the deletion UID from that verified token. A client-supplied UID or `isOwner` prop is not authorization.
30
+ 2. Reject owners on the server before changing any data. Ownership must be transferred through a trusted process first.
31
+ 3. Save a private server job and a protected `deletionPending` marker before removing data. Freeze the content policy after confirmation. Do not allow clients to read or write job checkpoints or secrets.
32
+ 4. Process one bounded stage per request, using limited queries and transaction/batch sizes comfortably below 500 writes. Bound Storage listing and deletion concurrency too. Account size must not result in an unbounded `Promise.all` or a single giant client batch.
33
+ 5. Commit Firestore changes and their progress checkpoint together. Decrement counters in the same transaction that removes their corresponding records. Storage deletion must ignore only missing files; other errors must stop the stage, preserve progress, and allow retry.
34
+ 6. Use a server lease to serialize duplicate requests. Check that lease again when committing a checkpoint. Bind any continuation key to one preconfirmed job, hash it server-side, expire it, and keep it out of URLs and logs. A continuation may only finish the scope already confirmed; it must not change the target or policy.
35
+ 7. Delete Firebase Auth **last**, after all required data/file stages succeed. Treat `auth/user-not-found` as success when retrying this final stage. A scoped continuation permits finalizing a lost response even after the user is signed out. Without it, the host can strand a cleanup job by requiring an account it has just deleted.
36
+ 8. Show loading and processed counts; protect against accidental navigation. Keep a browser-session continuation for refresh/retry. On a signed-out continuation page, render no private profile or email data and still verify the continuation server-side.
37
+
38
+ The memi-dev reference implementation uses 100-record pages, at most 201 Firestore writes per main step, an additional approximate 2 MiB payload budget, Storage concurrency of five, a 90-second request lease, and a 24-hour continuation key. Large legacy subtrees use Admin SDK `recursiveDelete`/BulkWriter. It retains no names or emails in job records.
39
+
40
+ ## Rules and activation
41
+
42
+ Merge the deletion protections in the current [Firestore rules](firestore.rules.example) and [Storage rules](storage.rules.example) into the host's actual rules. They block writes by pending/deleted members and new content beneath posts/comments currently being removed. The Storage check shares the existing user-document lookup to remain within the two-document read budget.
43
+
44
+ A minimal `memiBoardUsers/{uid}` document containing only `deletionPending: true` remains after profile erasure. It contains no profile or contact fields. This marker blocks still-valid cached tokens and automatic profile recreation. Public profile/directory components omit these records. Removing this marker immediately would reopen that race until old tokens expire. Do not let clients remove it.
45
+
46
+ The host server job collection `memiBoardAccountDeletionJobs` is server-only. Collection names and Storage prefixes must match the host's configured board collections. Do not use a request body or a document's arbitrary download URL as an unrestricted Storage deletion path.
47
+
48
+ In memi-dev, actual deletion is **disabled until deployment** through `runtimeConfig.boardAccountDeletionEnabled`. Deploy the reviewed Firestore/Storage rules, verify server Admin credentials and bucket configuration, then set `NUXT_BOARD_ACCOUNT_DELETION_ENABLED=true`. Keep it disabled if those prerequisites have not been verified. Installing/building the package does not deploy rules or activate the endpoint.
49
+
50
+ ## Verification
51
+
52
+ Run against a dedicated `demo-...` emulator project, never production accounts. Cover more than 500 posts/comments/likes/files, retention and full-content modes, owner rejection, expired/incorrect continuation keys, cached-token write attempts, duplicate requests, Storage failures, Auth response loss, and preservation of unrelated users' data. Test the exact host rules used for deployment and the UI acknowledgement/confirmation gate.
53
+
54
+ The memi-dev regression command is:
55
+
56
+ ```sh
57
+ firebase emulators:exec --config firebase.account-deletion-tests.json \
58
+ --only firestore,auth,storage --project demo-memi-account-deletion \
59
+ 'node --experimental-strip-types scripts/test-account-deletion.mjs'
60
+ ```
@@ -79,6 +79,83 @@ firebase deploy --only firestore:rules,storage
79
79
 
80
80
  **If you merge but don't deploy, writing posts and comments fails with `permission-denied`** — this is the most common installation mistake.
81
81
 
82
+ ### Initialize post URL reservations
83
+
84
+ **Required once before the first post when installing 0.57.0+ on a new project.**
85
+ This section assumes the configured posts collection is empty. It initializes the
86
+ URL reservation feature; it does not move, rename, or rewrite any post documents.
87
+ An already initialized project does not need this step again for a package upgrade.
88
+
89
+ Deploy the Firestore rules above first, then use either of the following methods.
90
+ Firebase Admin is optional: the console method needs no Admin SDK in the host app.
91
+
92
+ **Firebase console:**
93
+
94
+ 1. Open the intended Firebase project → Firestore Database → Data. Confirm that
95
+ `memiBoardPosts` has no documents.
96
+ 2. Create the collection `memiBoardPostsSlugs` and document `__migration`.
97
+ 3. Add `ready` with type **Boolean** and value **true**, then save. The spelling
98
+ `__migration` is an internal setup marker name; no post migration is being run.
99
+ Do not use the string `"true"`.
100
+
101
+ **Optional command:** Run from the host project root with a trusted account that
102
+ can read/write this Firestore database. The command rejects a nonempty posts
103
+ collection and never updates post documents. Keep Admin credentials outside the
104
+ repository and outside browser/public configuration.
105
+
106
+ ```bash
107
+ pnpm add -D firebase-admin
108
+
109
+ GCLOUD_PROJECT=YOUR_FIREBASE_PROJECT_ID \
110
+ GOOGLE_APPLICATION_CREDENTIALS=/absolute/path/to/service-account.json \
111
+ node --input-type=module <<'NODE'
112
+ import { initializeApp, applicationDefault } from 'firebase-admin/app'
113
+ import { getFirestore, FieldValue } from 'firebase-admin/firestore'
114
+
115
+ const projectId = process.env.GCLOUD_PROJECT
116
+ if (!projectId || projectId === 'YOUR_FIREBASE_PROJECT_ID') {
117
+ throw new Error('Set the intended Firebase project ID first')
118
+ }
119
+ initializeApp({ projectId, credential: applicationDefault() })
120
+ const db = getFirestore()
121
+ const postsCollection = process.env.MEMI_POSTS_COLLECTION || 'memiBoardPosts'
122
+ const marker = db.doc(`${postsCollection}Slugs/__migration`)
123
+ await db.runTransaction(async transaction => {
124
+ const current = await transaction.get(marker)
125
+ if (current.get('ready') === true) return
126
+ const posts = await transaction.get(db.collection(postsCollection).limit(1))
127
+ if (!posts.empty) throw new Error('Fresh-project setup requires an empty posts collection')
128
+ if (current.exists) throw new Error('Setup marker already exists; inspect it instead of overwriting it')
129
+ transaction.create(marker, { ready: true, completedAt: FieldValue.serverTimestamp() })
130
+ })
131
+ console.log(`Post URL reservations ready: ${projectId}/${postsCollection}Slugs`)
132
+ NODE
133
+ ```
134
+
135
+ If using Application Default Credentials already configured on the machine, omit
136
+ `GOOGLE_APPLICATION_CREDENTIALS`. For a custom posts collection, prefix the command
137
+ with `MEMI_POSTS_COLLECTION=yourPostsCollection`; the marker then belongs at
138
+ `yourPostsCollectionSlugs/__migration`. Update **every** reservation path in the
139
+ Firestore rule example to match. This marker is shared by the boards using that
140
+ posts collection, so creating another board does not require another initialization.
141
+
142
+ The rules let signed-in writers check individual reservations and create a reservation
143
+ only alongside their own matching new post. Clients cannot list, overwrite, or delete
144
+ reservations, or write the setup marker. Reservations contain only `postId`, not a
145
+ draft's title, body, or author profile. Their document paths contain the URL slug.
146
+ Post read permissions still protect unpublished/private content.
147
+
148
+ After setup, save a test draft, create another post with the same title, and check
149
+ `title` then `title-2`. Refresh the draft, edit it, and publish it; its address should
150
+ stay the same. The number counts conflicting addresses **within that board**, not
151
+ all posts in the project.
152
+
153
+ If saving reports `Post URL reservations are not initialized`, check the correct
154
+ project, posts collection, marker path, and Boolean field. If it reports
155
+ `permission-denied`, confirm that the reservation rules were deployed and the user
156
+ has write access to that board. Do not remove the readiness check or allow public
157
+ reservation writes to work around setup errors.
158
+
82
159
  ### 0.45.1 rule changes and migrating existing hosts
83
160
 
84
161
  From 0.46 onward, the global roles for the file management described below are **admin/owner**. staff/manager manage their assigned content, but the default rules do not let them modify or delete other users' files. For a new install, set the first role to `owner`; migrate existing admin and staff accounts using the [role migration](roles-migration.md) procedure.
@@ -18,7 +18,10 @@ service cloud.firestore {
18
18
  match /databases/{database}/documents {
19
19
 
20
20
  function isSignedIn() {
21
- return request.auth != null;
21
+ // A server-only deletion marker also blocks still-valid cached ID tokens.
22
+ return request.auth != null
23
+ && (!exists(boardUserPath())
24
+ || get(boardUserPath()).data.get('deletionPending', false) == false);
22
25
  }
23
26
  // Even if the host app enables anonymous sign-in for another feature (e.g. a guest queue),
24
27
  // block creating board content (posts/comments/likes) by default — reads and isSignedIn()
@@ -26,6 +29,12 @@ service cloud.firestore {
26
29
  function isBoardWriter() {
27
30
  return isSignedIn() && request.auth.token.firebase.sign_in_provider != 'anonymous';
28
31
  }
32
+ function replyTargetIsActive() {
33
+ let uid = request.resource.data.get('replyToUid', null);
34
+ return uid == null || uid == '' || (uid is string
35
+ && (!exists(/databases/$(database)/documents/memiBoardUsers/$(uid))
36
+ || get(/databases/$(database)/documents/memiBoardUsers/$(uid)).data.get('deletionPending', false) == false));
37
+ }
29
38
  function boardUserPath() {
30
39
  return /databases/$(database)/documents/memiBoardUsers/$(request.auth.uid);
31
40
  }
@@ -89,7 +98,9 @@ service cloud.firestore {
89
98
  && likeId == request.resource.data.commentId + '_' + request.auth.uid
90
99
  && exists(commentPath(request.resource.data.commentId))
91
100
  && get(commentPath(request.resource.data.commentId)).data.postId == request.resource.data.postId
92
- && get(postPath(request.resource.data.postId)).data.boardId == request.resource.data.boardId;
101
+ && get(commentPath(request.resource.data.commentId)).data.get('deletionPending', false) == false
102
+ && get(postPath(request.resource.data.postId)).data.boardId == request.resource.data.boardId
103
+ && get(postPath(request.resource.data.postId)).data.get('deletionPending', false) == false;
93
104
  }
94
105
  function isAllowedStaffForBoard(boardId) {
95
106
  let allowed = get(boardSettingsPath(boardId)).data.get('allowedStaffUids', []);
@@ -230,6 +241,10 @@ service cloud.firestore {
230
241
  return get(boardSettingsPath(boardId)).data.get('visibility', 'public') != 'hidden';
231
242
  }
232
243
 
244
+ match /memiBoardAccountDeletionJobs/{uid} {
245
+ allow read, write: if false;
246
+ }
247
+
233
248
  match /memiBoardUsers/{uid} {
234
249
  // Everything except email is public profile info — email is kept separately in a private subcollection.
235
250
  allow read: if true;
@@ -343,10 +358,46 @@ service cloud.firestore {
343
358
  allow delete: if isBoardAdmin();
344
359
  }
345
360
 
361
+ // Older deployed clients still write auto-ID URLs. Freeze them only during backfill.
362
+ function postSlugWritesOpen() {
363
+ return !exists(/databases/$(database)/documents/memiBoardPostsSlugs/__migration)
364
+ || get(/databases/$(database)/documents/memiBoardPostsSlugs/__migration).data.ready == true;
365
+ }
366
+ function validNewPostSlug(postId) {
367
+ let data = request.resource.data;
368
+ return data.slug is string && (
369
+ data.slug == postId || data.slug.matches('.*-[a-zA-Z0-9]{20}')
370
+ || getAfter(/databases/$(database)/documents/memiBoardPostsSlugs/$(data.boardId)/slugs/$(data.slug)).data.postId == postId
371
+ );
372
+ }
373
+
374
+ // Address reservations contain only a post ID; private drafts never need to be queried.
375
+ // Backfill every legacy slug before setting __migration.ready; keep reservations after deletion.
376
+ match /memiBoardPostsSlugs/{boardId} {
377
+ allow get: if isBoardWriter();
378
+ allow list, write: if false;
379
+ match /slugs/{slug} {
380
+ allow get: if isBoardWriter() && canWriteBoard(boardId);
381
+ allow list, update, delete: if false;
382
+ allow create: if isBoardWriter()
383
+ && get(/databases/$(database)/documents/memiBoardPostsSlugs/__migration).data.ready == true
384
+ && request.resource.data.keys().hasOnly(['postId'])
385
+ && request.resource.data.postId is string
386
+ && !exists(postPath(request.resource.data.postId))
387
+ && getAfter(postPath(request.resource.data.postId)).data.boardId == boardId
388
+ && getAfter(postPath(request.resource.data.postId)).data.slug == slug
389
+ && getAfter(postPath(request.resource.data.postId)).data.authorUid == request.auth.uid;
390
+ }
391
+ }
392
+
346
393
  match /memiBoardPosts/{postId} {
394
+ // Transactions check that a fresh attachment namespace has not already been saved.
395
+ allow get: if isBoardWriter() && resource == null;
347
396
  allow read: if isBoardAdmin() || canReadPost(resource.data);
348
397
 
349
398
  allow create: if isBoardWriter()
399
+ && postSlugWritesOpen()
400
+ && validNewPostSlug(postId)
350
401
  && request.resource.data.boardId is string
351
402
  && request.resource.data.authorUid == request.auth.uid
352
403
  && request.resource.data.moderationStatus == 'approved'
@@ -360,6 +411,7 @@ service cloud.firestore {
360
411
 
361
412
  allow update: if (
362
413
  (isOwner(resource) || isBoardManager(resource.data.boardId))
414
+ && request.resource.data.get('slug', null) == resource.data.get('slug', null)
363
415
  && request.resource.data.boardId == resource.data.boardId
364
416
  && request.resource.data.authorUid == resource.data.authorUid
365
417
  && (
@@ -393,6 +445,10 @@ service cloud.firestore {
393
445
  // commentWriteRole and bypass canWriteBoardComment(), check that it matches the parent post's actual
394
446
  // boardId (cost: one extra get() per comment creation).
395
447
  && get(postPath(request.resource.data.postId)).data.boardId == request.resource.data.boardId
448
+ && get(postPath(request.resource.data.postId)).data.get('deletionPending', false) == false
449
+ && (request.resource.data.get('parentId', null) == null
450
+ || get(commentPath(request.resource.data.parentId)).data.get('deletionPending', false) == false)
451
+ && replyTargetIsActive()
396
452
  && canWriteBoardComment(request.resource.data.boardId)
397
453
  && boardWritesOpen(request.resource.data.boardId)
398
454
  && request.resource.data.authorUid == request.auth.uid
@@ -400,6 +456,7 @@ service cloud.firestore {
400
456
  && request.resource.data.body.size() > 0
401
457
  && request.resource.data.body.size() <= 1000
402
458
  && commentImagesValid(request.resource.data.postId, commentId)
459
+ && !request.resource.data.keys().hasAny(['isBlinded', 'moderatedAt', 'moderatedBy'])
403
460
  && request.resource.data.moderationStatus == 'approved'
404
461
  && request.resource.data.listed is bool
405
462
  && request.resource.data.listed == expectedListed(request.resource.data.boardId);
@@ -414,6 +471,11 @@ service cloud.firestore {
414
471
  && request.resource.data.postId == resource.data.postId
415
472
  && request.resource.data.boardId == resource.data.boardId
416
473
  && request.resource.data.authorUid == resource.data.authorUid
474
+ && (!request.resource.data.diff(resource.data).affectedKeys().hasAny(['isBlinded', 'moderatedAt', 'moderatedBy'])
475
+ || (isBoardManager(resource.data.boardId)
476
+ && request.resource.data.isBlinded is bool
477
+ && request.resource.data.moderatedBy == request.auth.uid
478
+ && request.resource.data.moderatedAt == request.time))
417
479
  && commentImagesValid(request.resource.data.postId, commentId)
418
480
  && (
419
481
  request.resource.data.get('listed', true) == resource.data.get('listed', true)
@@ -440,6 +502,7 @@ service cloud.firestore {
440
502
  && request.resource.data.uid == request.auth.uid
441
503
  && request.resource.data.postId is string
442
504
  && request.resource.data.boardId is string
505
+ && get(postPath(request.resource.data.postId)).data.get('deletionPending', false) == false
443
506
  && (isValidPostLike(likeId) || isValidCommentLike(likeId));
444
507
 
445
508
  allow delete: if isBoardAdmin() || (isSignedIn()
@@ -466,6 +529,8 @@ service cloud.firestore {
466
529
  && request.resource.data.reason in ['spam', 'abuse', 'adult', 'illegal', 'other']
467
530
  && request.resource.data.detail is string
468
531
  && request.resource.data.detail.size() <= 200
532
+ && (request.resource.data.reason != 'other'
533
+ || request.resource.data.detail.matches('(?s).*\\S.*'))
469
534
  && request.resource.data.postTitle is string
470
535
  && request.resource.data.postTitle.size() <= 200
471
536
  && request.resource.data.authorUid is string
@@ -473,14 +538,23 @@ service cloud.firestore {
473
538
  && reportId == request.resource.data.postId + '_' + request.auth.uid
474
539
  && exists(postPath(request.resource.data.postId))
475
540
  && get(postPath(request.resource.data.postId)).data.boardId == request.resource.data.boardId
541
+ && get(postPath(request.resource.data.postId)).data.get('deletionPending', false) == false
542
+ && request.resource.data.authorUid == get(postPath(request.resource.data.postId)).data.authorUid
476
543
  && get(postPath(request.resource.data.postId)).data.authorUid != request.auth.uid;
477
544
 
478
545
  allow update: if isBoardManager(resource.data.boardId)
479
546
  && request.resource.data.diff(resource.data).affectedKeys()
480
- .hasOnly(['status', 'reviewedAt', 'reviewedBy'])
547
+ .hasOnly(['status', 'reviewedAt', 'reviewedBy', 'reviewedByName', 'reviewNote'])
481
548
  && resource.data.status == 'open'
482
549
  && request.resource.data.status in ['dismissed', 'actioned']
483
- && request.resource.data.reviewedBy == request.auth.uid;
550
+ && request.resource.data.reviewedBy == request.auth.uid
551
+ && request.resource.data.reviewedAt == request.time
552
+ && (request.resource.data.get('reviewedByName', null) == null
553
+ || (request.resource.data.reviewedByName is string && request.resource.data.reviewedByName.size() <= 80))
554
+ && request.resource.data.get('reviewNote', '') is string
555
+ && request.resource.data.get('reviewNote', '').size() <= 200
556
+ && (!('reviewNote' in request.resource.data)
557
+ || request.resource.data.reviewNote.matches('(?s).*\\S.*'));
484
558
  allow delete: if false;
485
559
  }
486
560
  }