Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "pilmate-frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "/Users/itaegyeong/Desktop/naveraiagent/hub/frontend",
"port": 5173
}
]
}
11 changes: 5 additions & 6 deletions backend/account/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,11 @@ def _check_permission(*args, **kwargs):
if not contest_id:
return self.error("Parameter error, contest_id is required")

if not getattr(self, "contest", None):
try:
# use self.contest to avoid query contest again in view.
self.contest = Contest.objects.select_related("created_by").get(id=contest_id, visible=True)
except Contest.DoesNotExist:
return self.error("Contest %s doesn't exist" % contest_id)
try:
# Always reload with visible=True so preloaded contests cannot bypass soft-delete visibility.
self.contest = Contest.objects.select_related("created_by").get(id=contest_id, visible=True)
Comment on lines +116 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate the preloaded contest instead of a request override

When an endpoint has already derived self.contest from a post or comment, this reload still uses a caller-supplied contest_id from the request first. A user can therefore request a participant-visible post in a password-protected or otherwise inaccessible contest while supplying the ID of a contest they can access; the decorator validates the unrelated contest, and can_view_post then permits the actual post. Re-query the preloaded contest with visible=True, or reject a supplied ID that does not match it.

Useful? React with 👍 / 👎.

except Contest.DoesNotExist:
return self.error("Contest %s doesn't exist" % contest_id)

# Anonymous
if not user.is_authenticated:
Expand Down
2 changes: 2 additions & 0 deletions backend/community/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ class Meta:

def get_content_preview(self, obj):
"""게시글 내용의 미리보기(HTML 태그를 제거한 처음 100자)를 반환합니다."""
if not getattr(obj, "can_view", True):
return ""
text = strip_tags(obj.content)
return text[:100] + ("..." if len(text) > 100 else "")

Expand Down
106 changes: 106 additions & 0 deletions backend/community/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ def test_get_contest_host_only_post_list_visibility(self):
self.assertEqual(response.data["data"]["total"], 1)
self.assertEqual(response.data["data"]["results"][0]["visibility"], "CONTEST_HOSTS")
self.assertFalse(response.data["data"]["results"][0]["can_view"])
self.assertEqual(response.data["data"]["results"][0]["content_preview"], "")
self.client.logout()

self.client.force_login(self.admin)
Expand All @@ -256,6 +257,7 @@ def test_get_contest_host_only_post_list_visibility(self):
self.assertEqual(response.data["data"]["total"], 1)
self.assertEqual(response.data["data"]["results"][0]["visibility"], "CONTEST_HOSTS")
self.assertTrue(response.data["data"]["results"][0]["can_view"])
self.assertEqual(response.data["data"]["results"][0]["content_preview"], "Content")

def test_get_contest_host_only_post_detail_visibility(self):
"""주최자 전용 대회 게시글 상세는 주최자만 조회할 수 있다."""
Expand Down Expand Up @@ -306,6 +308,70 @@ def test_get_contest_host_only_post_detail_by_author(self):
self.assertSuccess(response)
self.assertEqual(response.data["data"]["visibility"], "CONTEST_HOSTS")

def test_get_contest_mine_question_filter_is_server_side(self):
"""내 질문 필터는 서버에서 필터링되어 페이지네이션 total도 내 질문 기준으로 계산된다."""
self.client.force_login(self.other_user)
for index in range(12):
response = self.client.post(
self.post_list_url,
{
"title": f"Other Contest Post {index}",
"content": "Content",
"post_type": "ARTICLE",
"contest_id": self.contest["id"],
},
)
self.assertSuccess(response)
self.client.logout()

self.client.force_login(self.user)
response = self.client.post(
self.post_list_url,
{
"title": "My Contest Question",
"content": "Content",
"post_type": "QUESTION",
"contest_id": self.contest["id"],
},
)
self.assertSuccess(response)

response = self.client.get(
self.post_list_url,
{
"contest_id": self.contest["id"],
"post_type": "QUESTION",
"is_mine": "true",
"offset": 0,
"limit": 10,
},
)
self.assertSuccess(response)
self.assertEqual(response.data["data"]["total"], 1)
self.assertEqual(response.data["data"]["results"][0]["title"], "My Contest Question")

def test_get_contest_post_detail_hidden_contest_denied(self):
"""숨김 처리된 대회의 게시글은 preloaded contest를 통해 상세 조회할 수 없다."""
self.client.force_login(self.user)
response = self.client.post(
self.post_list_url,
{
"title": "Contest Post",
"content": "Content",
"post_type": "ARTICLE",
"contest_id": self.contest["id"],
},
)
self.assertSuccess(response)
post_id = response.data["data"]["id"]
contest = Contest.objects.get(id=self.contest["id"])
contest.visible = False
contest.save()

detail_url = self.reverse("community_post_detail", kwargs={"post_id": post_id})
response = self.client.get(detail_url)
self.assertFailed(response, "No permission to access this contest's community")

def test_get_contest_post_list_no_permission(self):
"""대회에 대한 접근 권한이 없는 사용자는 대회 게시글 목록을 조회할 수 없다."""
# 비공개 대회 게시글 생성
Expand Down Expand Up @@ -661,6 +727,46 @@ def test_contest_host_badge_on_comments_in_post_detail(self):
self.assertEqual(user_comment_data["replies"][0]["id"], host_reply.id)
self.assertTrue(user_comment_data["replies"][0]["is_contest_host"])

def test_host_only_post_comments_require_view_permission(self):
"""주최자 전용 게시글 댓글 API는 상세 조회와 같은 열람 권한을 요구한다."""
self.client.force_login(self.admin)
response = self.client.post(
self.post_list_url,
{
"title": "Host Only Contest Post",
"content": "Content",
"post_type": "QUESTION",
"contest_id": self.contest["id"],
"visibility": "CONTEST_HOSTS",
},
)
self.assertSuccess(response)
post_id = response.data["data"]["id"]
post = Post.objects.get(id=post_id)
comment = Comment.objects.create(post=post, author=self.admin, content="Host comment")
comment_url = self.reverse("community_post_comments", kwargs={"post_id": post_id})
comment_detail_url = self.reverse(
"community_comment_detail",
kwargs={"post_id": post_id, "comment_id": comment.id},
)
self.client.logout()

self.client.force_login(self.other_user)
response = self.client.get(comment_url)
self.assertFailed(response, "Only contest hosts or the author can view this post")
response = self.client.post(comment_url, {"content": "Direct comment"})
self.assertFailed(response, "Only contest hosts or the author can view this post")
response = self.client.put(comment_detail_url, {"content": "Updated"})
self.assertFailed(response, "Only contest hosts or the author can view this post")
response = self.client.delete(comment_detail_url)
self.assertFailed(response, "Only contest hosts or the author can view this post")

self.client.logout()
self.client.force_login(self.admin)
response = self.client.get(comment_url)
self.assertSuccess(response)
self.assertEqual(response.data["data"]["total"], 1)

def test_get_comment_list(self):
"""게시글의 댓글 목록을 조회할 수 있다."""
# 댓글 생성
Expand Down
95 changes: 72 additions & 23 deletions backend/community/views/oj.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
PostUpdateSerializer)


def can_view_post(user, post):
if post.visibility != Post.Visibility.CONTEST_HOSTS:
return True
return user.is_authenticated and (post.author == user or user.is_contest_admin(post.contest))


class PostAPIView(APIView):
"""게시글 생성 및 목록 조회를 위한 API"""

Expand Down Expand Up @@ -76,6 +82,7 @@ def get(self, request):
question_status = request.GET.get("question_status")
keyword = request.GET.get("keyword", "").strip()
sort_type = request.GET.get("sort_type")
is_mine = request.GET.get("is_mine") in ["1", "true", "True"]

is_mine_condition = Q(author_id=request.user.id) if request.user.is_authenticated else Q(pk__isnull=True)
can_view_condition = Q(visibility=Post.Visibility.CONTEST_PARTICIPANTS) | Q(contest__isnull=True)
Expand Down Expand Up @@ -115,6 +122,12 @@ def get(self, request):
if post_type:
posts = posts.filter(post_type=post_type)

if is_mine:
if request.user.is_authenticated:
posts = posts.filter(author=request.user)
else:
posts = posts.none()

if question_status:
posts = posts.filter(question_status=question_status)

Expand Down Expand Up @@ -160,9 +173,7 @@ def get(self, request, post_id):
error = self._check_contest_permission(request)
if error:
return self.error("No permission to access this contest's community")
if (post.visibility == Post.Visibility.CONTEST_HOSTS and
post.author != request.user and
not request.user.is_contest_admin(post.contest)):
if not can_view_post(request.user, post):
return self.error("Only contest hosts or the author can view this post")

return self.success(PostDetailSerializer(post).data)
Expand Down Expand Up @@ -217,28 +228,47 @@ def delete(self, request, post_id):
class CommentAPIView(APIView):
"""특정 게시글의 댓글 생성 및 목록 조회를 위한 API"""

def get(self, request, post_id):
"""댓글 목록을 조회합니다."""
@check_contest_permission(check_type="community")
def _check_contest_permission(self, request):
return None

def get_post(self, request, post_id):
try:
Post.objects.get(id=post_id)
post = Post.objects.select_related("author", "contest__created_by").get(id=post_id)
except Post.DoesNotExist:
return self.error("Post does not exist")
return None, self.error("Post does not exist")

if post.contest:
self.contest = post.contest
error = self._check_contest_permission(request)
if error:
return None, self.error("No permission to access this contest's community")
if not can_view_post(request.user, post):
return None, self.error("Only contest hosts or the author can view this post")
return post, None

def get(self, request, post_id):
"""댓글 목록을 조회합니다."""
post, error = self.get_post(request, post_id)
if error:
return error

comments = (
Comment.objects.filter(
post_id=post_id).select_related("author").prefetch_related("replies__author").order_by("created_at"))
post=post).select_related("author", "post__contest").prefetch_related(
"replies__author", "replies__post__contest").order_by("created_at"))

root_comments = comments.filter(parent_comment__isnull=True)
data = self.paginate_data(request, root_comments, CommentSerializer)
data = self.paginate_data(request, root_comments, lambda results, many: CommentSerializer(
results, many=many, context={"contest": post.contest}))
return self.success(data)

@login_required
def post(self, request, post_id):
"""댓글을 생성합니다."""
try:
post = Post.objects.get(id=post_id)
except Post.DoesNotExist:
return self.error("Post does not exist")
post, error = self.get_post(request, post_id)
if error:
return error

content = request.data.get("content")
parent_comment_id = request.data.get("parent_comment_id")
Expand All @@ -259,19 +289,39 @@ def post(self, request, post_id):
content=content,
parent_comment=parent_comment,
)
return self.success(CommentSerializer(comment).data)
return self.success(CommentSerializer(comment, context={"contest": post.contest}).data)


class CommentDetailAPIView(APIView):
"""특정 댓글 수정 및 삭제를 위한 API"""

@check_contest_permission(check_type="community")
def _check_contest_permission(self, request):
return None

def get_comment(self, request, post_id, comment_id):
try:
comment = Comment.objects.select_related(
"author", "post__author", "post__contest__created_by").get(id=comment_id, post_id=post_id)
except Comment.DoesNotExist:
return None, self.error("Comment does not exist")

post = comment.post
if post.contest:
self.contest = post.contest
error = self._check_contest_permission(request)
if error:
return None, self.error("No permission to access this contest's community")
if not can_view_post(request.user, post):
return None, self.error("Only contest hosts or the author can view this post")
return comment, None

@login_required
def put(self, request, post_id, comment_id):
"""댓글을 수정합니다."""
try:
comment = Comment.objects.get(id=comment_id, post_id=post_id)
except Comment.DoesNotExist:
return self.error("Comment does not exist")
comment, error = self.get_comment(request, post_id, comment_id)
if error:
return error

if comment.author != request.user and not request.user.is_super_admin():
return self.error("No permission to edit this comment")
Expand All @@ -282,15 +332,14 @@ def put(self, request, post_id, comment_id):

comment.content = content
comment.save()
return self.success(CommentSerializer(comment).data)
return self.success(CommentSerializer(comment, context={"contest": comment.post.contest}).data)

@login_required
def delete(self, request, post_id, comment_id):
"""댓글을 삭제합니다."""
try:
comment = Comment.objects.get(id=comment_id, post_id=post_id)
except Comment.DoesNotExist:
return self.error("Comment does not exist")
comment, error = self.get_comment(request, post_id, comment_id)
if error:
return error

if comment.author != request.user and not request.user.is_super_admin():
return self.error("No permission to delete this comment")
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/oj/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ export default {
contest_id = null,
keyword = null,
sort_type = null,
is_mine = null,
) {
const params = {
offset,
Expand All @@ -455,6 +456,7 @@ export default {
if (contest_id) params.contest_id = contest_id
if (keyword) params.keyword = keyword
if (sort_type) params.sort_type = sort_type
if (is_mine !== null) params.is_mine = is_mine

return ajax("community/posts", "get", {
params,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/oj/views/contest/ContestHistory.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
</template>

<script>
import { mapGetters } from "vuex"
import api from "@oj/api"
import utils from "@/utils/utils"
import YearDropdown from "./components/YearDropdown"
Expand All @@ -90,6 +91,7 @@ import { CONTEST_STATUS_REVERSE } from "../../../../utils/constants"
export default {
name: "contest-history-list",
computed: {
...mapGetters(["isAuthenticated"]),
CONTEST_STATUS_REVERSE() {
return CONTEST_STATUS_REVERSE
},
Expand Down
Loading