From 3141cd0c90818ef06b8d38fa7b94c7dc4bc51016 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Wed, 11 Feb 2026 07:25:29 +0200 Subject: [PATCH] init --- .dockerignore | 31 +++ .gitignore | 20 ++ README.md | 48 ++++ build/Dockerfile | 15 ++ build/docker-compose.yml | 18 ++ pom.xml | 118 +++++++++ src/main/java/im/rosetta/Main.java | 56 ++++ .../java/im/rosetta/api/FilesResource.java | 55 ++++ .../java/im/rosetta/api/UpdatesResource.java | 250 ++++++++++++++++++ .../java/im/rosetta/api/dto/UpdateItem.java | 9 + .../rosetta/api/dto/UpdateListResponse.java | 6 + .../im/rosetta/api/dto/UpdateResponse.java | 20 ++ .../java/im/rosetta/config/AppConfig.java | 13 + 13 files changed, 659 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build/Dockerfile create mode 100644 build/docker-compose.yml create mode 100644 pom.xml create mode 100644 src/main/java/im/rosetta/Main.java create mode 100644 src/main/java/im/rosetta/api/FilesResource.java create mode 100644 src/main/java/im/rosetta/api/UpdatesResource.java create mode 100644 src/main/java/im/rosetta/api/dto/UpdateItem.java create mode 100644 src/main/java/im/rosetta/api/dto/UpdateListResponse.java create mode 100644 src/main/java/im/rosetta/api/dto/UpdateResponse.java create mode 100644 src/main/java/im/rosetta/config/AppConfig.java diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a82bd2c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# .dockerignore + +# Git +.git +.gitignore +.github + +# IDE +.vscode +.idea +*.iml +*.swp +*.swo +*~ + +# Maven target +target/ +*.log + +# OS +.DS_Store +Thumbs.db + +# Development +README.md +.env.local +*.env.local + +# Исходный код не нужен в образе (используется build/) +src/ +pom.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6932c26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Maven build +target/ + +# IDE +.vscode/ +.idea/ +*.iml +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +*.log +*.jar diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b66203 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Rosetta Server Updates + +Это сервер обновлений для клиентских приложений. Для сборки нужно поставить Maven и использовать команду + +```bash +mvn clean package +``` + +## API + +### GET /updates/get + +Получить информацию о доступных обновлениях. + +**Параметры запроса:** +- `platform` - платформа клиента (win32, darwin, linux) +- `arch` - архитектура (x64, arm64) +- `app` - текущая версия приложения (e.g., 0.5.0) +- `kernel` - текущая версия ядра (e.g., 1.4.4) + +**Пример запроса:** +``` +GET /updates/get?platform=win32&arch=x64&app=0.5.0&kernel=1.4.4 +``` + +**Формат ответа:** +```json +{ + "version": "0.6.0", + "platform": "win32", + "arch": "x64", + "kernel_update_required": false, + "sevice_pack_url": "/sp/sp-win32-x64-0.6.0-1.4.6.zip", + "kernel_url": null +} +``` + +**Поля ответа:** +- `version` - самая актуальная версия приложения на сервере +- `platform` - платформа +- `arch` - архитектура +- `kernel_update_required` - требуется ли обновление ядра +- `sevice_pack_url` - ссылка на пакет обновления приложения (если доступен) +- `kernel_url` - ссылка на обновление ядра (если требуется) + +### GET /updates/all + +Получить список всех доступных обновлений (заглушка). \ No newline at end of file diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..979d6e4 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,15 @@ +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +# Копируем готовый JAR со всеми зависимостями +COPY app.jar ./app.jar + +# Создаём директории для обновлений (будут смонтированы из хоста) +RUN mkdir -p kernel packs + +# Открываем порт (может быть переопределён через ENV) +EXPOSE ${PORT:-8080} + +# Запускаем приложение с портом из окружения +CMD ["sh", "-c", "java -jar app.jar ${PORT:-8080}"] \ No newline at end of file diff --git a/build/docker-compose.yml b/build/docker-compose.yml new file mode 100644 index 0000000..ebbb0d0 --- /dev/null +++ b/build/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + rosetta-updates: + build: + context: . + dockerfile: Dockerfile + container_name: rosetta-updates + ports: + - "${PORT:-8080}:${PORT:-8080}" + environment: + - PORT=${PORT:-8080} + volumes: + # Монтируем директории обновлений для автоматического подхвата изменений + # При загрузке на FTP докер автоматически подхватит обновления + - ./kernel:/app/kernel + - ./packs:/app/packs + restart: unless-stopped diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d091397 --- /dev/null +++ b/pom.xml @@ -0,0 +1,118 @@ + + + 4.0.0 + + im.rosetta + rosetta-sdu + 1.0-SNAPSHOT + + + 21 + 21 + 3.1.5 + + + + + org.glassfish.jersey.containers + jersey-container-grizzly2-http + ${jersey.version} + + + org.glassfish.jersey.inject + jersey-hk2 + ${jersey.version} + + + org.glassfish.jersey.media + jersey-media-json-jackson + ${jersey.version} + + + org.slf4j + slf4j-simple + 2.0.12 + + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.6.0 + + + package + + single + + + + + im.rosetta.Main + + + + jar-with-dependencies + + app + false + + + + + + + org.apache.maven.plugins + maven-clean-plugin + 3.3.2 + + + pre-clean + + true + + + ${project.basedir}/build + + classes/ + + + + + + clean + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.1.0 + + + package + + + + + + + + + run + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/im/rosetta/Main.java b/src/main/java/im/rosetta/Main.java new file mode 100644 index 0000000..edf99a8 --- /dev/null +++ b/src/main/java/im/rosetta/Main.java @@ -0,0 +1,56 @@ +package im.rosetta; + +import im.rosetta.config.AppConfig; +import org.glassfish.grizzly.http.server.HttpServer; +import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; + +import java.net.URI; + +public class Main { + public static void main(String[] args) { + // Определяем порт запуска. + int port = resolvePort(args); + URI baseUri = URI.create("http://0.0.0.0:" + port + "/"); + + // Создаём и запускаем встроенный Grizzly с Jersey. + HttpServer server = GrizzlyHttpServerFactory.createHttpServer(baseUri, new AppConfig(), false); + Runtime.getRuntime().addShutdownHook(new Thread(() -> stopServer(server))); + + try { + // Запуск и ожидание завершения. + server.start(); + Thread.currentThread().join(); + } catch (Exception e) { + throw new RuntimeException("Failed to start server", e); + } + } + + private static int resolvePort(String[] args) { + // Если порт указан аргументом — используем его. + if (args != null && args.length > 0) { + try { + return Integer.parseInt(args[0]); + } catch (NumberFormatException ignored) { + } + } + + // Если порт задан в окружении — используем его. + String envPort = System.getenv("PORT"); + if (envPort != null && !envPort.isBlank()) { + try { + return Integer.parseInt(envPort); + } catch (NumberFormatException ignored) { + } + } + + // Значение по умолчанию. + return 8080; + } + + private static void stopServer(HttpServer server) { + // Корректная остановка сервера. + if (server != null) { + server.shutdownNow(); + } + } +} \ No newline at end of file diff --git a/src/main/java/im/rosetta/api/FilesResource.java b/src/main/java/im/rosetta/api/FilesResource.java new file mode 100644 index 0000000..6141f25 --- /dev/null +++ b/src/main/java/im/rosetta/api/FilesResource.java @@ -0,0 +1,55 @@ +package im.rosetta.api; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Paths; + +// Ресурс для выдачи файлов обновлений. +@Path("/") +public class FilesResource { + + // Базовые каталоги с файлами. + private static final java.nio.file.Path KERNEL_DIR = Paths.get("kernel").toAbsolutePath().normalize(); + private static final java.nio.file.Path PACKS_DIR = Paths.get("packs").toAbsolutePath().normalize(); + + @GET + @Path("/kernel/{platform}/{arch}/{file}") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + public Response getKernel(@PathParam("platform") String platform, + @PathParam("arch") String arch, + @PathParam("file") String file) { + // Формируем безопасный путь к файлу ядра. + java.nio.file.Path resolved = KERNEL_DIR.resolve(platform).resolve(arch).resolve(file).normalize(); + if (!resolved.startsWith(KERNEL_DIR) || !Files.isRegularFile(resolved)) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + + File target = resolved.toFile(); + return Response.ok(target) + .header("Content-Disposition", "attachment; filename=\"" + target.getName() + "\"") + .build(); + } + + @GET + @Path("/sp/{file}") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + public Response getServicePack(@PathParam("file") String file) { + // Формируем безопасный путь к файлу пакета обновления. + java.nio.file.Path resolved = PACKS_DIR.resolve(file).normalize(); + if (!resolved.startsWith(PACKS_DIR) || !Files.isRegularFile(resolved)) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + + File target = resolved.toFile(); + return Response.ok(target) + .header("Content-Disposition", "attachment; filename=\"" + target.getName() + "\"") + .build(); + } +} diff --git a/src/main/java/im/rosetta/api/UpdatesResource.java b/src/main/java/im/rosetta/api/UpdatesResource.java new file mode 100644 index 0000000..66acc72 --- /dev/null +++ b/src/main/java/im/rosetta/api/UpdatesResource.java @@ -0,0 +1,250 @@ +package im.rosetta.api; + +import im.rosetta.api.dto.UpdateResponse; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Path("/updates") +@Produces(MediaType.APPLICATION_JSON) +public class UpdatesResource { + + // Каталоги с обновлениями относительно корня запуска приложения. + private static final java.nio.file.Path KERNEL_DIR = Paths.get("kernel"); + private static final java.nio.file.Path PACKS_DIR = Paths.get("packs"); + + // Регулярка для извлечения версии ядра из имени файла. + private static final Pattern KERNEL_VERSION_PATTERN = Pattern.compile(".*-(\\d+(?:\\.\\d+)*)\\.[^.]+$"); + + @GET + @Path("/get") + public UpdateResponse getUpdate(@QueryParam("platform") String platform, + @QueryParam("arch") String arch, + @QueryParam("app") String appVersion, + @QueryParam("kernel") String kernelVersion) { + // Нормализуем входные параметры. + String normalizedPlatform = normalize(platform); + String normalizedArch = normalize(arch); + String normalizedApp = normalize(appVersion); + String normalizedKernel = normalize(kernelVersion); + + // Приводим платформу и архитектуру к нижнему регистру для сопоставления с именами файлов. + if (normalizedPlatform != null) { + normalizedPlatform = normalizedPlatform.toLowerCase(Locale.ROOT); + } + if (normalizedArch != null) { + normalizedArch = normalizedArch.toLowerCase(Locale.ROOT); + } + + // Если параметры не заданы — возвращаем пустой ответ. + if (normalizedPlatform == null || normalizedArch == null + || normalizedApp == null || normalizedKernel == null) { + return new UpdateResponse(null, normalizedPlatform, normalizedArch, false, null, null); + } + + // Ищем лучший пакет обновления и последнюю версию приложения на сервере. + PackSelection packSelection = findPackSelection(normalizedPlatform, normalizedArch, normalizedApp); + + String serverAppVersion = packSelection.serverVersion() != null + ? packSelection.serverVersion() + : normalizedApp; + + String servicePackUrl = packSelection.updateFileName() != null + ? "/sp/" + packSelection.updateFileName() + : null; + + boolean kernelUpdateRequired = false; + String kernelUrl = null; + + // Проверяем требование к ядру, только если пакет обновления найден. + if (packSelection.updateFileName() != null + && compareVersions(normalizedKernel, packSelection.minKernelRequired()) < 0) { + kernelUpdateRequired = true; + + // Ищем актуальную версию ядра на сервере. + Optional latestKernel = findLatestKernel(normalizedPlatform, normalizedArch); + if (latestKernel.isPresent()) { + KernelFileInfo info = latestKernel.get(); + kernelUrl = "/kernel/" + normalizedPlatform + "/" + normalizedArch + "/" + info.fileName(); + } + } + + return new UpdateResponse(serverAppVersion, + normalizedPlatform, + normalizedArch, + kernelUpdateRequired, + servicePackUrl, + kernelUrl); + } + + @GET + @Path("/all") + public UpdateResponse getAll() { + // По требованию возвращаем пустой ответ, если параметры не указаны. + return new UpdateResponse(null, null, null, false, null, null); + } + + // Находим самый новый пакет обновления и последнюю версию приложения на сервере. + private PackSelection findPackSelection(String platform, String arch, String clientAppVersion) { + if (!Files.isDirectory(PACKS_DIR)) { + return new PackSelection(null, null, null); + } + + String bestUpdateFile = null; + String bestUpdateVersion = null; + String minKernelRequired = null; + String maxServerVersion = null; + + try (var stream = Files.list(PACKS_DIR)) { + for (java.nio.file.Path path : (Iterable) stream::iterator) { + if (!Files.isRegularFile(path)) { + continue; + } + + PackFileInfo info = parsePackFileName(path.getFileName().toString()); + if (info == null) { + continue; + } + + if (!platform.equals(info.platform()) || !arch.equals(info.arch())) { + continue; + } + + // Обновляем информацию о максимальной версии на сервере. + if (maxServerVersion == null || compareVersions(info.appVersion(), maxServerVersion) > 0) { + maxServerVersion = info.appVersion(); + } + + // Проверяем, что версия клиента меньше версии пакета. + if (compareVersions(clientAppVersion, info.appVersion()) >= 0) { + continue; + } + + // Выбираем самый новый доступный пакет. + if (bestUpdateVersion == null || compareVersions(info.appVersion(), bestUpdateVersion) > 0) { + bestUpdateVersion = info.appVersion(); + bestUpdateFile = info.fileName(); + minKernelRequired = info.minKernelRequired(); + } + } + } catch (IOException ignored) { + // Если каталог недоступен, возвращаем пустой результат. + return new PackSelection(null, null, null); + } + + return new PackSelection(bestUpdateFile, minKernelRequired, maxServerVersion); + } + + // Ищем последний доступный файл ядра. + private Optional findLatestKernel(String platform, String arch) { + java.nio.file.Path targetDir = KERNEL_DIR.resolve(platform).resolve(arch); + if (!Files.isDirectory(targetDir)) { + return Optional.empty(); + } + + try (var stream = Files.list(targetDir)) { + return stream.filter(Files::isRegularFile) + .map(java.nio.file.Path::getFileName) + .map(java.nio.file.Path::toString) + .map(this::parseKernelFileName) + .filter(info -> info != null) + .max(Comparator.comparing(KernelFileInfo::version, this::compareVersions)); + } catch (IOException ignored) { + return Optional.empty(); + } + } + + // Парсим имя файла ядра и извлекаем версию. + private KernelFileInfo parseKernelFileName(String fileName) { + Matcher matcher = KERNEL_VERSION_PATTERN.matcher(fileName); + if (!matcher.matches()) { + return null; + } + return new KernelFileInfo(fileName, matcher.group(1)); + } + + // Парсим имя файла пакета обновления приложения. + private PackFileInfo parsePackFileName(String fileName) { + String lower = fileName.toLowerCase(Locale.ROOT); + if (!lower.startsWith("sp-") || !lower.endsWith(".zip")) { + return null; + } + + String baseName = fileName.substring(0, fileName.length() - 4); + String[] parts = baseName.split("-"); + if (parts.length != 5) { + return null; + } + + return new PackFileInfo(parts[1], parts[2], parts[3], parts[4], fileName); + } + + // Сравнение версий вида 1.4.5. + private int compareVersions(String left, String right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return -1; + } + if (right == null) { + return 1; + } + + String[] leftParts = left.split("\\."); + String[] rightParts = right.split("\\."); + int max = Math.max(leftParts.length, rightParts.length); + + for (int i = 0; i < max; i++) { + int l = i < leftParts.length ? parseVersionPart(leftParts[i]) : 0; + int r = i < rightParts.length ? parseVersionPart(rightParts[i]) : 0; + if (l != r) { + return Integer.compare(l, r); + } + } + + return 0; + } + + // Безопасный парсинг части версии. + private int parseVersionPart(String part) { + try { + return Integer.parseInt(part); + } catch (NumberFormatException ignored) { + return 0; + } + } + + // Нормализуем строку (убираем пробелы и null). + private String normalize(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + // Вспомогательная структура для файла ядра. + private record KernelFileInfo(String fileName, String version) { + } + + // Вспомогательная структура для файла пакета. + private record PackFileInfo(String platform, String arch, String appVersion, + String minKernelRequired, String fileName) { + } + + // Результат выбора пакета обновления. + private record PackSelection(String updateFileName, String minKernelRequired, String serverVersion) { + } +} diff --git a/src/main/java/im/rosetta/api/dto/UpdateItem.java b/src/main/java/im/rosetta/api/dto/UpdateItem.java new file mode 100644 index 0000000..3d8fbc3 --- /dev/null +++ b/src/main/java/im/rosetta/api/dto/UpdateItem.java @@ -0,0 +1,9 @@ +package im.rosetta.api.dto; + +public record UpdateItem( + String platform, + String arch, + String version, + String downloadUrl +) { +} diff --git a/src/main/java/im/rosetta/api/dto/UpdateListResponse.java b/src/main/java/im/rosetta/api/dto/UpdateListResponse.java new file mode 100644 index 0000000..d2a1dd7 --- /dev/null +++ b/src/main/java/im/rosetta/api/dto/UpdateListResponse.java @@ -0,0 +1,6 @@ +package im.rosetta.api.dto; + +import java.util.List; + +public record UpdateListResponse(List items) { +} diff --git a/src/main/java/im/rosetta/api/dto/UpdateResponse.java b/src/main/java/im/rosetta/api/dto/UpdateResponse.java new file mode 100644 index 0000000..b590816 --- /dev/null +++ b/src/main/java/im/rosetta/api/dto/UpdateResponse.java @@ -0,0 +1,20 @@ +package im.rosetta.api.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +// Ответ сервера обновлений в формате, ожидаемом клиентом. +public record UpdateResponse( + // Версия приложения на сервере (самая актуальная доступная для платформы/архитектуры). + String version, + // Платформа клиента. + String platform, + // Архитектура клиента. + String arch, + // Требуется ли обновление ядра. + @JsonProperty("kernel_update_required") boolean kernelUpdateRequired, + // Ссылка на пакет обновления приложения. + @JsonProperty("sevice_pack_url") String servicePackUrl, + // Ссылка на обновление ядра (если требуется). + @JsonProperty("kernel_url") String kernelUrl +) { +} diff --git a/src/main/java/im/rosetta/config/AppConfig.java b/src/main/java/im/rosetta/config/AppConfig.java new file mode 100644 index 0000000..ea0727c --- /dev/null +++ b/src/main/java/im/rosetta/config/AppConfig.java @@ -0,0 +1,13 @@ +package im.rosetta.config; + +import org.glassfish.jersey.jackson.JacksonFeature; +import org.glassfish.jersey.server.ResourceConfig; + +public class AppConfig extends ResourceConfig { + public AppConfig() { + // Регистрируем REST-ресурсы. + packages("im.rosetta.api"); + // Включаем JSON-сериализацию. + register(JacksonFeature.class); + } +}