иним чиним чиним чиним а так же новая система друзей и бутстраппера
This commit is contained in:
@@ -770,3 +770,15 @@ async def activate_pass(
|
||||
"message": f"Проходка активирована для {uname}",
|
||||
"role": 1,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pass/my")
|
||||
async def my_pass_status(current_user: dict = Depends(get_current_user)):
|
||||
"""Check if current user has an active pass"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT 1 FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
""", (current_user["id"], time.time())).fetchone()
|
||||
return {"has_active": row is not None}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import structlog
|
||||
import time
|
||||
|
||||
from auth import get_db, get_current_user
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["friends"])
|
||||
|
||||
def init_friends_db():
|
||||
with get_db() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS friendships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
requester_id INTEGER NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(requester_id, target_id),
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id),
|
||||
FOREIGN KEY (target_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_status (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
is_online INTEGER DEFAULT 0,
|
||||
current_pack TEXT DEFAULT '',
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendships_requester ON friendships(requester_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendships_target ON friendships(target_id);
|
||||
""")
|
||||
|
||||
class AddFriendRequest(BaseModel):
|
||||
username: str
|
||||
|
||||
class RemoveFriendRequest(BaseModel):
|
||||
user_id: int
|
||||
|
||||
class AcceptFriendRequest(BaseModel):
|
||||
user_id: int
|
||||
|
||||
class StatusUpdateRequest(BaseModel):
|
||||
online: bool = True
|
||||
current_pack: Optional[str] = None
|
||||
|
||||
@router.post("/friends/add")
|
||||
async def add_friend(
|
||||
req: AddFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute("SELECT id FROM users WHERE username = ?", (req.username,))
|
||||
target = cursor.fetchone()
|
||||
if not target:
|
||||
raise HTTPException(404, "User not found")
|
||||
target_id = target[0]
|
||||
|
||||
if target_id == current_user["id"]:
|
||||
raise HTTPException(400, "Cannot add yourself")
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT status FROM friendships WHERE requester_id = ? AND target_id = ?",
|
||||
(current_user["id"], target_id)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
if existing:
|
||||
if existing[0] == "accepted":
|
||||
raise HTTPException(400, "Already friends")
|
||||
raise HTTPException(400, f"Friend request already {existing[0]}")
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO friendships (requester_id, target_id, status) VALUES (?, ?, 'pending')",
|
||||
(current_user["id"], target_id)
|
||||
)
|
||||
logger.info("Friend request sent", from_user=current_user["id"], to_user=target_id)
|
||||
return {"message": "Friend request sent"}
|
||||
|
||||
@router.post("/friends/accept")
|
||||
async def accept_friend(
|
||||
req: AcceptFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id, requester_id FROM friendships WHERE target_id = ? AND requester_id = ? AND status = 'pending'",
|
||||
(current_user["id"], req.user_id)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "No pending friend request from this user")
|
||||
conn.execute("UPDATE friendships SET status = 'accepted' WHERE id = ?", (row[0],))
|
||||
logger.info("Friend request accepted", from_user=req.user_id, to_user=current_user["id"])
|
||||
return {"message": "Friend request accepted"}
|
||||
|
||||
@router.post("/friends/remove")
|
||||
async def remove_friend(
|
||||
req: RemoveFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id FROM friendships WHERE (requester_id = ? AND target_id = ?) OR (requester_id = ? AND target_id = ?)",
|
||||
(current_user["id"], req.user_id, req.user_id, current_user["id"])
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Not friends")
|
||||
conn.execute("DELETE FROM friendships WHERE id = ?", (row[0],))
|
||||
logger.info("Friend removed", user=current_user["id"], target=req.user_id)
|
||||
return {"message": "Friend removed"}
|
||||
|
||||
@router.get("/friends/list")
|
||||
async def list_friends(current_user: dict = Depends(get_current_user)):
|
||||
friends = []
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, u.role,
|
||||
COALESCE(us.is_online, 0) as online,
|
||||
COALESCE(us.current_pack, '') as current_pack,
|
||||
us.last_seen
|
||||
FROM friendships f
|
||||
JOIN users u ON (CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END) = u.id
|
||||
LEFT JOIN user_status us ON u.id = us.user_id
|
||||
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
|
||||
""", (current_user["id"], current_user["id"], current_user["id"]))
|
||||
|
||||
for row in rows:
|
||||
friends.append({
|
||||
"id": row[0],
|
||||
"username": row[1],
|
||||
"role": row[2],
|
||||
"online": bool(row[3]),
|
||||
"current_pack": row[4],
|
||||
"last_seen": row[5].isoformat() if row[5] else None
|
||||
})
|
||||
|
||||
return {"friends": friends}
|
||||
|
||||
@router.get("/friends/requests")
|
||||
async def list_friend_requests(current_user: dict = Depends(get_current_user)):
|
||||
requests = []
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, u.role, f.created_at
|
||||
FROM friendships f
|
||||
JOIN users u ON f.requester_id = u.id
|
||||
WHERE f.target_id = ? AND f.status = 'pending'
|
||||
""", (current_user["id"],))
|
||||
for row in rows:
|
||||
requests.append({
|
||||
"id": row[0],
|
||||
"username": row[1],
|
||||
"role": row[2],
|
||||
"created_at": row[3].isoformat() if row[3] else None
|
||||
})
|
||||
return {"requests": requests}
|
||||
|
||||
@router.post("/friends/status")
|
||||
async def update_status(
|
||||
req: StatusUpdateRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO user_status (user_id, is_online, current_pack, last_seen)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
is_online = excluded.is_online,
|
||||
current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
|
||||
last_seen = CURRENT_TIMESTAMP
|
||||
""", (current_user["id"], int(req.online), req.current_pack or ""))
|
||||
return {"status": "ok"}
|
||||
+78
-11
@@ -28,6 +28,8 @@ from log_manager import init_logging
|
||||
from auth import get_current_user, router as auth_router, init_db, verify_jwt
|
||||
from roles import Permissions, has_permission
|
||||
from admin_router import router as admin_router
|
||||
from friends import router as friends_router, init_friends_db
|
||||
from playtime import router as playtime_router, init_playtime_db
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -143,6 +145,8 @@ async def lifespan(app: FastAPI):
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
init_db()
|
||||
init_friends_db()
|
||||
init_playtime_db()
|
||||
|
||||
if args.test:
|
||||
await run_test_mode()
|
||||
@@ -754,6 +758,8 @@ async def send_file_async(
|
||||
# Register routers
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(friends_router)
|
||||
app.include_router(playtime_router)
|
||||
|
||||
|
||||
# Monkey patch to catch invalid HTTP requests
|
||||
@@ -842,17 +848,23 @@ async def list_packs(
|
||||
updated_at = meta.get("updated_at")
|
||||
if updated_at and isinstance(updated_at, datetime):
|
||||
updated_at = updated_at.isoformat()
|
||||
|
||||
packs.append({
|
||||
"name": pack_dir.name,
|
||||
"version": meta.get("version", 1),
|
||||
"files_count": len(meta.get("files", {})),
|
||||
"updated_at": updated_at,
|
||||
"minecraft_version": meta.get("minecraft_version", "unknown"),
|
||||
"loader_type": meta.get("loader_type", "vanilla"),
|
||||
"loader_version": meta.get("loader_version"),
|
||||
"asset_index": meta.get("asset_index")
|
||||
})
|
||||
|
||||
desc_path = pack_dir / "description.txt"
|
||||
description = ""
|
||||
if desc_path.exists():
|
||||
description = desc_path.read_text(encoding="utf-8")
|
||||
|
||||
packs.append({
|
||||
"name": pack_dir.name,
|
||||
"version": meta.get("version", 1),
|
||||
"files_count": len(meta.get("files", {})),
|
||||
"updated_at": updated_at,
|
||||
"minecraft_version": meta.get("minecraft_version", "unknown"),
|
||||
"loader_type": meta.get("loader_type", "vanilla"),
|
||||
"loader_version": meta.get("loader_version"),
|
||||
"asset_index": meta.get("asset_index"),
|
||||
"description": description
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}")
|
||||
packs.append({
|
||||
@@ -1698,6 +1710,61 @@ async def get_launcher_full_info():
|
||||
return info
|
||||
|
||||
|
||||
# ====================== НОВОСТИ ======================
|
||||
|
||||
NEWS_DIR = Path(__file__).parent / "news"
|
||||
|
||||
|
||||
@app.get("/news")
|
||||
async def list_news():
|
||||
"""List all news files with their content"""
|
||||
if not NEWS_DIR.exists():
|
||||
return {"news": []}
|
||||
|
||||
news_list = []
|
||||
for f in sorted(NEWS_DIR.iterdir()):
|
||||
if f.is_file() and f.suffix == ".txt":
|
||||
try:
|
||||
content = f.read_text(encoding="utf-8").strip().split("\n")
|
||||
if len(content) >= 4:
|
||||
title = content[0].strip()
|
||||
news_type = content[1].strip()
|
||||
version = content[2].strip()
|
||||
body = "\n".join(content[3:]).strip()
|
||||
news_list.append({
|
||||
"id": f.stem,
|
||||
"title": title,
|
||||
"type": news_type,
|
||||
"version": version,
|
||||
"body": body
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read news file {f.name}: {e}")
|
||||
|
||||
news_list.reverse()
|
||||
return {"news": news_list}
|
||||
|
||||
|
||||
@app.get("/news/{news_id}")
|
||||
async def get_news(news_id: str):
|
||||
"""Get a single news item by ID"""
|
||||
file_path = NEWS_DIR / f"{news_id}.txt"
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "News not found")
|
||||
|
||||
content = file_path.read_text(encoding="utf-8").strip().split("\n")
|
||||
if len(content) < 4:
|
||||
raise HTTPException(400, "Invalid news file format")
|
||||
|
||||
return {
|
||||
"id": file_path.stem,
|
||||
"title": content[0].strip(),
|
||||
"type": content[1].strip(),
|
||||
"version": content[2].strip(),
|
||||
"body": "\n".join(content[3:]).strip()
|
||||
}
|
||||
|
||||
|
||||
# ====================== ПРОКСИ ЭНДПОИНТЫ ======================
|
||||
# Эти эндпоинты позволяют клиентам с сетевыми проблемами
|
||||
# скачивать файлы через сервер Zern
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
from auth import get_db, get_current_user
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["playtime"])
|
||||
|
||||
def init_playtime_db():
|
||||
with get_db() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS playtime (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
pack_name TEXT DEFAULT '',
|
||||
minutes INTEGER DEFAULT 0,
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_playtime_user ON playtime(user_id);
|
||||
""")
|
||||
|
||||
class SyncPlaytimeRequest(BaseModel):
|
||||
minutes: int
|
||||
pack_name: Optional[str] = ""
|
||||
|
||||
@router.post("/playtime/sync")
|
||||
async def sync_playtime(
|
||||
req: SyncPlaytimeRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
if req.minutes < 0 or req.minutes > 60:
|
||||
raise HTTPException(400, "Minutes must be between 0 and 60")
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id, minutes FROM playtime WHERE user_id = ? AND pack_name = ?",
|
||||
(current_user["user_id"], req.pack_name)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE playtime SET minutes = minutes + ?, last_updated = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(req.minutes, existing[0])
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO playtime (user_id, pack_name, minutes) VALUES (?, ?, ?)",
|
||||
(current_user["user_id"], req.pack_name, req.minutes)
|
||||
)
|
||||
logger.info("Playtime synced", user=current_user["user_id"], minutes=req.minutes)
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/playtime/stats")
|
||||
async def get_playtime_stats(current_user: dict = Depends(get_current_user)):
|
||||
total_minutes = 0
|
||||
pack_stats = []
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT COALESCE(SUM(minutes), 0) FROM playtime WHERE user_id = ?",
|
||||
(current_user["user_id"],)
|
||||
)
|
||||
total_minutes = rows.fetchone()[0]
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT pack_name, minutes FROM playtime WHERE user_id = ? AND pack_name != '' ORDER BY minutes DESC",
|
||||
(current_user["user_id"],)
|
||||
)
|
||||
for row in rows:
|
||||
pack_stats.append({
|
||||
"pack_name": row[0],
|
||||
"minutes": row[1]
|
||||
})
|
||||
return {
|
||||
"total_minutes": total_minutes,
|
||||
"total_hours": round(total_minutes / 60, 1),
|
||||
"packs": pack_stats
|
||||
}
|
||||
@@ -72,10 +72,66 @@ class TestPassMyStatus:
|
||||
"""Test /auth/pass/my endpoint."""
|
||||
|
||||
def test_my_pass_no_pass(self, client, logged_in_user):
|
||||
# Route may not exist
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code in (200, 404)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
assert "has_active" in data
|
||||
assert data["has_active"] is False
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": False}
|
||||
|
||||
def test_my_pass_with_pass(self, client, logged_in_user_with_pass):
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
pass_code = f"PASS-{secrets.token_hex(4)}"
|
||||
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
|
||||
conn.execute("""
|
||||
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||
SELECT id, ?, ? FROM users WHERE username = ?
|
||||
""", (pass_code, time.time(), logged_in_user_with_pass["username"]))
|
||||
conn.execute("UPDATE passes SET uses = 1 WHERE code = ?", (pass_code,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user_with_pass["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": True}
|
||||
|
||||
def test_my_pass_after_activation(self, client, logged_in_user):
|
||||
pass_code = f"AFTER-{secrets.token_hex(4)}"
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.post("/auth/pass/activate", json={"pass_code": pass_code},
|
||||
headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": True}
|
||||
|
||||
def test_my_pass_stale_jwt_role(self, client, registered_user):
|
||||
"""Test that /auth/pass/my works even if JWT has stale role.
|
||||
|
||||
Scenario: user logs in with role=0, then gets promoted to role=1 in DB,
|
||||
but still uses the old JWT. The endpoint should check DB directly."""
|
||||
resp = client.post("/auth/login", json=registered_user)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
old_token = data["access_token"]
|
||||
assert data["role"] == 0
|
||||
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute("UPDATE users SET role = 1 WHERE username = ?", (registered_user["username"],))
|
||||
pass_code = f"STALE-{secrets.token_hex(4)}"
|
||||
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
|
||||
conn.execute("""
|
||||
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||
SELECT id, ?, ? FROM users WHERE username = ?
|
||||
""", (pass_code, time.time(), registered_user["username"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(old_token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": True}, "Should detect active pass despite stale JWT role"
|
||||
|
||||
Reference in New Issue
Block a user