Skip to content

Commit ec5c76d

Browse files
committed
[PR-8] add report system, server hardning
1 parent a256317 commit ec5c76d

19 files changed

Lines changed: 1996 additions & 38 deletions

File tree

backend/.env.example

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,22 @@ GUEST_TOKEN_EXPIRE_HOURS=24
2222
# Set true to auto-create tables on startup (dev only).
2323
# Production should use: uv run alembic upgrade head
2424
CREATE_TABLES_ON_STARTUP=true
25+
26+
# --- Redis (optional) ---
27+
# Leave unset for local dev — in-memory fallback is used automatically.
28+
# Only covers the quick-match queue and reconnect token TTLs.
29+
# NOTE: Redis does NOT make the stack multi-worker-safe. Room, match, and
30+
# connection state are still in-process singletons. Run a single uvicorn
31+
# worker until a future PR moves those stores to a shared backend.
32+
# REDIS_URL=redis://localhost:6379/0
33+
34+
# --- Logging ---
35+
# INFO for normal beta use; DEBUG for step-through local debugging.
36+
LOG_LEVEL=INFO
37+
38+
# --- Admin API ---
39+
# Static bearer token that gates /admin/* endpoints.
40+
# Leave empty to disable admin endpoints (safe default for local dev).
41+
# Set to a strong random string before sharing with moderators:
42+
# python -c "import secrets; print(secrets.token_hex(32))"
43+
ADMIN_TOKEN=

backend/app/api/admin.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""
2+
Minimal admin query API (PR-08).
3+
4+
Intentionally narrow scope: read-only access to reports for beta moderation.
5+
No admin panel, no user management, no stats dashboards.
6+
7+
Authentication: static Bearer token from settings.ADMIN_TOKEN.
8+
Set ADMIN_TOKEN to a strong random string in .env before beta deployment.
9+
Leave it empty (default) to disable the admin endpoints entirely.
10+
11+
Endpoints
12+
---------
13+
GET /admin/reports — list reports with optional filters
14+
GET /admin/reports/{id} — get a single report by ID
15+
PATCH /admin/reports/{id} — update report status (reviewed / dismissed)
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from typing import Optional
21+
22+
from fastapi import APIRouter, Depends, HTTPException, Query, status
23+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
24+
from pydantic import BaseModel
25+
from sqlalchemy.orm import Session
26+
27+
from app.config import settings
28+
from app.database import get_db
29+
from app.models.db import ReportReasonCode, ReportStatus
30+
from app.repositories import report_repo
31+
32+
router = APIRouter(prefix="/admin", tags=["admin"])
33+
34+
_bearer = HTTPBearer(auto_error=False)
35+
36+
37+
def _require_admin(
38+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer),
39+
) -> None:
40+
"""Dependency that verifies the static admin token."""
41+
token = settings.ADMIN_TOKEN
42+
if not token:
43+
raise HTTPException(
44+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
45+
detail="Admin endpoints are disabled (ADMIN_TOKEN not set)",
46+
)
47+
if credentials is None or credentials.credentials != token:
48+
raise HTTPException(
49+
status_code=status.HTTP_401_UNAUTHORIZED,
50+
detail="Invalid or missing admin token",
51+
)
52+
53+
54+
# ---------------------------------------------------------------------------
55+
# Schemas
56+
# ---------------------------------------------------------------------------
57+
58+
59+
class ReportDetail(BaseModel):
60+
id: str
61+
reporter_user_id: Optional[str]
62+
reported_user_id: Optional[str]
63+
reported_connection_id: Optional[str]
64+
reason_code: str
65+
details: Optional[str]
66+
room_code: Optional[str]
67+
match_id: Optional[str]
68+
status: str
69+
created_at: str
70+
71+
model_config = {"from_attributes": True}
72+
73+
74+
class ReportListResponse(BaseModel):
75+
items: list[ReportDetail]
76+
total: int
77+
limit: int
78+
offset: int
79+
80+
81+
class UpdateStatusRequest(BaseModel):
82+
status: str
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Endpoints
87+
# ---------------------------------------------------------------------------
88+
89+
90+
@router.get(
91+
"/reports",
92+
response_model=ReportListResponse,
93+
dependencies=[Depends(_require_admin)],
94+
)
95+
def list_reports(
96+
status: Optional[str] = Query(default=None),
97+
reason_code: Optional[str] = Query(default=None),
98+
limit: int = Query(default=50, ge=1, le=200),
99+
offset: int = Query(default=0, ge=0),
100+
db: Session = Depends(get_db),
101+
) -> ReportListResponse:
102+
"""
103+
Return a paginated list of reports.
104+
105+
Optional query parameters:
106+
- status: open | reviewed | dismissed
107+
- reason_code: emote_spam | abusive_language | cheating |
108+
nickname_violation | other
109+
- limit / offset for pagination
110+
"""
111+
rs: Optional[ReportStatus] = None
112+
if status:
113+
try:
114+
rs = ReportStatus(status)
115+
except ValueError:
116+
valid = ", ".join(s.value for s in ReportStatus)
117+
raise HTTPException(400, detail=f"유효하지 않은 status. 허용 값: {valid}")
118+
119+
rc: Optional[ReportReasonCode] = None
120+
if reason_code:
121+
try:
122+
rc = ReportReasonCode(reason_code)
123+
except ValueError:
124+
valid = ", ".join(r.value for r in ReportReasonCode)
125+
raise HTTPException(400, detail=f"유효하지 않은 reason_code. 허용 값: {valid}")
126+
127+
# Count matching rows before applying limit/offset so `total` reflects the
128+
# true number of matching reports, not just the current page size.
129+
total = report_repo.count_reports(db, status=rs, reason_code=rc)
130+
reports = report_repo.list_reports(db, status=rs, reason_code=rc, limit=limit, offset=offset)
131+
items = [
132+
ReportDetail(
133+
id=r.id,
134+
reporter_user_id=r.reporter_user_id,
135+
reported_user_id=r.reported_user_id,
136+
reported_connection_id=r.reported_connection_id,
137+
reason_code=r.reason_code.value,
138+
details=r.details,
139+
room_code=r.room_code,
140+
match_id=r.match_id,
141+
status=r.status.value,
142+
created_at=r.created_at.isoformat(),
143+
)
144+
for r in reports
145+
]
146+
return ReportListResponse(
147+
items=items,
148+
total=total,
149+
limit=limit,
150+
offset=offset,
151+
)
152+
153+
154+
@router.get(
155+
"/reports/{report_id}",
156+
response_model=ReportDetail,
157+
dependencies=[Depends(_require_admin)],
158+
)
159+
def get_report(report_id: str, db: Session = Depends(get_db)) -> ReportDetail:
160+
report = report_repo.get_by_id(db, report_id)
161+
if report is None:
162+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found")
163+
return ReportDetail(
164+
id=report.id,
165+
reporter_user_id=report.reporter_user_id,
166+
reported_user_id=report.reported_user_id,
167+
reported_connection_id=report.reported_connection_id,
168+
reason_code=report.reason_code.value,
169+
details=report.details,
170+
room_code=report.room_code,
171+
match_id=report.match_id,
172+
status=report.status.value,
173+
created_at=report.created_at.isoformat(),
174+
)
175+
176+
177+
@router.patch(
178+
"/reports/{report_id}",
179+
response_model=ReportDetail,
180+
dependencies=[Depends(_require_admin)],
181+
)
182+
def update_report_status(
183+
report_id: str,
184+
req: UpdateStatusRequest,
185+
db: Session = Depends(get_db),
186+
) -> ReportDetail:
187+
"""Update report status to reviewed or dismissed."""
188+
report = report_repo.get_by_id(db, report_id)
189+
if report is None:
190+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found")
191+
192+
try:
193+
new_status = ReportStatus(req.status)
194+
except ValueError:
195+
valid = ", ".join(s.value for s in ReportStatus)
196+
raise HTTPException(400, detail=f"유효하지 않은 status. 허용 값: {valid}")
197+
198+
updated = report_repo.update_status(db, report, new_status)
199+
return ReportDetail(
200+
id=updated.id,
201+
reporter_user_id=updated.reporter_user_id,
202+
reported_user_id=updated.reported_user_id,
203+
reported_connection_id=updated.reported_connection_id,
204+
reason_code=updated.reason_code.value,
205+
details=updated.details,
206+
room_code=updated.room_code,
207+
match_id=updated.match_id,
208+
status=updated.status.value,
209+
created_at=updated.created_at.isoformat(),
210+
)

backend/app/api/auth.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
)
2323
from app.auth.password import hash_password
2424
from app.database import get_db
25+
from app.models.db import UserStatus
2526
from app.repositories import user_repo
2627
from app.schemas.auth import (
2728
AuthResponse,
@@ -139,6 +140,12 @@ def refresh(req: RefreshRequest, db: Session = Depends(get_db)) -> TokenResponse
139140
detail="User not found",
140141
)
141142

143+
if user.status != UserStatus.ACTIVE:
144+
raise HTTPException(
145+
status_code=status.HTTP_401_UNAUTHORIZED,
146+
detail="Account is not active",
147+
)
148+
142149
return TokenResponse(
143150
access_token=create_access_token(user.id, user.account_type.value),
144151
)

backend/app/api/report.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""
2+
Report submission API (PR-08).
3+
4+
POST /report — authenticated players (registered or guest) submit a report
5+
against another player.
6+
7+
The reporter is identified from the Bearer token so clients cannot spoof it.
8+
The reported player is identified by user_id and/or connection_id from the
9+
game context the client supplies.
10+
11+
Rate-limiting note: a single report per reporter+reported+reason per hour is
12+
not enforced here — the volume for beta is low enough that manual review is
13+
sufficient. Add a time-based dedup query in report_repo if needed later.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import Optional
19+
20+
from fastapi import APIRouter, Depends, HTTPException, status
21+
from pydantic import BaseModel, Field
22+
from sqlalchemy.orm import Session
23+
24+
from app.database import get_db
25+
from app.dependencies import get_current_user
26+
from app.models.db import User
27+
from app.services.report_service import ReportError, submit_report
28+
29+
router = APIRouter(prefix="/report", tags=["report"])
30+
31+
32+
class ReportRequest(BaseModel):
33+
reported_user_id: Optional[str] = None
34+
reported_connection_id: Optional[str] = None
35+
reason_code: str
36+
details: Optional[str] = Field(default=None, max_length=280)
37+
room_code: Optional[str] = Field(default=None, max_length=10)
38+
match_id: Optional[str] = Field(default=None, max_length=36)
39+
40+
41+
class ReportResponse(BaseModel):
42+
report_id: str
43+
status: str
44+
45+
46+
@router.post("", response_model=ReportResponse, status_code=status.HTTP_201_CREATED)
47+
def submit(
48+
req: ReportRequest,
49+
current_user: User = Depends(get_current_user),
50+
db: Session = Depends(get_db),
51+
) -> ReportResponse:
52+
"""
53+
Submit a report against another player.
54+
55+
The caller's identity is derived from the Bearer token — clients cannot
56+
spoof the reporter_user_id.
57+
58+
Returns 400 if the reason_code is unknown or the target is missing.
59+
Returns 201 with the new report_id on success.
60+
"""
61+
try:
62+
report = submit_report(
63+
db,
64+
reporter_user_id=current_user.id,
65+
reported_user_id=req.reported_user_id,
66+
reported_connection_id=req.reported_connection_id,
67+
reason_code=req.reason_code,
68+
details=req.details,
69+
room_code=req.room_code,
70+
match_id=req.match_id,
71+
)
72+
except ReportError as exc:
73+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
74+
75+
return ReportResponse(report_id=report.id, status=report.status.value)

backend/app/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ class Settings(BaseSettings):
6868
EMOTE_BURST_WINDOW_SECONDS: int = 10
6969
EMOTE_SAME_REPEAT_CAP: int = 2
7070

71+
# Beta ops (PR-08).
72+
# LOG_LEVEL: root log level — INFO for beta, DEBUG for local stepping.
73+
# ADMIN_TOKEN: static bearer token that gates /admin/* endpoints.
74+
# Leave empty to disable admin endpoints (safe default for local dev).
75+
# Set to a strong random string before beta deployment.
76+
LOG_LEVEL: str = "INFO"
77+
ADMIN_TOKEN: str = ""
78+
7179
model_config = SettingsConfigDict(
7280
env_file=".env",
7381
env_file_encoding="utf-8",

0 commit comments

Comments
 (0)