diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java index 57d6f7d..fc14afe 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java @@ -69,7 +69,7 @@ public class Bootstrap { private static String getServerVersion() { try { - URL url = new URL(BASE_URL.replace("download?type=jar", "version")); + URL url = new URL(BASE_URL + "/launcher/version"); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("GET"); if (conn.getResponseCode() == 200) { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java index 934f67c..89c2862 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java @@ -114,7 +114,7 @@ public class LauncherAPI { } idx = end; } - versions.sort((a, b) -> b.compareTo(a)); + versions.sort(LauncherAPI::compareVersions); break; case "neoforge": String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml"); @@ -129,7 +129,7 @@ public class LauncherAPI { } neoidx = end; } - versions.sort((a, b) -> b.compareTo(a)); + versions.sort(LauncherAPI::compareVersions); break; default: break; @@ -141,6 +141,23 @@ public class LauncherAPI { } } + private static int compareVersions(String a, String b) { + String[] partsA = a.split("\\."); + String[] partsB = b.split("\\."); + int len = Math.min(partsA.length, partsB.length); + for (int i = 0; i < len; i++) { + try { + int numA = Integer.parseInt(partsA[i]); + int numB = Integer.parseInt(partsB[i]); + if (numA != numB) return Integer.compare(numB, numA); + } catch (NumberFormatException e) { + int cmp = partsA[i].compareTo(partsB[i]); + if (cmp != 0) return cmp; + } + } + return Integer.compare(partsB.length, partsA.length); + } + private boolean isNeoForgeCompatible(String version, String mcVersion) { if (mcVersion.startsWith("1.21")) { return version.contains("1.21") && !version.contains("1.20"); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/auth/AuthService.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/auth/AuthService.java index 4a49672..9fa3753 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/auth/AuthService.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/auth/AuthService.java @@ -12,8 +12,10 @@ public class AuthService { public ApiResponse register(String username, String password) { try { - String response = post("/auth/register", - "{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}"); + JsonObject json = new JsonObject(); + json.addProperty("username", username); + json.addProperty("password", password); + String response = post("/auth/register", json.toString()); // If registration succeeds, auto-login AuthManager.AuthResult result = AuthManager.login(username, password); @@ -74,8 +76,9 @@ public class AuthService { public ApiResponse activatePass(String passCode) { try { - String response = post("/auth/pass/activate", - "{\"pass_code\":\"" + passCode + "\"}"); + JsonObject json = new JsonObject(); + json.addProperty("pass_code", passCode); + String response = post("/auth/pass/activate", json.toString()); AuthManager.refreshUserInfo(); return ApiResponse.success(true); } catch (Exception e) { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java index 8a4e695..01969dc 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java @@ -186,7 +186,7 @@ public class LaunchService { } String args = Config.getExtraJvmArgs(); if (args != null && !args.isEmpty()) { - for (String arg : args.split("\\s+")) { + for (String arg : args.split("\n")) { arg = arg.trim(); if (!arg.isEmpty()) extraArgs.add(arg); } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java index c568fcd..0096529 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java @@ -123,12 +123,18 @@ public class AuthManager { public static void logout() { if (session != null && session.refreshToken != null) { try { - post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}"); - } catch (Exception ignored) {} + JsonObject json = new JsonObject(); + json.addProperty("refresh_token", session.refreshToken); + post("/auth/logout", json.toString()); + } catch (Exception e) { + LauncherLogger.warn("Logout error: " + e.getMessage()); + } } session = null; userInfo = null; - try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} + try { Files.deleteIfExists(AUTH_FILE); } catch (Exception e) { + LauncherLogger.warn("Failed to delete auth.json: " + e.getMessage()); + } } public static boolean isLoggedIn() { @@ -140,23 +146,28 @@ public class AuthManager { } public static String getUsername() { - return session != null ? session.username : "Player"; + AuthSession localSession = session; + return localSession != null ? localSession.username : "Player"; } public static String getUuid() { - return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000"; + AuthSession localSession = session; + return localSession != null ? localSession.uuid : "00000000-0000-0000-0000-000000000000"; } public static String getAccessToken() { - if (session == null) return "0"; + AuthSession localSession = session; + if (localSession == null) return "0"; if (isAccessTokenExpired()) { boolean refreshed = tryRefresh(); if (!refreshed) { - if (session == null) return "0"; - return session.accessToken != null ? session.accessToken : "0"; + localSession = session; + if (localSession == null) return "0"; + return localSession.accessToken != null ? localSession.accessToken : "0"; } } - return session != null && session.accessToken != null ? session.accessToken : "0"; + localSession = session; + return localSession != null && localSession.accessToken != null ? localSession.accessToken : "0"; } private static boolean isAccessTokenExpired() { @@ -174,8 +185,9 @@ public class AuthManager { return false; } try { - String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}"; - SimpleHttpResponse resp = post("/auth/refresh", body); + JsonObject json = new JsonObject(); + json.addProperty("refresh_token", session.refreshToken); + SimpleHttpResponse resp = post("/auth/refresh", json.toString()); if (resp.statusCode() == 200) { AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java index c22effd..0219bf9 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java @@ -8,6 +8,7 @@ import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher; import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; import me.sashegdev.zernmc.launcher.utils.ZAnsi; import java.io.BufferedReader; @@ -160,14 +161,15 @@ public class MinecraftLib { } private void safeDeleteDirectory(Path dir) { - try { - Files.walk(dir) - .sorted((a, b) -> b.compareTo(a)) - .forEach(p -> { - try { Files.deleteIfExists(p); } - catch (IOException ignored) {} - }); - } catch (IOException ignored) {} + try (var stream = Files.walk(dir)) { + stream.sorted((a, b) -> b.compareTo(a)) + .forEach(p -> { + try { Files.deleteIfExists(p); } + catch (IOException e) { /* ignore */ } + }); + } catch (IOException e) { + LauncherLogger.warn("safeDeleteDirectory: " + e.getMessage()); + } } private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java index fae0515..fac176f 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java @@ -20,6 +20,7 @@ import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; +import java.time.Duration; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; @@ -464,12 +465,19 @@ public class PackDownloader { */ private void downloadFile(FileInfo file, Path destination) throws Exception { String url = ZHttpClient.getBaseUrl() + file.getUrl(); + String accessToken = AuthManager.getAccessToken(); - HttpRequest request = HttpRequest.newBuilder() + HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(java.net.URI.create(url)) - .GET() - .build(); + .timeout(Duration.ofSeconds(60)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET(); + if (accessToken != null && !accessToken.equals("0")) { + builder.header("Authorization", "Bearer " + accessToken); + } + + HttpRequest request = builder.build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java index bdb4942..e728b0e 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java @@ -11,7 +11,9 @@ import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; public class ForgeInstaller { @@ -240,29 +242,35 @@ public class ForgeInstaller { System.out.println(ZAnsi.cyan("Checking and downloading missing libraries...")); // List of problematic libraries and their alternate URLs - Map alternativeUrls = new HashMap<>(); - alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", - "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar"); - alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", - "https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar"); - Path librariesDir = instance.getPath().resolve("libraries"); - for (Map.Entry entry : alternativeUrls.entrySet()) { + // Map from maven path to list of mirror URLs (tried in order) + Map> alternativeUrls = new HashMap<>(); + alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", Arrays.asList( + "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar", + "https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar" + )); + + for (Map.Entry> entry : alternativeUrls.entrySet()) { Path target = librariesDir.resolve(entry.getKey()); if (!Files.exists(target)) { Files.createDirectories(target.getParent()); System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName())); - for (int attempt = 1; attempt <= 3; attempt++) { - try { - downloadFileWithProgress(entry.getValue(), target); - break; - } catch (Exception e) { - if (attempt == 3) throw e; - System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3...")); - Thread.sleep(2000); + boolean downloaded = false; + for (String mirrorUrl : entry.getValue()) { + for (int attempt = 1; attempt <= 3; attempt++) { + try { + downloadFileWithProgress(mirrorUrl, target); + downloaded = true; + break; + } catch (Exception e) { + if (attempt == 3 && mirrorUrl.equals(entry.getValue().get(entry.getValue().size() - 1))) throw e; + System.out.println(ZAnsi.yellow("Retry " + attempt + "/3...")); + try { Thread.sleep(2000); } catch (InterruptedException ignored) {} + } } + if (downloaded) break; } } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java index f28a61a..83f7b15 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java @@ -183,6 +183,7 @@ public class VersionInstaller { } CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + executor.shutdown(); ProgressBar.finish("Assets downloaded (" + success[0] + " ok, " + failed[0] + " skipped)"); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java index 737e61a..84102a0 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java @@ -41,12 +41,24 @@ public class Config { } } - maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096")); + try { + maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096")); + } catch (NumberFormatException e) { + System.err.println(ZAnsi.yellow("Config: invalid maxMemory value, using default")); + } ramManuallySet = Boolean.parseBoolean(props.getProperty("ramManuallySet", "false")); serverUrl = props.getProperty("serverUrl", serverUrl); lastUsername = props.getProperty("lastUsername", lastUsername); - windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280")); - windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720")); + try { + windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280")); + } catch (NumberFormatException e) { + System.err.println(ZAnsi.yellow("Config: invalid windowWidth value, using default")); + } + try { + windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720")); + } catch (NumberFormatException e) { + System.err.println(ZAnsi.yellow("Config: invalid windowHeight value, using default")); + } extraJvmArgs = props.getProperty("extraJvmArgs", ""); javaPath = props.getProperty("javaPath", "java"); locale = props.getProperty("locale", "en"); @@ -235,7 +247,6 @@ public class Config { StringBuilder sb = new StringBuilder(); sb.append("-XX:ParallelGCThreads=").append(Math.max(1, cores)); sb.append(" -XX:ConcGCThreads=").append(Math.max(1, cores / 2)); - sb.append(" -XX:+UseContainerSupport"); sb.append(" -XX:+AlwaysPreTouch"); if (ramMB >= 8192) { sb.append(" -XX:+UseZGC"); diff --git a/server/admin_router.py b/server/admin_router.py index 899e5c1..c367ac6 100644 --- a/server/admin_router.py +++ b/server/admin_router.py @@ -60,8 +60,8 @@ async def list_users( query += " FROM users" if search: - query += " AND (username LIKE ? OR email LIKE ?)" - params.extend([f"%{search}%", f"%{search}%"]) + query += " AND username LIKE ?" + params.append(f"%{search}%") query += " ORDER BY role DESC, username" @@ -108,19 +108,13 @@ async def get_user_detail( """Детальная информация о пользователе""" with get_db() as conn: row = conn.execute(""" - SELECT id, username, email, uuid, role, created_at, last_login, is_active, banned_until + SELECT id, username, uuid, role, created_at, last_login, is_active, banned_until FROM users WHERE id = ? """, (user_id,)).fetchone() if not row: raise HTTPException(404, "Пользователь не найден") - # Модераторы не видят email обычных пользователей - if current_user["role"] < ROLE_ELDER and row["role"] < ROLE_MODERATOR: - email = None - else: - email = row["email"] - # Получаем активную проходку pass_info = None if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER: @@ -151,7 +145,6 @@ async def get_user_detail( return { "id": row["id"], "username": row["username"], - "email": email, "uuid": row["uuid"], "role": row["role"], "role_name": ROLE_NAMES.get(row["role"], "Неизвестно"), diff --git a/server/friends.py b/server/friends.py index 173c748..67f7b95 100644 --- a/server/friends.py +++ b/server/friends.py @@ -135,7 +135,7 @@ async def list_friends(current_user: dict = Depends(get_current_user)): "role": row[2], "online": bool(row[3]), "current_pack": row[4], - "last_seen": row[5].isoformat() if row[5] else None + "last_seen": row[5] if row[5] else None }) return {"friends": friends} @@ -155,7 +155,7 @@ async def list_friend_requests(current_user: dict = Depends(get_current_user)): "id": row[0], "username": row[1], "role": row[2], - "created_at": row[3].isoformat() if row[3] else None + "created_at": row[3] if row[3] else None }) return {"requests": requests} diff --git a/server/main.py b/server/main.py index 48f1c72..839288f 100644 --- a/server/main.py +++ b/server/main.py @@ -647,18 +647,12 @@ class CacheControlMiddleware: await self.app(scope, receive, send) return - # Add caching headers for static files async def send_wrapper(status, headers, *args, **kwargs): - # Add cache headers for static files - cache_headers = [ - (b"cache-control", b"public, max-age=86400"), # 24 hours - (b"etag", b'"file-etag"'), - ] + cache_headers = [(b"cache-control", b"public, max-age=86400")] headers = list(headers) + cache_headers await send(status, headers, *args, **kwargs) - # Use original send - await self.app(scope, receive, send) + await self.app(scope, receive, send_wrapper) app.add_middleware(CacheControlMiddleware) @@ -962,7 +956,7 @@ async def get_pack_diff( @app.get("/pack/{pack_name}") -async def get_pack_manifest(pack_name: str, request: Request): +async def get_pack_manifest(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)): """Get pack manifest with caching""" client_ip = request.client.host if request.client else "unknown" @@ -1009,7 +1003,12 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request): client_ip = request.client.host if request.client else None # Security: prevent path traversal - if ".." in file_path: + try: + full_path = full_path.resolve() + pack_root = (PACKS_DIR / pack_name).resolve() + if not str(full_path).startswith(str(pack_root)): + raise HTTPException(403, "Invalid file path") + except (ValueError, OSError): raise HTTPException(403, "Invalid file path") if not full_path.exists() or not full_path.is_file(): @@ -1461,28 +1460,6 @@ async def download_legacy_launcher(): raise HTTPException(404, "No legacy launcher files available") -@app.get("/launcher/download/zip/{filename}") -async def download_launcher_zip(filename: str): - """Download specific launcher ZIP archive""" - if ".." in filename: - raise HTTPException(400, "Invalid filename") - - valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"] - if not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"): - raise HTTPException(400, "Invalid filename") - - file_path = BUILDS_DIR / filename - - if not file_path.exists(): - raise HTTPException(404, "ZIP file not found") - - return FileResponse( - path=file_path, - filename=filename, - media_type="application/zip" - ) - - # ====================== ЛАУНЧЕР МЕТА ЭНДПОИНТЫ ====================== @app.get("/launcher/meta") diff --git a/server/pack_manager.py b/server/pack_manager.py index 584321c..8002803 100644 --- a/server/pack_manager.py +++ b/server/pack_manager.py @@ -5,6 +5,8 @@ from pathlib import Path import json from typing import Optional, Dict import structlog +import asyncio +import aiofiles from models import PackMeta, FileEntry @@ -33,9 +35,9 @@ def calculate_sha256_sync(file_path: Path) -> str: return hash_sha.hexdigest() async def calculate_sha256(file_path: Path) -> str: - """Calculate SHA256 hash of a file (async wrapper)""" - # Используем синхронную версию для простоты - return calculate_sha256_sync(file_path) + """Calculate SHA256 hash of a file (async)""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, calculate_sha256_sync, file_path) async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: """Scan pack directory and update manifest if needed""" @@ -51,11 +53,11 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: if not force_rescan and pack_name in _manifest_cache: return _manifest_cache[pack_name] - # Load existing meta if available (синхронно) + # Load existing meta if available if meta_path.exists(): try: - with open(meta_path, 'r', encoding='utf-8') as f: - data = json.load(f) + async with aiofiles.open(meta_path, 'r', encoding='utf-8') as f: + data = json.loads(await f.read()) current_meta = PackMeta.model_validate(data) except Exception as e: logger.warning(f"Failed to load existing meta for pack {pack_name}: {e}") @@ -114,9 +116,8 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: pack_config_path = pack_path / "instance.json" if pack_config_path.exists(): try: - # Синхронное чтение конфига - with open(pack_config_path, 'r', encoding='utf-8') as f: - config = json.load(f) + async with aiofiles.open(pack_config_path, 'r', encoding='utf-8') as f: + config = json.loads(await f.read()) minecraft_version = config.get("minecraftVersion", minecraft_version) loader_type = config.get("loaderType", loader_type) loader_version = config.get("loaderVersion") @@ -137,9 +138,8 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: asset_index=asset_index ) - # Save to disk (синхронно) - with open(meta_path, 'w', encoding='utf-8') as f: - f.write(new_meta.model_dump_json(indent=2)) + async with aiofiles.open(meta_path, 'w', encoding='utf-8') as f: + await f.write(new_meta.model_dump_json(indent=2)) # Update cache _manifest_cache[pack_name] = new_meta diff --git a/server/playtime.py b/server/playtime.py index 2b3aca9..d988eb9 100644 --- a/server/playtime.py +++ b/server/playtime.py @@ -37,7 +37,7 @@ async def sync_playtime( 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) + (current_user["id"], req.pack_name) ) existing = cursor.fetchone() if existing: