ForgeFix и новые фичи в интерфейсе
This commit is contained in:
@@ -9,6 +9,8 @@ import hashlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pack_manager import PACKS_DIR, load_packs_config, get_pack_presets, toggle_pack_disabled
|
||||
|
||||
from auth import get_db, require_role, log_audit, get_current_user, hash_password
|
||||
from roles import (
|
||||
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
|
||||
@@ -877,6 +879,58 @@ async def admin_delete_news(
|
||||
return {"success": True, "id": target.stem}
|
||||
|
||||
|
||||
# ====================== PACK MANAGEMENT (ELDER+) ======================
|
||||
|
||||
@router.get("/packs")
|
||||
async def admin_list_packs(
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
):
|
||||
"""List all packs with disabled status"""
|
||||
packs = []
|
||||
packs_config = load_packs_config()
|
||||
|
||||
packs_dir = Path("packs")
|
||||
if packs_dir.exists():
|
||||
for pack_dir in packs_dir.iterdir():
|
||||
if pack_dir.is_dir():
|
||||
entry = packs_config.get(pack_dir.name, {})
|
||||
packs.append({
|
||||
"name": pack_dir.name,
|
||||
"disabled": entry.get("disabled", False),
|
||||
"presets": get_pack_presets(pack_dir.name)
|
||||
})
|
||||
|
||||
return {"packs": packs}
|
||||
|
||||
|
||||
@router.post("/packs/{pack_name}/toggle")
|
||||
async def admin_toggle_pack(
|
||||
pack_name: str,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Toggle pack disabled/enabled status"""
|
||||
pack_path = PACKS_DIR / pack_name
|
||||
if not pack_path.exists() or not pack_path.is_dir():
|
||||
raise HTTPException(404, "Pack not found")
|
||||
|
||||
new_state = toggle_pack_disabled(pack_name)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
log_audit(
|
||||
current_user["id"],
|
||||
"pack_toggle",
|
||||
f"{'Disabled' if new_state else 'Enabled'} pack {pack_name}",
|
||||
ip
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pack_name": pack_name,
|
||||
"disabled": new_state
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_my_info(current_user: dict = Depends(get_current_user)):
|
||||
"""Информация о текущем пользователе с правами"""
|
||||
|
||||
+49
-10
@@ -19,7 +19,7 @@ from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
||||
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR, is_pack_disabled, get_pack_presets, get_merged_manifest
|
||||
from models import PackMeta
|
||||
from middleware import LoggingMiddleware
|
||||
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode, run_sync_mode
|
||||
@@ -1026,7 +1026,9 @@ async def list_packs(
|
||||
"loader_type": meta.get("loader_type", "vanilla"),
|
||||
"loader_version": meta.get("loader_version"),
|
||||
"asset_index": meta.get("asset_index"),
|
||||
"description": description
|
||||
"description": description,
|
||||
"disabled": is_pack_disabled(pack_dir.name),
|
||||
"presets": get_pack_presets(pack_dir.name)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}")
|
||||
@@ -1047,7 +1049,8 @@ async def list_packs(
|
||||
async def get_pack_diff(
|
||||
pack_name: str,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user) # Добавляем зависимость
|
||||
current_user: dict = Depends(get_current_user),
|
||||
preset: Optional[str] = None
|
||||
):
|
||||
"""Client sends: { "mods/jei.jar": "sha256_hash", ... }
|
||||
Server returns diff information
|
||||
@@ -1060,6 +1063,10 @@ async def get_pack_diff(
|
||||
detail="Для скачивания сборок требуется активная проходка. Обратитесь к администратору."
|
||||
)
|
||||
|
||||
# Check if pack is disabled
|
||||
if is_pack_disabled(pack_name):
|
||||
raise HTTPException(403, "Pack is disabled by administrator")
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Читаем тело запроса
|
||||
@@ -1071,13 +1078,17 @@ async def get_pack_diff(
|
||||
|
||||
logger.info("Received diff request",
|
||||
pack=pack_name,
|
||||
preset=preset,
|
||||
client_files_count=len(body),
|
||||
client_ip=client_ip)
|
||||
|
||||
try:
|
||||
meta = get_cached_manifest(pack_name)
|
||||
if not meta:
|
||||
meta = await scan_pack(pack_name)
|
||||
if preset:
|
||||
meta = get_merged_manifest(pack_name, preset)
|
||||
else:
|
||||
meta = get_cached_manifest(pack_name)
|
||||
if not meta:
|
||||
meta = await scan_pack(pack_name)
|
||||
except FileNotFoundError:
|
||||
logger.warning("Pack not found", pack=pack_name, client_ip=client_ip)
|
||||
raise HTTPException(404, "Pack not found")
|
||||
@@ -1095,6 +1106,8 @@ async def get_pack_diff(
|
||||
client_hash = body.get(path)
|
||||
if client_hash is None or client_hash != entry.hash:
|
||||
url = f"/pack/{pack_name}/file/{path}"
|
||||
if preset:
|
||||
url += f"?preset={preset}"
|
||||
to_download.append({
|
||||
"path": path,
|
||||
"url": url,
|
||||
@@ -1127,6 +1140,9 @@ async def get_pack_diff(
|
||||
@app.get("/pack/{pack_name}")
|
||||
async def get_pack_manifest(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""Get pack manifest with caching"""
|
||||
if is_pack_disabled(pack_name):
|
||||
raise HTTPException(403, "Pack is disabled by administrator")
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
cached_meta = get_cached_manifest(pack_name)
|
||||
@@ -1166,13 +1182,34 @@ async def get_pack_manifest(pack_name: str, request: Request, current_user: dict
|
||||
|
||||
|
||||
@app.get("/pack/{pack_name}/file/{file_path:path}")
|
||||
async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""Get a file from a pack"""
|
||||
async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user), preset: Optional[str] = None):
|
||||
"""Get a file from a pack. If preset is specified, try preset dir first, fall back to base."""
|
||||
if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
|
||||
raise HTTPException(403, "Requires active pass")
|
||||
full_path = PACKS_DIR / pack_name / file_path
|
||||
if is_pack_disabled(pack_name):
|
||||
raise HTTPException(403, "Pack is disabled by administrator")
|
||||
|
||||
client_ip = request.client.host if request.client else None
|
||||
|
||||
# If preset specified, try preset path first
|
||||
if preset:
|
||||
preset_path = PACKS_DIR / pack_name / "presets" / preset / file_path
|
||||
preset_resolved = preset_path.resolve()
|
||||
preset_root = (PACKS_DIR / pack_name).resolve()
|
||||
try:
|
||||
if str(preset_resolved).startswith(str(preset_root)) and preset_resolved.is_file():
|
||||
full_path = preset_resolved
|
||||
else:
|
||||
full_path = None
|
||||
except (ValueError, OSError):
|
||||
full_path = None
|
||||
else:
|
||||
full_path = None
|
||||
|
||||
# Fall back to base path
|
||||
if full_path is None:
|
||||
full_path = PACKS_DIR / pack_name / file_path
|
||||
|
||||
# Security: prevent path traversal
|
||||
try:
|
||||
full_path = full_path.resolve()
|
||||
@@ -2245,7 +2282,9 @@ async def proxy_download(request: Request):
|
||||
"resources.download.minecraft.net",
|
||||
"maven.minecraftforge.net",
|
||||
"files.minecraftforge.net",
|
||||
"maven.neoforged.net"
|
||||
"maven.neoforged.net",
|
||||
"api.zernmc.ru",
|
||||
"api.zernmc.online"
|
||||
]
|
||||
|
||||
# Проверяем, что URL ведет на разрешенный домен
|
||||
|
||||
+15
-12
@@ -154,24 +154,27 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
_stats["blocked"] += 1
|
||||
return Response(status_code=404, content="")
|
||||
|
||||
# Check rate limit
|
||||
if not check_rate_limit(client_ip):
|
||||
_stats["rate_limited"] += 1
|
||||
# Periodic stats logging instead of every warning
|
||||
if time.time() - _stats_last_log > STATS_LOG_INTERVAL:
|
||||
logger.warning(f"Stats: {_stats}")
|
||||
_stats_last_log = time.time()
|
||||
return Response(status_code=429, content="Too many requests")
|
||||
path = request.url.path
|
||||
|
||||
# Skip rate limiting for pack file downloads (direct high-speed serving)
|
||||
if path.startswith("/pack/") and "/file/" in path:
|
||||
is_file_download = True
|
||||
else:
|
||||
is_file_download = False
|
||||
|
||||
# Check rate limit
|
||||
if not check_rate_limit(client_ip):
|
||||
_stats["rate_limited"] += 1
|
||||
if time.time() - _stats_last_log > STATS_LOG_INTERVAL:
|
||||
logger.warning(f"Stats: {_stats}")
|
||||
_stats_last_log = time.time()
|
||||
return Response(status_code=429, content="Too many requests")
|
||||
|
||||
# Check suspicious path (silent 404 for bots)
|
||||
path = request.url.path
|
||||
if is_suspicious_path(path):
|
||||
# Return 404 without logging - confuse the bots
|
||||
return Response(status_code=404, content="")
|
||||
|
||||
# Skip logging for large file downloads (don't spam logs)
|
||||
is_file_download = path.startswith("/pack/") and "/file/" in path
|
||||
|
||||
# Track total requests for stats
|
||||
_stats["total"] += 1
|
||||
|
||||
|
||||
+105
-5
@@ -1,9 +1,10 @@
|
||||
import hashlib
|
||||
import os
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional, Dict, List
|
||||
import structlog
|
||||
import asyncio
|
||||
import aiofiles
|
||||
@@ -19,6 +20,46 @@ DATA_DIR.mkdir(exist_ok=True)
|
||||
# Cache for loaded manifests
|
||||
_manifest_cache: Dict[str, PackMeta] = {}
|
||||
|
||||
# Pack config cache
|
||||
_packs_config: Optional[Dict] = None
|
||||
|
||||
def load_packs_config() -> dict:
|
||||
global _packs_config
|
||||
if _packs_config is not None:
|
||||
return _packs_config
|
||||
config_path = DATA_DIR / "packs_config.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
_packs_config = json.loads(config_path.read_text())
|
||||
except Exception:
|
||||
_packs_config = {}
|
||||
else:
|
||||
_packs_config = {}
|
||||
return _packs_config
|
||||
|
||||
def save_packs_config(config: dict):
|
||||
global _packs_config
|
||||
_packs_config = config
|
||||
config_path = DATA_DIR / "packs_config.json"
|
||||
config_path.write_text(json.dumps(config, indent=2))
|
||||
|
||||
def is_pack_disabled(pack_name: str) -> bool:
|
||||
config = load_packs_config()
|
||||
return config.get(pack_name, {}).get("disabled", False)
|
||||
|
||||
def toggle_pack_disabled(pack_name: str) -> bool:
|
||||
config = load_packs_config()
|
||||
entry = config.setdefault(pack_name, {})
|
||||
entry["disabled"] = not entry.get("disabled", False)
|
||||
save_packs_config(config)
|
||||
return entry["disabled"]
|
||||
|
||||
def get_pack_presets(pack_name: str) -> List[str]:
|
||||
presets_dir = PACKS_DIR / pack_name / "presets"
|
||||
if not presets_dir.exists() or not presets_dir.is_dir():
|
||||
return []
|
||||
return sorted([d.name for d in presets_dir.iterdir() if d.is_dir()])
|
||||
|
||||
def get_cached_manifest(pack_name: str) -> Optional[PackMeta]:
|
||||
"""Get manifest from memory cache if available"""
|
||||
return _manifest_cache.get(pack_name)
|
||||
@@ -66,10 +107,11 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
new_files: Dict[str, FileEntry] = {}
|
||||
changed = False
|
||||
|
||||
# Get ignored directories
|
||||
ignored_dirs = current_meta.ignored_dirs if current_meta else [
|
||||
# Get ignored directories (always use current list, not cached from meta)
|
||||
ignored_dirs = [
|
||||
"resourcepacks", "shaderpacks", "saves", "logs",
|
||||
"crash-reports", "screenshots", "journeymap", "config"
|
||||
"crash-reports", "screenshots", "journeymap",
|
||||
"presets"
|
||||
]
|
||||
|
||||
# Walk through pack directory
|
||||
@@ -81,10 +123,19 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
file_path = Path(root) / file
|
||||
rel_path = file_path.relative_to(pack_path).as_posix()
|
||||
|
||||
# Skip metadata files that should not be part of the pack
|
||||
if file in ("description.txt", "instance.json"):
|
||||
continue
|
||||
|
||||
# Skip if in ignored directory
|
||||
if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored_dirs):
|
||||
continue
|
||||
|
||||
# Validate .jar files — skip corrupted ones
|
||||
if file.endswith(".jar") and not _is_valid_zip(file_path):
|
||||
logger.warning(f"Skipping corrupt jar in pack {pack_name}: {rel_path}")
|
||||
continue
|
||||
|
||||
stat = file_path.stat()
|
||||
file_hash = await calculate_sha256(file_path)
|
||||
|
||||
@@ -153,4 +204,53 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
return current_meta
|
||||
|
||||
# Should not happen
|
||||
raise Exception(f"Failed to scan pack {pack_name}")
|
||||
raise Exception(f"Failed to scan pack {pack_name}")
|
||||
|
||||
def _is_valid_zip(path: Path) -> bool:
|
||||
"""Check if a file is a valid ZIP archive"""
|
||||
try:
|
||||
with zipfile.ZipFile(path, 'r') as zf:
|
||||
return zf.testzip() is None
|
||||
except zipfile.BadZipFile:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_merged_manifest(pack_name: str, preset: str) -> PackMeta:
|
||||
"""Get manifest merged with preset files. Preset files override base files."""
|
||||
base_meta = get_cached_manifest(pack_name)
|
||||
if not base_meta:
|
||||
raise FileNotFoundError(f"Pack {pack_name} not found")
|
||||
|
||||
preset_dir = PACKS_DIR / pack_name / "presets" / preset
|
||||
if not preset_dir.exists() or not preset_dir.is_dir():
|
||||
raise FileNotFoundError(f"Preset {preset} not found for pack {pack_name}")
|
||||
|
||||
merged_files = dict(base_meta.files)
|
||||
|
||||
for root, dirs, files in os.walk(preset_dir):
|
||||
for file in files:
|
||||
file_path = Path(root) / file
|
||||
rel_path = file_path.relative_to(preset_dir).as_posix()
|
||||
stat = file_path.stat()
|
||||
file_hash = calculate_sha256_sync(file_path)
|
||||
merged_files[rel_path] = FileEntry(
|
||||
path=rel_path,
|
||||
hash=file_hash,
|
||||
size=stat.st_size,
|
||||
added_at=datetime.utcfromtimestamp(stat.st_ctime),
|
||||
modified_at=datetime.utcfromtimestamp(stat.st_mtime)
|
||||
)
|
||||
|
||||
return PackMeta(
|
||||
pack_name=base_meta.pack_name,
|
||||
version=base_meta.version,
|
||||
files=merged_files,
|
||||
updated_at=base_meta.updated_at,
|
||||
ignored_dirs=base_meta.ignored_dirs,
|
||||
minecraft_version=base_meta.minecraft_version,
|
||||
loader_type=base_meta.loader_type,
|
||||
loader_version=base_meta.loader_version,
|
||||
asset_index=base_meta.asset_index
|
||||
)
|
||||
Reference in New Issue
Block a user