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.
- package/README.md +11 -8
- package/dist/composables/useMemiBoardAuth.d.ts +2 -0
- package/dist/composables/useMemiBoardComments.d.ts +81 -4
- package/dist/composables/useMemiBoardUsers.d.ts +11 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +501 -265
- package/dist/index.js.map +1 -1
- package/dist/module.mjs +4 -0
- package/dist/module.mjs.map +1 -1
- package/dist/runtime/components/CommentForm.vue +21 -6
- package/dist/runtime/components/CommentItem.vue +73 -0
- package/dist/runtime/components/CommentList.vue +83 -39
- package/dist/runtime/components/CommentSkeleton.vue +12 -0
- package/dist/runtime/components/CommentThread.vue +121 -0
- package/dist/runtime/components/Detail.vue +3 -6
- package/dist/runtime/components/Settings.vue +24 -4
- package/dist/runtime/components/Users.vue +64 -0
- package/dist/types.d.ts +23 -2
- package/docs/firestore.rules.example +88 -14
- package/docs/storage.rules.example +6 -6
- package/package.json +3 -1
|
@@ -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
|
|
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))) ||
|
|
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.
|
|
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
|
-
||
|
|
127
|
+
|| isStaff();
|
|
53
128
|
|
|
54
|
-
// 댓글 수정은 지원하지
|
|
55
|
-
allow update: if
|
|
129
|
+
// 댓글 본문 수정은 지원하지 않는다. 답글 batch의 replyCount ±1만 허용한다.
|
|
130
|
+
allow update: if isReplyCountUpdate();
|
|
56
131
|
}
|
|
57
132
|
}
|
|
58
133
|
|
|
59
134
|
// 게시판 역할 문서.
|
|
60
|
-
// 본인은 role:'user'로만
|
|
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
|
|
15
|
+
function isStaff() {
|
|
16
16
|
return isSignedIn() &&
|
|
17
|
-
firestore.get(/databases/(default)/documents/memiBoardUsers/$(request.auth.uid)).data.role
|
|
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) ||
|
|
31
|
+
&& (!postExists(postId) || isPostOwner(postId) || isStaff())
|
|
32
32
|
&& request.resource.size < 20 * 1024 * 1024;
|
|
33
|
-
allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) ||
|
|
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) ||
|
|
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) ||
|
|
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.
|
|
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",
|