memi-board 0.5.0 → 0.9.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.
@@ -19,57 +19,130 @@ service cloud.firestore {
19
19
  return isSignedIn() &&
20
20
  get(/databases/$(database)/documents/memiBoardUsers/$(request.auth.uid)).data.role == 'admin';
21
21
  }
22
+ function isStaff() {
23
+ return isSignedIn() &&
24
+ get(/databases/$(database)/documents/memiBoardUsers/$(request.auth.uid)).data.role in ['admin', 'staff'];
25
+ }
26
+ function canWriteCategory(categoryId) {
27
+ let categoryPath = /databases/$(database)/documents/memiBoardSettings/config/categories/$(categoryId);
28
+ let requiredRole = get(categoryPath).data.get('writeRole', 'user');
29
+ return exists(categoryPath)
30
+ && (requiredRole == 'user'
31
+ || (requiredRole == 'staff' && isStaff())
32
+ || (requiredRole == 'admin' && isAdmin()));
33
+ }
22
34
  function isOwner(resource) {
23
35
  return isSignedIn() && request.auth.uid == resource.data.authorUid;
24
36
  }
37
+ function isCommentCountUpdate() {
38
+ return isSignedIn()
39
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['commentCount'])
40
+ && request.resource.data.commentCount is int
41
+ && request.resource.data.commentCount >= 0
42
+ && (request.resource.data.commentCount == resource.data.get('commentCount', 0) + 1
43
+ || request.resource.data.commentCount == resource.data.get('commentCount', 0) - 1);
44
+ }
25
45
 
26
46
  match /memiBoardPosts/{postId} {
27
47
  allow read: if true;
28
48
 
29
49
  allow create: if isSignedIn()
30
50
  && request.resource.data.authorUid == request.auth.uid
31
- && request.resource.data.moderationStatus == 'approved';
51
+ && request.resource.data.moderationStatus == 'approved'
52
+ && request.resource.data.category is string
53
+ && canWriteCategory(request.resource.data.category);
32
54
 
33
- // 작성자 본인 또는 관리자만 수정/삭제 가능
34
- allow update, delete: if isOwner(resource) || isAdmin();
55
+ // 작성자 본인 또는 관리자·스태프만 수정/삭제 가능
56
+ allow update: if isOwner(resource) || isStaff() || isCommentCountUpdate();
57
+ allow delete: if isOwner(resource) || isStaff();
35
58
 
36
59
  match /body/{docId} {
37
60
  allow read: if true;
38
- allow write: if isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId))) || isAdmin();
61
+ allow write: if isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId))) || isStaff();
39
62
  }
40
63
 
41
64
  match /comments/{commentId} {
42
65
  allow read: if true;
43
66
 
67
+ function isLegacyTopLevelComment() {
68
+ return !request.resource.data.keys().hasAny([
69
+ 'parentId', 'rootId', 'depth', 'replyToUid', 'replyToName', 'replyCount', 'isReply'
70
+ ]);
71
+ }
72
+ function isValidTopLevelComment() {
73
+ return request.resource.data.parentId == null
74
+ && request.resource.data.rootId == commentId
75
+ && request.resource.data.depth == 0
76
+ && request.resource.data.replyToUid == null
77
+ && request.resource.data.replyToName == null
78
+ && request.resource.data.replyCount == 0
79
+ && request.resource.data.isReply == false;
80
+ }
81
+ function isValidReply() {
82
+ let parentPath = /databases/$(database)/documents/memiBoardPosts/$(postId)/comments/$(request.resource.data.parentId);
83
+ let rootPath = /databases/$(database)/documents/memiBoardPosts/$(postId)/comments/$(request.resource.data.rootId);
84
+ let parent = get(parentPath).data;
85
+ return request.resource.data.parentId is string
86
+ && request.resource.data.rootId is string
87
+ && request.resource.data.parentId != commentId
88
+ && exists(parentPath)
89
+ && exists(rootPath)
90
+ && get(rootPath).data.get('parentId', null) == null
91
+ && request.resource.data.replyToUid == parent.authorUid
92
+ && request.resource.data.replyCount == 0
93
+ && request.resource.data.isReply == true
94
+ && ((parent.get('parentId', null) == null
95
+ && request.resource.data.rootId == request.resource.data.parentId)
96
+ || (parent.get('parentId', null) != null
97
+ && request.resource.data.rootId == parent.rootId))
98
+ && ((parent.get('depth', 0) < 2
99
+ && request.resource.data.depth == parent.get('depth', 0) + 1)
100
+ || (parent.get('depth', 0) >= 2
101
+ && request.resource.data.depth == 2));
102
+ }
103
+ function isReplyCountUpdate() {
104
+ return isSignedIn()
105
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['replyCount'])
106
+ && request.resource.data.replyCount is int
107
+ && request.resource.data.replyCount >= 0
108
+ && (request.resource.data.replyCount == resource.data.get('replyCount', 0) + 1
109
+ || request.resource.data.replyCount == resource.data.get('replyCount', 0) - 1);
110
+ }
111
+
44
112
  allow create: if isSignedIn()
45
113
  && request.resource.data.authorUid == request.auth.uid
46
- && request.resource.data.moderationStatus == 'approved';
114
+ && request.resource.data.postId == postId
115
+ && request.resource.data.body is string
116
+ && request.resource.data.moderationStatus == 'approved'
117
+ && (isLegacyTopLevelComment()
118
+ || (request.resource.data.keys().hasAll([
119
+ 'parentId', 'rootId', 'depth', 'replyToUid', 'replyToName', 'replyCount', 'isReply'
120
+ ])
121
+ && (isValidTopLevelComment() || isValidReply())));
47
122
 
48
123
  // 댓글 작성자·부모 게시글 작성자·게시판 관리자 모두 삭제 가능.
49
124
  // 부모 글을 삭제할 때 작성자가 다른 사용자의 댓글도 함께 정리할 수 있어야 한다.
50
125
  allow delete: if isOwner(resource)
51
126
  || isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId)))
52
- || isAdmin();
127
+ || isStaff();
53
128
 
54
- // 댓글 수정은 지원하지 않는다 (삭제 후 재작성만 가능) — Editor에서 UI 자체도 제공하지 않음
55
- allow update: if false;
129
+ // 댓글 본문 수정은 지원하지 않는다. 답글 batch의 replyCount ±1만 허용한다.
130
+ allow update: if isReplyCountUpdate();
56
131
  }
57
132
  }
58
133
 
59
134
  // 게시판 역할 문서.
60
- // 본인은 role:'user'로만 생성/유지할 수 있고, 자기 role을 'admin'으로 바꿀 수 없다.
61
- // 관리자 승격은 Firebase 콘솔에서 해당 uid 문서의 role 필드를 직접 'admin'으로 바꿔야 한다
62
- // (이 모듈은 관리자 승격 UI를 제공하지 않는다).
135
+ // 본인은 role:'user'로만 생성하며, admin은 Users UI에서 admin/staff/user를 변경한다.
63
136
  // moderationBlockCount/At: 검열 차단 누적(+1만). 감소·리셋은 admin.
64
137
  match /memiBoardUsers/{uid} {
65
- allow read: if isSignedIn();
138
+ allow read: if isAdmin() || (isSignedIn() && request.auth.uid == uid);
66
139
  allow create: if isSignedIn()
67
140
  && request.auth.uid == uid
68
141
  && request.resource.data.role == 'user'
69
142
  && (!('moderationBlockCount' in request.resource.data)
70
143
  || request.resource.data.moderationBlockCount == 0
71
144
  || request.resource.data.moderationBlockCount == 1);
72
- allow update: if isAdmin()
145
+ allow update: if (isAdmin() && request.resource.data.role in ['admin', 'staff', 'user'])
73
146
  || (isSignedIn()
74
147
  && request.auth.uid == uid
75
148
  && request.resource.data.role == resource.data.role
@@ -94,9 +167,10 @@ service cloud.firestore {
94
167
  match /categories/{categoryId} {
95
168
  allow read: if true;
96
169
  allow create, update: if isAdmin()
97
- && request.resource.data.keys().hasOnly(['label', 'listView', 'order', 'updatedAt'])
170
+ && request.resource.data.keys().hasOnly(['label', 'listView', 'writeRole', 'order', 'updatedAt'])
98
171
  && request.resource.data.label is string
99
172
  && request.resource.data.listView in ['default', 'image', 'video']
173
+ && request.resource.data.writeRole in ['user', 'staff', 'admin']
100
174
  && request.resource.data.order is number;
101
175
  allow delete: if isAdmin();
102
176
  }
@@ -12,9 +12,9 @@ service firebase.storage {
12
12
  function isSignedIn() {
13
13
  return request.auth != null;
14
14
  }
15
- function isAdmin() {
15
+ function isStaff() {
16
16
  return isSignedIn() &&
17
- firestore.get(/databases/(default)/documents/memiBoardUsers/$(request.auth.uid)).data.role == 'admin';
17
+ firestore.get(/databases/(default)/documents/memiBoardUsers/$(request.auth.uid)).data.role in ['admin', 'staff'];
18
18
  }
19
19
  function postExists(postId) {
20
20
  return firestore.exists(/databases/(default)/documents/memiBoardPosts/$(postId));
@@ -28,9 +28,9 @@ service firebase.storage {
28
28
  allow read: if true;
29
29
  // uploadBytesResumable → create + 청크 update 필요 (write)
30
30
  allow write: if isSignedIn()
31
- && (!postExists(postId) || isPostOwner(postId) || isAdmin())
31
+ && (!postExists(postId) || isPostOwner(postId) || isStaff())
32
32
  && request.resource.size < 20 * 1024 * 1024;
33
- allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isAdmin());
33
+ allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isStaff());
34
34
  }
35
35
 
36
36
  // 에디터 본문 이미지 + 썸네일
@@ -39,10 +39,10 @@ service firebase.storage {
39
39
  match /memiBoard/posts/{postId}/images/{allPaths=**} {
40
40
  allow read: if true;
41
41
  allow write: if isSignedIn()
42
- && (!postExists(postId) || isPostOwner(postId) || isAdmin())
42
+ && (!postExists(postId) || isPostOwner(postId) || isStaff())
43
43
  && request.resource.size < 5 * 1024 * 1024
44
44
  && request.resource.contentType.matches('image/(jpeg|png|webp|gif|jpg)');
45
- allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isAdmin());
45
+ allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isStaff());
46
46
  }
47
47
  }
48
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.5.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Firebase 게시판 — core composable + thin Nuxt module(UI SFC). Nuxt 4 + Nuxt UI 호스트에 붙임",
6
6
  "license": "MIT",
@@ -58,6 +58,7 @@
58
58
  "@nuxt/ui": "^4.0.0",
59
59
  "@tiptap/core": ">=2.0.0",
60
60
  "@tiptap/pm": ">=2.0.0",
61
+ "dayjs": "^1.11.0",
61
62
  "firebase": "^12.0.0",
62
63
  "nuxt": ">=3.0.0 || >=4.0.0",
63
64
  "vue": "^3.5.0",
@@ -80,6 +81,7 @@
80
81
  },
81
82
  "devDependencies": {
82
83
  "@nuxt/ui": "^4.9.0",
84
+ "dayjs": "^1.11.21",
83
85
  "firebase": "^12.15.0",
84
86
  "nuxt": "^4.5.1",
85
87
  "nuxt-vuefire": "^1.1.2",