package su.xserver.iikocon; import io.vertx.config.ConfigRetriever; import io.vertx.config.ConfigRetrieverOptions; import io.vertx.config.ConfigStoreOptions; import io.vertx.core.AbstractVerticle; import io.vertx.core.Future; import io.vertx.core.Promise; import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpServer; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.client.HttpRequest; import io.vertx.ext.web.client.HttpResponse; import io.vertx.ext.web.client.WebClient; import io.vertx.ext.web.handler.BodyHandler; import io.vertx.ext.web.handler.SessionHandler; import io.vertx.ext.web.handler.StaticHandler; import io.vertx.ext.web.sstore.SessionStore; import io.vertx.ext.web.sstore.redis.RedisSessionStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import su.xserver.iikocon.config.AppConfig; import su.xserver.iikocon.handler.*; import su.xserver.iikocon.iiko.IikoHandler; import su.xserver.iikocon.iiko.IikoOlapClient; import su.xserver.iikocon.iiko.OlapQueryService; import su.xserver.iikocon.service.*; import java.net.URI; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; public class MainVerticle extends AbstractVerticle { private final Logger log = LoggerFactory.getLogger("[MainVerticle]"); private DataBaseService db; private RedisService redis; private HttpServer httpServer; private AppConfig config; private SessionStore sessionStore; private UserService userService; private RestaurantService restaurantService; private ExternalDataBaseService externalDataBaseService; private SettingsService settingsService; private OlapQueryService olapQueryService; @Override public void start(Promise startPromise) throws ClassNotFoundException { Class.forName("com.mysql.cj.jdbc.Driver"); Class.forName("org.postgresql.Driver"); ConfigStoreOptions classpathStore = new ConfigStoreOptions() .setType("file") .setFormat("json") .setConfig(new JsonObject().put("path", "config.json").put("hierarchical", true)) .setOptional(false); ConfigRetrieverOptions options = new ConfigRetrieverOptions() .addStore(classpathStore); ConfigRetriever retriever = ConfigRetriever.create(vertx, options); retriever.getConfig() .onSuccess(cfg -> { config = AppConfig.from(cfg); db = new DataBaseService(vertx, config.database); redis = new RedisService(vertx, config.redis); userService = new UserService(db.getPool()); restaurantService = new RestaurantService(db.getPool()); settingsService = new SettingsService(db.getPool()); externalDataBaseService = new ExternalDataBaseService(db.getPool(), vertx); olapQueryService = new OlapQueryService(db.getPool(), externalDataBaseService); userService.initDatabase().onFailure(err -> { log.error("Failed to initialize database", err); startPromise.fail(err); }); restaurantService.initDatabase().onFailure(err -> { log.error("Failed to initialize database", err); startPromise.fail(err); }); settingsService.initDatabase().onFailure(err -> { log.error("Failed to initialize database", err); startPromise.fail(err); }); externalDataBaseService.initDatabase().onFailure(err -> { log.error("Failed to initialize database", err); startPromise.fail(err); }); olapQueryService.initDatabase().onFailure(err -> { log.error("Failed to initialize database", err); startPromise.fail(err); }); createRouterAndStartHttp(startPromise); }) .onFailure(startPromise::fail); } private void createRouterAndStartHttp(Promise startPromise) { settingsService.get("session_timeout_minutes") .compose(timeoutStr -> { long timeoutMinutes = 60; // default if (timeoutStr != null && !timeoutStr.isEmpty()) { try { timeoutMinutes = Long.parseLong(timeoutStr); } catch (NumberFormatException ignored) {} } long timeoutMs = timeoutMinutes * 60 * 1000; sessionStore = RedisSessionStore.create(vertx, redis.getRedis()); SessionHandler sessionHandler = SessionHandler.create(sessionStore) .setSessionCookieName("admin.session") .setCookieHttpOnlyFlag(true) .setCookieSecureFlag(false) .setSessionTimeout(timeoutMs); Router router = initRouter(sessionHandler); startHttp(router, startPromise); return Future.succeededFuture(); }) .onFailure(err -> { log.error("Failed to get session timeout", err); startPromise.fail(err); }); } private void setupPhpmyadminProxy(Router router) { if (config.pma == null || !config.pma.enabled) return; String upstream = config.pma.upstream; String basePath = config.pma.basePath; final URI upstreamUri = URI.create(upstream); final String host = upstreamUri.getHost(); int portTmp = upstreamUri.getPort(); if (portTmp == -1) { portTmp = "https".equals(upstreamUri.getScheme()) ? 443 : 80; } final int port = portTmp; final WebClient webClient = WebClient.create(vertx); router.route(basePath + "/*").handler(ctx -> { if (ctx.session() != null && "admin".equals(ctx.session().get("role"))) { ctx.next(); } else { ctx.response().putHeader("Location", "/").setStatusCode(302).end(); } }); router.route(basePath + "/*").handler(ctx -> { String targetPathBase = ctx.request().path().substring(basePath.length()); if (targetPathBase.isEmpty()) targetPathBase = "/"; String targetPath = targetPathBase; String query = ctx.request().query(); if (query != null && !query.isEmpty()) { targetPath += "?" + query; } final String targetPathFinal = targetPath; final HttpRequest proxyReq = webClient.request( ctx.request().method(), port, host, targetPathFinal ); ctx.request().headers().forEach(header -> { if (!"host".equalsIgnoreCase(header.getKey())) { proxyReq.putHeader(header.getKey(), header.getValue()); } }); proxyReq.putHeader("Host", host + ":" + port); ctx.request().bodyHandler(body -> { if (body != null && body.length() > 0) { proxyReq.sendBuffer(body) .onSuccess(resp -> sendResponse(ctx, resp)) .onFailure(err -> sendError(ctx, err)); } else { proxyReq.send() .onSuccess(resp -> sendResponse(ctx, resp)) .onFailure(err -> sendError(ctx, err)); } }); }); } private void sendResponse(RoutingContext ctx, HttpResponse resp) { ctx.response().setStatusCode(resp.statusCode()); resp.headers().forEach(h -> ctx.response().putHeader(h.getKey(), h.getValue())); ctx.response().end(resp.body()); } private void sendError(RoutingContext ctx, Throwable err) { log.error("Proxy error: {}", err.getMessage()); ctx.response().setStatusCode(502).end("Bad Gateway: " + err.getMessage()); } private Router initRouter(SessionHandler sessionHandler) { Router router = Router.router(vertx); router.route().handler(ctx -> { String path = ctx.request().path(); if (path != null && path.startsWith(config.pma.basePath + "/")) { ctx.next(); // пропускаем BodyHandler для прокси } else { BodyHandler.create().handle(ctx); } }); router.route().handler(sessionHandler); setupPhpmyadminProxy(router); SecurityHandler securityHandlers = new SecurityHandler(settingsService); // Обработчики безопасности router.route().handler(securityHandlers.hostValidator()); router.route().handler(securityHandlers.proxyHeadersHandler()); router.route().handler(securityHandlers.cspHeader()); // CORS для разработки router.route().handler(ctx -> { ctx.response() .putHeader("Access-Control-Allow-Origin", "*") .putHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") .putHeader("Access-Control-Allow-Headers", "Content-Type, Authorization") .putHeader("Access-Control-Allow-Credentials", "true"); if (ctx.request().method().name().equals("OPTIONS")) { ctx.response().end(); } else { ctx.next(); } }); router.route().handler(ctx -> { long start = System.currentTimeMillis(); String method = ctx.request().method().name(); String path = ctx.request().path(); final String remoteIp = ctx.get("realClientIp") != null ? ctx.get("realClientIp") : ctx.request().remoteAddress().host(); ctx.addBodyEndHandler(v -> { long duration = System.currentTimeMillis() - start; log.info("{} {} - {} ms - {} - {}", method, path, duration, ctx.response().getStatusCode(), remoteIp); }); ctx.next(); }); // ------ Раздаём Vue статику ------ router.route("/assets/*").handler(StaticHandler.create("webroot/assets")); router.route("/favicon.ico").handler(ctx -> ctx.response().sendFile("webroot/favicon.ico")); // ------ SPA fallback: отдаём index.html на все не-API запросы ------ router.route().handler(ctx -> { if (ctx.request().path().startsWith("/api")) { ctx.next(); } else { ctx.response() .putHeader("Content-Type", "text/html") .sendFile("webroot/index.html"); } }); // Rate Limiter Handler RedisRateLimiter limiter = new RedisRateLimiter( redis.getRedis(), 60, 60_000 ); router.route().handler(limiter); // Health Checks HealthCheckService healthCheckService = new HealthCheckService(vertx, redis, db); healthCheckService.registerHealthCheck(router); // API маршруты AuthHandler authHandler = new AuthHandler(userService); SetupHandler setupHandler = new SetupHandler(userService); router.get("/api/status").handler(setupHandler::checkStatus); router.post("/api/setup").handler(setupHandler::handleSetup); router.post("/api/login").handler(authHandler::handleLogin); router.post("/api/logout").handler(authHandler::handleLogout); router.post("/api/register").handler(rc -> settingsService.get("enable_registration").onComplete(regCheck -> { if (regCheck.succeeded() && "false".equals(regCheck.result())) { rc.response().setStatusCode(403).end(new JsonObject().put("error", "Registration is disabled").encode()); return; } JsonObject body = rc.body().asJsonObject(); String login = body.getString("login"); String email = body.getString("email"); String password = body.getString("password"); String ip = rc.request().remoteAddress().host(); if (login == null || email == null || password == null) { rc.response().setStatusCode(400).end("Missing fields"); return; } userService.createUser(login, email, password, ip) .onSuccess(v -> rc.response().setStatusCode(201).end(new JsonObject().put("success", true).encode())) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); })); router.route("/api/profile").handler(authHandler::requireAuth); router.get("/api/profile").handler(rc -> { Integer userId = rc.session().get("userId"); userService.getProfile(userId) .onSuccess(profile -> rc.response().putHeader("Content-Type", "application/json").end(profile.encode())) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.put("/api/profile").handler(rc -> { Integer userId = rc.session().get("userId"); JsonObject body = rc.body().asJsonObject(); String email = body.getString("email"); String password = body.getString("password"); String language = body.getString("language"); userService.updateProfile(userId, email, password, language) .onSuccess(v -> { if (language != null) rc.session().put("language", language); rc.response().end(new JsonObject().put("success", true).encode()); }) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.route("/api/admin/*").handler(authHandler::requireAuth); router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> { if (ar.succeeded()) { rc.response() .putHeader("Content-Type", "application/json") .end(ar.result().encode()); } else { rc.response().setStatusCode(500).end(ar.cause().getMessage()); } })); router.route("/api/admin/users*").handler(AdminHandler::requireAdmin); router.post("/api/admin/users").handler(rc -> { JsonObject body = rc.body().asJsonObject(); String login = body.getString("login"); String email = body.getString("email"); String password = body.getString("password"); String role = body.getString("role"); String ip = rc.request().remoteAddress().host(); if (login == null || email == null || password == null) { rc.response().setStatusCode(400).end("Missing login, email or password"); return; } if (role == null || role.isEmpty()) role = "user"; userService.createUser(login, email, password, ip, true, role) .onSuccess(v -> rc.response().setStatusCode(201).end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.put("/api/admin/users/:id").handler(rc -> { int id = Integer.parseInt(rc.pathParam("id")); JsonObject body = rc.body().asJsonObject(); String login = body.getString("login"); String email = body.getString("email"); String password = body.getString("password"); String role = body.getString("role"); String ip = rc.request().remoteAddress().host(); if (login == null || email == null) { rc.response().setStatusCode(400).end("Missing login or email"); return; } userService.updateUser(id, login, email, password, ip, role) .onSuccess(v -> rc.response().end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.delete("/api/admin/users/:id").handler(rc -> { int id = Integer.parseInt(rc.pathParam("id")); Integer currentUserId = rc.session().get("userId"); if (currentUserId != null && currentUserId == id) { rc.response().setStatusCode(403).end(new JsonObject() .put("error", "You cannot delete your own account") .encode()); return; } userService.deleteUser(id) .onSuccess(v -> rc.response().end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.put("/api/admin/users/:id/activate").handler(rc -> { int id = Integer.parseInt(rc.pathParam("id")); boolean active = Boolean.parseBoolean(rc.queryParam("active").getFirst()); Integer currentUserId = rc.session().get("userId"); if (currentUserId != null && currentUserId == id) { rc.response().setStatusCode(403).end(new JsonObject().put("error", "You cannot deactivate yourself").encode()); return; } userService.setActive(id, active) .onSuccess(v -> rc.response().end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.get("/api/admin/restaurants").handler(rc -> restaurantService.getAllRestaurants().onComplete(ar -> { if (ar.succeeded()) { rc.response() .putHeader("Content-Type", "application/json") .end(ar.result().encode()); } else { rc.response().setStatusCode(500).end(ar.cause().getMessage()); } })); router.get("/api/admin/restaurants/:id").handler(rc -> { int id = Integer.parseInt(rc.pathParam("id")); restaurantService.findById(id) .onSuccess(rest -> { if (rest == null) rc.response().setStatusCode(404).end(); else rc.response().putHeader("Content-Type", "application/json").end(rest.encode()); }) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.get("/api/admin/restaurants/:id/check").handler(rc -> { int id = Integer.parseInt(rc.pathParam("id")); restaurantService.findById(id) .onSuccess(rest -> { if (rest == null) { rc.response().setStatusCode(404).end(); } else { IikoOlapClient iiko = new IikoOlapClient(vertx, rest); iiko.checkConnection() .onSuccess(res -> rc.response().putHeader("Content-Type", "application/json").end(res.encode())) .onFailure(err -> rc.response().putHeader("Content-Type", "application/json").end( new JsonObject().put("success", false).put("error", err.getMessage()).encode())); } }) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.post("/api/admin/restaurants").handler(rc -> { JsonObject body = rc.body().asJsonObject(); String name = body.getString("name"); String login = body.getString("login"); String password = body.getString("password"); String host = body.getString("host"); boolean https = body.getBoolean("https", false); if (name == null || login == null || password == null || host == null) { rc.response().setStatusCode(400).end("Missing fields"); return; } restaurantService.createRestaurant(name, login, password, host, https) .onSuccess(v -> rc.response().setStatusCode(201).end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.put("/api/admin/restaurants/:id").handler(rc -> { int id = Integer.parseInt(rc.pathParam("id")); JsonObject body = rc.body().asJsonObject(); String name = body.getString("name"); String login = body.getString("login"); String password = body.getString("password"); String host = body.getString("host"); boolean https = body.getBoolean("https", false); if (name == null || login == null || host == null) { rc.response().setStatusCode(400).end("Missing required fields"); return; } restaurantService.updateRestaurant(id, name, login, password, host, https) .onSuccess(v -> rc.response().end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.delete("/api/admin/restaurants/:id").handler(rc -> { int id = Integer.parseInt(rc.pathParam("id")); restaurantService.deleteRestaurant(id) .onSuccess(v -> rc.response().end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.get("/api/settings").handler(rc -> { settingsService.getPublicSettings() .onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode())) .onFailure(err -> rc.response().setStatusCode(500).end()); }); router.route("/api/admin/settings*").handler(AdminHandler::requireAdmin); router.get("/api/admin/settings/meta").handler(rc -> { settingsService.getMetadata() .onSuccess(meta -> rc.response().putHeader("Content-Type", "application/json").end(meta.encode())) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.get("/api/admin/settings").handler(rc -> { settingsService.getAllWithDefaults() .onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode())) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.put("/api/admin/settings").handler(rc -> { JsonObject body = rc.body().asJsonObject(); List> futures = new ArrayList<>(); // явно указываем тип Future body.forEach(entry -> futures.add(settingsService.set(entry.getKey(), entry.getValue().toString()))); Future.all(futures) .onSuccess(v -> rc.response().end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); externalDataBaseService.handleRoute(router); new IikoHandler(vertx, router, db, restaurantService, authHandler); // Роуты для OLAP запросов router.get("/api/olap/queries").handler(authHandler::requireAuth).handler(rc -> { olapQueryService.getAllQueries() .onSuccess(queries -> rc.response().putHeader("Content-Type", "application/json").end(queries.encode())) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.get("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> { int id = Integer.parseInt(rc.pathParam("id")); olapQueryService.getQueryById(id) .onSuccess(query -> { if (query == null) rc.response().setStatusCode(404).end(); else rc.response().putHeader("Content-Type", "application/json").end(query.encode()); }) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.post("/api/olap/queries").handler(authHandler::requireAuth).handler(rc -> { JsonObject body = rc.body().asJsonObject(); String name = body.getString("name"); Integer dbConnectionId = body.getInteger("dbConnectionId"); JsonObject config = body.getJsonObject("config"); JsonArray restaurantIdsArray = body.getJsonArray("restaurantIds", new JsonArray()); List restaurantIds = restaurantIdsArray.stream().map(id -> (Integer) id).collect(Collectors.toList()); // Получаем active: сначала из тела, иначе из конфига, иначе true Boolean active = body.getBoolean("active"); if (active == null && config != null) active = config.getBoolean("active", true); if (active == null) active = true; if (name == null || dbConnectionId == null || config == null) { rc.response().setStatusCode(400).end("Missing required fields"); return; } String tableName = config.getString("tableName"); if (tableName.isEmpty()) { rc.response().setStatusCode(400).end("Missing required fields"); return; } if (!olapQueryService.isValidTableName(tableName)) { rc.response().setStatusCode(400).end("Invalid tableName: must start with a letter and contain only letters and digits"); return; } Boolean finalActive = active; olapQueryService.generateSql(config, dbConnectionId) .compose(sql -> olapQueryService.createQuery(name, dbConnectionId, config, restaurantIds, sql, finalActive)) .onSuccess(id -> rc.response().setStatusCode(201).putHeader("Content-Type", "application/json").end(new JsonObject().put("id", id).encode())) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.put("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> { int id = Integer.parseInt(rc.pathParam("id")); JsonObject body = rc.body().asJsonObject(); String name = body.getString("name"); Integer dbConnectionId = body.getInteger("dbConnectionId"); JsonObject config = body.getJsonObject("config"); JsonArray restaurantIdsArray = body.getJsonArray("restaurantIds", new JsonArray()); List restaurantIds = restaurantIdsArray.stream().map(v -> (Integer) v).collect(Collectors.toList()); Boolean active = body.getBoolean("active"); if (active == null && config != null) active = config.getBoolean("active", true); if (active == null) active = true; if (name == null || dbConnectionId == null || config == null) { rc.response().setStatusCode(400).end("Missing required fields"); return; } String tableName = config.getString("tableName"); if (tableName.isEmpty()) { rc.response().setStatusCode(400).end("Missing required fields"); return; } if (!olapQueryService.isValidTableName(tableName)) { rc.response().setStatusCode(400).end("Invalid tableName: must start with a letter and contain only letters and digits"); return; } Boolean finalActive = active; olapQueryService.generateSql(config, dbConnectionId) .compose(sql -> olapQueryService.updateQuery(id, name, dbConnectionId, config, restaurantIds, sql, finalActive)) .onSuccess(v -> rc.response().end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.delete("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> { int id = Integer.parseInt(rc.pathParam("id")); olapQueryService.deleteQuery(id) .onSuccess(v -> rc.response().end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.post("/api/olap/generate-sql").handler(authHandler::requireAuth).handler(rc -> { JsonObject body = rc.body().asJsonObject(); JsonObject config = body.getJsonObject("config"); Integer dbConnectionId = body.getInteger("dbConnectionId"); if (config == null || dbConnectionId == null) { rc.response().setStatusCode(400).end("Missing config or dbConnectionId"); return; } olapQueryService.generateSql(config, dbConnectionId) .onSuccess(sql -> rc.response().putHeader("Content-Type", "text/plain").end(sql)) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); router.post("/api/olap/export-json").handler(authHandler::requireAuth).handler(rc -> { JsonObject body = rc.body().asJsonObject(); JsonObject config = body.getJsonObject("config"); if (config == null) { rc.response().setStatusCode(400).end("Missing config"); return; } JsonObject fullJson = olapQueryService.generateFullIikoJson(config); rc.response() .putHeader("Content-Type", "application/json") .putHeader("Content-Disposition", "attachment; filename=olap_export.json") .end(fullJson.encodePrettily()); }); return router; } private void startHttp(Router router, Promise startPromise) { httpServer = vertx.createHttpServer(); httpServer.requestHandler(router).listen(config.server.port, config.server.host) .onSuccess(server -> { log.info("HTTP server started on port {}", server.actualPort()); startPromise.complete(); }) .onFailure(throwable -> { log.error(throwable.getMessage()); startPromise.fail(throwable); }); } @Override public void stop(Promise stopPromise) { this.httpServer.close() .onSuccess(server -> { log.info("Stop HTTP server"); this.db.disconnect(); this.redis.disconnect(); this.vertx.close() .onSuccess(vertx -> { log.info("Stop Vert.x"); stopPromise.complete(); }) .onFailure(throwable -> log.info(throwable.getMessage())); }) .onFailure(throwable -> log.info(throwable.getMessage())); } }