Forge, NeoForge, Fabric
+Every major mod loader handled automatically. Libraries are prefetched and verified, so installs just work — even on slow networks.
+diff --git a/server/main.py b/server/main.py index c268345..3981435 100644 --- a/server/main.py +++ b/server/main.py @@ -12,7 +12,8 @@ import json import structlog from cachetools import TTLCache from fastapi import Depends, FastAPI, HTTPException, Request, Response -from fastapi.responses import FileResponse, JSONResponse, StreamingResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol # Disable httpx debug logging @@ -46,6 +47,12 @@ BUILDS_DIR = Path("builds") VERSIONS_DIR = BUILDS_DIR / "versions" WHITELIST_DIR = Path(__file__).parent / "whitelist" +# Website (landing) static files +SITE_DIR = Path(__file__).parent / "site" + +# SkinRestorer skins (shared with TG bot) +SKINS_DIR = Path("/root/python/site/skins") + # Mirror configuration LAUNCHER_MIRRORS = { "main": "https://api.zern.cc", # primary @@ -838,7 +845,7 @@ class CacheControlMiddleware: path = scope.get("path", "") # Skip caching for dynamic endpoints - skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install"]) + skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install", "/skin/"]) if skip_cache: await self.app(scope, receive, send) return @@ -953,6 +960,12 @@ app.include_router(admin_router) app.include_router(friends_router) app.include_router(playtime_router) +# Static assets for the landing site +for sub in ("css", "js", "img"): + mount_dir = SITE_DIR / sub + mount_dir.mkdir(parents=True, exist_ok=True) + app.mount(f"/{sub}", StaticFiles(directory=mount_dir), name=f"site-{sub}") + # Monkey patch to catch invalid HTTP requests original_data_received = HttpToolsProtocol.data_received @@ -990,8 +1003,10 @@ HttpToolsProtocol.data_received = patched_data_received @app.get("/") async def root(): - """Root endpoint""" - logger.info("Root endpoint accessed") + """Root endpoint - serves the launcher landing site""" + index_path = SITE_DIR / "index.html" + if index_path.exists(): + return HTMLResponse(index_path.read_text(encoding="utf-8")) return { "status": "ok", "message": "ZernMC Launcher Server is running", @@ -1000,6 +1015,45 @@ async def root(): } +@app.get("/skin/{filename}") +@app.head("/skin/{filename}") +async def get_skin(filename: str, request: Request): + """Serve SkinRestorer skins (compatibility with TG bot direct links)""" + if not filename or ".." in filename or "/" in filename or "\\" in filename: + raise HTTPException(status_code=404, detail="Скин не найден") + + filepath = SKINS_DIR / filename + + if not filepath.is_file(): + raise HTTPException(status_code=404, detail="Скин не найден") + + try: + content = filepath.read_bytes() + size = len(content) + + headers = { + "Content-Length": str(size), + "Content-Type": "image/png", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + "Access-Control-Allow-Origin": "*", + "Content-Encoding": "identity", + } + + if request.method == "HEAD": + return Response(status_code=200, headers=headers) + + return Response( + content=content, + media_type="image/png", + headers=headers + ) + except Exception as e: + logger.error("Skin read error", error=str(e)) + raise HTTPException(status_code=500, detail="Ошибка чтения скина") + + @app.get("/health") async def health(): """Health check endpoint""" diff --git a/server/middleware.py b/server/middleware.py index 63d6c2b..7564c3f 100644 --- a/server/middleware.py +++ b/server/middleware.py @@ -159,6 +159,9 @@ class LoggingMiddleware(BaseHTTPMiddleware): # Skip rate limiting for pack file downloads (direct high-speed serving) if path.startswith("/pack/") and "/file/" in path: is_file_download = True + elif path.startswith(("/skin/", "/css/", "/js/", "/img/")): + # SkinRestorer skins + landing site assets must not be rate-limited + is_file_download = True else: is_file_download = False diff --git a/server/site/css/style.css b/server/site/css/style.css new file mode 100644 index 0000000..cab39f1 --- /dev/null +++ b/server/site/css/style.css @@ -0,0 +1,613 @@ +:root { + --accent: #e94560; + --accent-soft: #ff6b6b; + --bg-deep: #07070a; + --bg-surface: #0c0c12; + --bg-card: #16161f; + --text: #eeeef0; + --text-secondary: #88889a; + --border: rgba(255, 255, 255, 0.06); + --font: 'Onest', system-ui, -apple-system, 'Segoe UI', sans-serif; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + font-family: var(--font); + background: var(--bg-deep); + color: var(--text); + line-height: 1.55; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +a { color: inherit; text-decoration: none; } +img { display: block; } +ul { list-style: none; } + +::selection { background: var(--accent); color: #fff; } + +/* ---------- Nav ---------- */ + +.nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + transition: background .3s ease, border-color .3s ease, backdrop-filter .3s ease; + border-bottom: 1px solid transparent; +} + +.nav.scrolled { + background: rgba(7, 7, 10, 0.72); + backdrop-filter: blur(14px); + border-bottom-color: var(--border); +} + +.nav-inner { + max-width: 1200px; + margin: 0 auto; + padding: 18px 32px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.nav-brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + font-size: 18px; + letter-spacing: 0.3px; +} + +.nav-brand img { border-radius: 6px; } + +.nav-links { + display: flex; + gap: 36px; + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); +} + +.nav-links a { position: relative; transition: color .2s ease; } + +.nav-links a::after { + content: ''; + position: absolute; + left: 0; + bottom: -6px; + width: 0; + height: 2px; + background: var(--accent); + transition: width .25s ease; +} + +.nav-links a:hover { color: var(--text); } +.nav-links a:hover::after { width: 100%; } + +.nav-cta { + font-size: 14px; + font-weight: 600; + padding: 10px 22px; + border-radius: 8px; + background: var(--accent); + color: #fff; + transition: background .2s ease, transform .15s ease; +} + +.nav-cta:hover { background: var(--accent-soft); transform: translateY(-1px); } + +/* ---------- Hero ---------- */ + +.hero { + position: relative; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + overflow: hidden; + padding: 120px 32px 80px; + background: + radial-gradient(1000px 500px at 50% -10%, rgba(233, 69, 96, 0.12), transparent 60%), + var(--bg-deep); +} + +.hero-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px); + background-size: 64px 64px; + mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 75%); + -webkit-mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 75%); +} + +.hero-glow { + position: absolute; + width: 560px; + height: 560px; + left: 50%; + top: 42%; + transform: translate(-50%, -50%); + background: radial-gradient(circle, rgba(233, 69, 96, 0.16), transparent 65%); + filter: blur(20px); + animation: pulse 6s ease-in-out infinite; + pointer-events: none; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.6; transform: translate(-50%, -50%) scale(1); } + 50% { opacity: 1; transform: translate(-50%, -50%) scale(1.12); } +} + +.hero-diamond { + position: absolute; + border: 1px solid rgba(233, 69, 96, 0.35); + transform: rotate(45deg); + opacity: 0; + animation: float-in 2s ease forwards; +} + +.hero-diamond--1 { + width: 260px; height: 260px; + right: 8%; top: 16%; + animation-delay: .4s; +} + +.hero-diamond--2 { + width: 150px; height: 150px; + left: 10%; top: 24%; + border-color: rgba(255, 255, 255, 0.1); + animation-delay: .7s; +} + +.hero-diamond--3 { + width: 90px; height: 90px; + left: 24%; bottom: 14%; + animation-delay: 1s; +} + +@keyframes float-in { + 0% { opacity: 0; transform: rotate(45deg) scale(0.6); } + 100% { opacity: 1; transform: rotate(45deg) scale(1); } +} + +.hero-inner { position: relative; z-index: 2; max-width: 900px; will-change: transform; } + +.hero-kicker { + font-size: 13px; + font-weight: 600; + letter-spacing: 3px; + color: var(--accent); + margin-bottom: 24px; + text-transform: uppercase; +} + +.hero-title { + font-size: clamp(52px, 9vw, 108px); + font-weight: 900; + line-height: 0.98; + letter-spacing: -2px; + margin-bottom: 28px; +} + +.hero-title-accent { + background: linear-gradient(120deg, var(--accent-soft), var(--accent)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.hero-sub { + font-size: clamp(16px, 2.2vw, 19px); + color: var(--text-secondary); + max-width: 560px; + margin: 0 auto 40px; + font-weight: 300; +} + +.hero-actions { + display: flex; + gap: 16px; + justify-content: center; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 15px 30px; + border-radius: 10px; + font-family: inherit; + font-size: 16px; + font-weight: 600; + cursor: pointer; + border: none; + transition: transform .15s ease, box-shadow .2s ease, background .2s ease, border-color .2s ease; +} + +.btn:hover { transform: translateY(-2px); } + +.btn-primary { + background: var(--accent); + color: #fff; + box-shadow: 0 8px 30px rgba(233, 69, 96, 0.35); +} + +.btn-primary:hover { + background: var(--accent-soft); + box-shadow: 0 10px 36px rgba(233, 69, 96, 0.45); +} + +.btn-ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.btn-ghost:hover { border-color: rgba(255, 255, 255, 0.18); background: rgba(255, 255, 255, 0.03); } + +.btn-block { width: 100%; } + +.hero-meta { + margin-top: 26px; + font-size: 13px; + color: var(--text-secondary); + letter-spacing: 0.5px; +} + +.scroll-hint { + position: absolute; + bottom: 36px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + z-index: 2; + color: var(--text-secondary); + font-size: 12px; + letter-spacing: 2px; + text-transform: uppercase; + will-change: transform; +} + +.scroll-hint-line { + width: 1px; + height: 44px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.scroll-hint-line i { + display: block; + width: 1px; + height: 100%; + background: linear-gradient(180deg, var(--accent), transparent); + animation: scroll-line 1.8s ease-in-out infinite; +} + +@keyframes scroll-line { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(100%); } +} + +/* ---------- Sections ---------- */ + +.section { + max-width: 1200px; + margin: 0 auto; + padding: 120px 32px; + position: relative; +} + +.section-head { margin-bottom: 64px; } + +.section-head--center { text-align: center; } +.section-head--center .section-sub { margin-left: auto; margin-right: auto; } + +.section-index { + display: inline-block; + font-size: 13px; + font-weight: 600; + letter-spacing: 2px; + color: var(--accent); + text-transform: uppercase; + margin-bottom: 16px; +} + +.section-title { + font-size: clamp(34px, 5vw, 56px); + font-weight: 800; + line-height: 1.05; + letter-spacing: -1px; + margin-bottom: 16px; +} + +.accent { color: var(--accent); } + +.section-sub { + font-size: 17px; + color: var(--text-secondary); + max-width: 520px; + font-weight: 300; +} + +/* ---------- Features ---------- */ + +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} + +.feature-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 34px 30px; + position: relative; + overflow: hidden; + transition: transform .25s ease, border-color .25s ease, background .25s ease; +} + +.feature-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--accent), transparent); + opacity: 0; + transition: opacity .3s ease; +} + +.feature-card:hover { + transform: translateY(-4px); + border-color: rgba(233, 69, 96, 0.35); + background: #1a1a25; +} + +.feature-card:hover::before { opacity: 1; } + +.feature-num { + position: absolute; + top: 26px; + right: 28px; + font-size: 13px; + font-weight: 700; + color: rgba(255, 255, 255, 0.12); +} + +.feature-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(233, 69, 96, 0.12); + color: var(--accent); + margin-bottom: 22px; +} + +.feature-card h3 { + font-size: 20px; + font-weight: 700; + margin-bottom: 10px; +} + +.feature-card p { + font-size: 15px; + color: var(--text-secondary); + font-weight: 300; +} + +/* ---------- Download ---------- */ + +.download { + background: + radial-gradient(800px 400px at 50% 0%, rgba(233, 69, 96, 0.07), transparent 65%), + var(--bg-surface); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.download-inner { max-width: 720px; margin: 0 auto; } + +.download-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 20px; + padding: 36px; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4); +} + +.download-card-top { + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 28px; +} + +.download-card-top img { border-radius: 12px; } + +.download-name { + font-size: 22px; + font-weight: 700; +} + +.download-desc { font-size: 14px; color: var(--text-secondary); } + +.download-mirrors { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + margin-top: 22px; +} + +.mirrors-label { + font-size: 12px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--text-secondary); + margin-right: 6px; +} + +.mirror-tag { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + border: 1px solid var(--border); + padding: 6px 12px; + border-radius: 999px; + transition: border-color .2s ease, color .2s ease; +} + +.mirror-tag:hover { border-color: rgba(233, 69, 96, 0.4); color: var(--text); } + +.download-note { + text-align: center; + margin-top: 28px; + font-size: 14px; + color: var(--text-secondary); +} + +/* ---------- News ---------- */ + +.news-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} + +.news-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 28px; + display: flex; + flex-direction: column; + transition: transform .25s ease, border-color .25s ease; +} + +.news-card:hover { transform: translateY(-4px); border-color: rgba(233, 69, 96, 0.35); } + +.news-tags { display: flex; gap: 8px; margin-bottom: 16px; } + +.news-tag { + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--accent); + background: rgba(233, 69, 96, 0.12); + padding: 5px 10px; + border-radius: 6px; +} + +.news-tag--version { color: var(--text-secondary); background: rgba(255, 255, 255, 0.06); } + +.news-card h3 { + font-size: 18px; + font-weight: 700; + line-height: 1.3; + margin-bottom: 10px; +} + +.news-card p { + font-size: 14px; + color: var(--text-secondary); + font-weight: 300; + flex: 1; +} + +.news-skeleton { + background: var(--bg-card); + border-radius: 16px; + height: 180px; + border: 1px solid var(--border); + animation: shimmer 1.5s ease-in-out infinite; +} + +@keyframes shimmer { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} + +/* ---------- Footer ---------- */ + +.footer { + border-top: 1px solid var(--border); + background: var(--bg-deep); + padding: 48px 32px; +} + +.footer-inner { + max-width: 1200px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; +} + +.footer-brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; +} + +.footer-brand img { border-radius: 6px; } + +.footer-note { font-size: 13px; color: var(--text-secondary); } + +/* ---------- Reveal on scroll ---------- */ + +.reveal { + opacity: 0; + transform: translateY(24px); + transition: opacity .7s ease, transform .7s ease; +} + +.reveal.visible { + opacity: 1; + transform: none; +} + +/* ---------- Responsive ---------- */ + +@media (max-width: 1024px) { + .features-grid, .news-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 720px) { + .nav-links { display: none; } + .section { padding: 90px 20px; } + .features-grid, .news-grid { grid-template-columns: 1fr; } + .hero { padding-top: 140px; } + .hero-diamond--1 { right: -10%; } + .download-card { padding: 26px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } + html { scroll-behavior: auto; } +} diff --git a/server/site/img/zernmc.svg b/server/site/img/zernmc.svg new file mode 100644 index 0000000..11a1e02 --- /dev/null +++ b/server/site/img/zernmc.svg @@ -0,0 +1,10 @@ + diff --git a/server/site/index.html b/server/site/index.html new file mode 100644 index 0000000..ec7e840 --- /dev/null +++ b/server/site/index.html @@ -0,0 +1,173 @@ + + +
+ + +CUSTOM MINECRAFT LAUNCHER
+Zern brings your favourite modpacks to life. One launcher, every loader, no hassle — built for the ZernMC server and beyond.
+ + +A launcher that does the heavy lifting, so you can just play.
+Every major mod loader handled automatically. Libraries are prefetched and verified, so installs just work — even on slow networks.
+A lightweight JavaFX interface with a clean dark theme. Starts quick, responds instantly, and stays out of your way.
+Downloads route through fast mirrors with automatic failover. Blocked or throttled? The proxy takes over so you never stall at 80%.
+Changelogs, events and updates stream straight into the launcher. You always know what's new before you hit play.
+Pick a pack, hit install. Version manifests, vanilla assets and libraries are pulled and verified automatically. Simple.
+Download the launcher for Windows and join the ZernMC server.
+No paywalls, no ads. Just a launcher. System requirements: Windows 10+, 4 GB RAM.
+Latest changelogs and updates from the Zern team.
+No news yet.
'; return; } + + grid.innerHTML = ''; + news.forEach((n) => { + const card = document.createElement('article'); + card.className = 'news-card'; + + const tags = document.createElement('div'); + tags.className = 'news-tags'; + if (n.type) { + const t = document.createElement('span'); + t.className = 'news-tag'; + t.textContent = n.type; + tags.appendChild(t); + } + if (n.version) { + const v = document.createElement('span'); + v.className = 'news-tag news-tag--version'; + v.textContent = n.version; + tags.appendChild(v); + } + + const title = document.createElement('h3'); + title.textContent = n.title || 'Update'; + + const body = document.createElement('p'); + const text = stripMarkup(n.body); + body.textContent = text.length > 220 ? text.slice(0, 220) + '…' : text; + + card.appendChild(tags); + card.appendChild(title); + card.appendChild(body); + grid.appendChild(card); + }); + } catch (e) { + console.warn('[site] failed to load news', e); + grid.innerHTML = 'News is temporarily unavailable.
'; + } + } + + function initNav() { + const nav = document.getElementById('nav'); + const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 24); + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + } + + function initParallax() { + const els = document.querySelectorAll('[data-parallax]'); + if (!els.length) return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + const onScroll = () => { + const y = window.scrollY; + if (y > window.innerHeight) return; + els.forEach((el) => { + const speed = parseFloat(el.dataset.parallax); + el.style.transform = 'translateY(' + (y * speed) + 'px)'; + }); + }; + + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + } + + function initReveal() { + const els = document.querySelectorAll('.section-head, .feature-card, .download-card, .news-card'); + if (!('IntersectionObserver' in window)) { + els.forEach((el) => el.classList.add('visible')); + return; + } + const io = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('visible'); + io.unobserve(entry.target); + } + }); + }, { threshold: 0.12 }); + els.forEach((el) => { el.classList.add('reveal'); io.observe(el); }); + } + + function initYear() { + const el = document.getElementById('year'); + if (el) el.textContent = new Date().getFullYear(); + } + + document.addEventListener('DOMContentLoaded', () => { + loadLauncherInfo(); + loadMirrors(); + loadNews(); + initNav(); + initParallax(); + initReveal(); + initYear(); + }); +})();