ForgeFix и новые фичи в интерфейсе

This commit is contained in:
SashegDev
2026-07-13 13:16:51 +00:00
parent 0d61ad1107
commit 201269efea
23 changed files with 1166 additions and 327 deletions
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
)