This commit is contained in:
RoyceDa
2026-02-11 07:25:29 +02:00
commit 3141cd0c90
13 changed files with 659 additions and 0 deletions

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}

View File

@@ -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<KernelFileInfo> 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<java.nio.file.Path>) 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<KernelFileInfo> 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) {
}
}

View File

@@ -0,0 +1,9 @@
package im.rosetta.api.dto;
public record UpdateItem(
String platform,
String arch,
String version,
String downloadUrl
) {
}

View File

@@ -0,0 +1,6 @@
package im.rosetta.api.dto;
import java.util.List;
public record UpdateListResponse(List<UpdateItem> items) {
}

View File

@@ -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
) {
}

View File

@@ -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);
}
}