4740 lines
183 KiB
Plaintext
4740 lines
183 KiB
Plaintext
package server.logic.ws_protocol.JSON;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
|
||
import java.util.Set;
|
||
import java.util.concurrent.ConcurrentHashMap;
|
||
import java.util.concurrent.CopyOnWriteArraySet;
|
||
|
||
/**
|
||
* Реестр активных подключений (только авторизованные).
|
||
*/
|
||
public final class ActiveConnectionsRegistry {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(ActiveConnectionsRegistry.class);
|
||
|
||
private static final ActiveConnectionsRegistry INSTANCE = new ActiveConnectionsRegistry();
|
||
|
||
public static ActiveConnectionsRegistry getInstance() {
|
||
return INSTANCE;
|
||
}
|
||
|
||
private ActiveConnectionsRegistry() {
|
||
// singleton
|
||
}
|
||
|
||
// sessionId (String) -> ConnectionContext
|
||
private final ConcurrentHashMap<String, ConnectionContext> bySessionId = new ConcurrentHashMap<>();
|
||
|
||
// login (String) -> множество ConnectionContext для этого пользователя
|
||
private final ConcurrentHashMap<String, Set<ConnectionContext>> byLogin = new ConcurrentHashMap<>();
|
||
|
||
/**
|
||
* Зарегистрировать авторизованное подключение.
|
||
* Ожидается, что в ctx уже выставлены login и sessionId.
|
||
*/
|
||
public void register(ConnectionContext ctx) {
|
||
if (ctx == null) return;
|
||
|
||
String sessionId = ctx.getSessionId();
|
||
String login = ctx.getLogin();
|
||
|
||
if (sessionId == null || sessionId.isBlank() || login == null || login.isBlank()) {
|
||
log.debug("register skipped: bad ctx fields (login='{}', sessionId='{}')", login, sessionId);
|
||
return;
|
||
}
|
||
|
||
// ✅ Если кто-то перерегистрировал тот же sessionId — вычищаем старый ctx из byLogin
|
||
ConnectionContext prev = bySessionId.put(sessionId, ctx);
|
||
if (prev != null && prev != ctx) {
|
||
String prevLogin = prev.getLogin();
|
||
if (prevLogin != null && !prevLogin.isBlank()) {
|
||
Set<ConnectionContext> prevSet = byLogin.get(prevLogin);
|
||
if (prevSet != null) {
|
||
prevSet.remove(prev);
|
||
if (prevSet.isEmpty()) {
|
||
byLogin.remove(prevLogin);
|
||
}
|
||
}
|
||
}
|
||
log.warn("sessionId reused: replaced previous ctx (sessionId={}, prevLogin={}, newLogin={})",
|
||
sessionId, prevLogin, login);
|
||
}
|
||
|
||
byLogin
|
||
.computeIfAbsent(login, id -> new CopyOnWriteArraySet<>())
|
||
.add(ctx);
|
||
|
||
log.debug("registered ctx (login={}, sessionId={})", login, sessionId);
|
||
}
|
||
|
||
/**
|
||
* Удалить подключение по контексту (например, при onClose).
|
||
*/
|
||
public void remove(ConnectionContext ctx) {
|
||
if (ctx == null) return;
|
||
|
||
String sessionId = ctx.getSessionId();
|
||
String login = ctx.getLogin();
|
||
|
||
if (sessionId != null && !sessionId.isBlank()) {
|
||
ConnectionContext removed = bySessionId.remove(sessionId);
|
||
|
||
// Если в мапе лежал другой ctx под тем же sessionId — не трогаем его byLogin
|
||
if (removed != null && removed != ctx) {
|
||
log.debug("remove(ctx): sessionId mapped to another ctx, skip byLogin cleanup (sessionId={})", sessionId);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (login != null && !login.isBlank()) {
|
||
Set<ConnectionContext> set = byLogin.get(login);
|
||
if (set != null) {
|
||
set.remove(ctx);
|
||
if (set.isEmpty()) {
|
||
byLogin.remove(login);
|
||
}
|
||
}
|
||
}
|
||
|
||
log.debug("removed ctx (login={}, sessionId={})", login, sessionId);
|
||
}
|
||
|
||
/**
|
||
* Удалить подключение по sessionId.
|
||
*/
|
||
public void removeBySessionId(String sessionId) {
|
||
if (sessionId == null || sessionId.isBlank()) return;
|
||
|
||
ConnectionContext ctx = bySessionId.remove(sessionId);
|
||
if (ctx == null) return;
|
||
|
||
String login = ctx.getLogin();
|
||
if (login != null && !login.isBlank()) {
|
||
Set<ConnectionContext> set = byLogin.get(login);
|
||
if (set != null) {
|
||
set.remove(ctx);
|
||
if (set.isEmpty()) {
|
||
byLogin.remove(login);
|
||
}
|
||
}
|
||
}
|
||
|
||
log.debug("removed by sessionId (login={}, sessionId={})", login, sessionId);
|
||
}
|
||
|
||
/**
|
||
* Получить контекст по sessionId.
|
||
*/
|
||
public ConnectionContext getBySessionId(String sessionId) {
|
||
if (sessionId == null || sessionId.isBlank()) return null;
|
||
return bySessionId.get(sessionId);
|
||
}
|
||
|
||
/**
|
||
* Получить все активные подключения пользователя по login.
|
||
*/
|
||
public Set<ConnectionContext> getByLogin(String login) {
|
||
if (login == null || login.isBlank()) return Set.of();
|
||
Set<ConnectionContext> set = byLogin.get(login);
|
||
return (set == null) ? Set.of() : set; // CopyOnWriteArraySet можно отдавать как есть
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON;
|
||
|
||
import org.eclipse.jetty.websocket.api.Session;
|
||
import shine.db.entities.SolanaUserEntry;
|
||
import shine.db.entities.ActiveSessionEntry;
|
||
|
||
/**
|
||
* ConnectionContext — контекст состояния одного WebSocket-соединения.
|
||
* Живёт ровно столько же, сколько живёт подключение.
|
||
*
|
||
* Важно (v2):
|
||
* - Авторизация всегда 2 шага:
|
||
* A) Создание новой сессии через deviceKey:
|
||
* AuthChallenge(login) -> ctx.authNonce
|
||
* CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession
|
||
*
|
||
* B) Вход в существующую сессию через sessionKey:
|
||
* SessionChallenge(sessionId) -> ctx.sessionLoginNonce + ctx.sessionLoginSessionId + expiresAt
|
||
* SessionLogin(...) -> проверка подписи sessionKey по pubkey из БД -> ctx.AUTH_STATUS_USER
|
||
*/
|
||
public class ConnectionContext {
|
||
|
||
// Статусы аутентификации
|
||
public static final int AUTH_STATUS_NONE = 0; // анонимный / не авторизован
|
||
public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge)
|
||
public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь
|
||
|
||
// Полный пользователь из БД (solana_users)
|
||
private SolanaUserEntry solanaUserEntry;
|
||
|
||
// Активная сессия из БД (active_sessions)
|
||
private ActiveSessionEntry activeSessionEntry;
|
||
|
||
/**
|
||
* Идентификатор сессии — base64-строка от 32 байт.
|
||
* Заполняется после успешного входа (AUTH_STATUS_USER).
|
||
*/
|
||
private String sessionId;
|
||
|
||
/**
|
||
* Одноразовый nonce, выданный на шаге 1 (AuthChallenge),
|
||
* используется на шаге CreateAuthSession для проверки подписи deviceKey.
|
||
*/
|
||
private String authNonce;
|
||
|
||
/* ===================== SessionLogin challenge (v2) ===================== */
|
||
|
||
/**
|
||
* Одноразовый nonce, выданный на шаге SessionChallenge(sessionId),
|
||
* используется на шаге SessionLogin для проверки подписи sessionKey.
|
||
*/
|
||
private String sessionLoginNonce;
|
||
|
||
/**
|
||
* sessionId, для которого был выдан sessionLoginNonce.
|
||
* Нужен, чтобы SessionLogin не мог "подставить" другой sessionId.
|
||
*/
|
||
private String sessionLoginSessionId;
|
||
|
||
/**
|
||
* Время истечения sessionLoginNonce (мс с 1970-01-01).
|
||
* Если текущее время > expiresAt, то nonce считается недействительным.
|
||
*/
|
||
private long sessionLoginNonceExpiresAtMs;
|
||
|
||
/* ====================================================================== */
|
||
|
||
/**
|
||
* Текущий статус аутентификации.
|
||
* См. константы AUTH_STATUS_*
|
||
*/
|
||
private int authenticationStatus = AUTH_STATUS_NONE;
|
||
|
||
/**
|
||
* WebSocket-сессия Jetty для данного подключения.
|
||
* Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту.
|
||
*/
|
||
private Session wsSession;
|
||
|
||
// --- WebSocket Session ---
|
||
|
||
public Session getWsSession() {
|
||
return wsSession;
|
||
}
|
||
|
||
public void setWsSession(Session wsSession) {
|
||
this.wsSession = wsSession;
|
||
}
|
||
|
||
// --- SolanaUser / ActiveSession ---
|
||
|
||
public SolanaUserEntry getSolanaUser() {
|
||
return solanaUserEntry;
|
||
}
|
||
|
||
public void setSolanaUser(SolanaUserEntry solanaUserEntry) {
|
||
this.solanaUserEntry = solanaUserEntry;
|
||
}
|
||
|
||
public ActiveSessionEntry getActiveSession() {
|
||
return activeSessionEntry;
|
||
}
|
||
|
||
public void setActiveSession(ActiveSessionEntry activeSessionEntry) {
|
||
this.activeSessionEntry = activeSessionEntry;
|
||
}
|
||
|
||
// --- Удобный геттер для логина ---
|
||
|
||
public String getLogin() {
|
||
return solanaUserEntry != null ? solanaUserEntry.getLogin() : null;
|
||
}
|
||
|
||
// --- sessionId ---
|
||
|
||
public String getSessionId() {
|
||
return sessionId;
|
||
}
|
||
|
||
public void setSessionId(String sessionId) {
|
||
this.sessionId = sessionId;
|
||
}
|
||
|
||
// --- authNonce ---
|
||
|
||
public String getAuthNonce() {
|
||
return authNonce;
|
||
}
|
||
|
||
public void setAuthNonce(String authNonce) {
|
||
this.authNonce = authNonce;
|
||
}
|
||
|
||
// --- sessionLoginNonce (v2) ---
|
||
|
||
public String getSessionLoginNonce() {
|
||
return sessionLoginNonce;
|
||
}
|
||
|
||
public void setSessionLoginNonce(String sessionLoginNonce) {
|
||
this.sessionLoginNonce = sessionLoginNonce;
|
||
}
|
||
|
||
public String getSessionLoginSessionId() {
|
||
return sessionLoginSessionId;
|
||
}
|
||
|
||
public void setSessionLoginSessionId(String sessionLoginSessionId) {
|
||
this.sessionLoginSessionId = sessionLoginSessionId;
|
||
}
|
||
|
||
public long getSessionLoginNonceExpiresAtMs() {
|
||
return sessionLoginNonceExpiresAtMs;
|
||
}
|
||
|
||
public void setSessionLoginNonceExpiresAtMs(long sessionLoginNonceExpiresAtMs) {
|
||
this.sessionLoginNonceExpiresAtMs = sessionLoginNonceExpiresAtMs;
|
||
}
|
||
|
||
// --- auth status ---
|
||
|
||
public int getAuthenticationStatus() {
|
||
return authenticationStatus;
|
||
}
|
||
|
||
public void setAuthenticationStatus(int authenticationStatus) {
|
||
this.authenticationStatus = authenticationStatus;
|
||
}
|
||
|
||
public boolean isAuthenticatedUser() {
|
||
return authenticationStatus == AUTH_STATUS_USER;
|
||
}
|
||
|
||
public boolean isAnonymous() {
|
||
return authenticationStatus == AUTH_STATUS_NONE;
|
||
}
|
||
|
||
public void reset() {
|
||
solanaUserEntry = null;
|
||
activeSessionEntry = null;
|
||
|
||
sessionId = null;
|
||
authNonce = null;
|
||
|
||
sessionLoginNonce = null;
|
||
sessionLoginSessionId = null;
|
||
sessionLoginNonceExpiresAtMs = 0;
|
||
|
||
authenticationStatus = AUTH_STATUS_NONE;
|
||
wsSession = null;
|
||
}
|
||
|
||
@Override
|
||
public String toString() {
|
||
return "ConnectionContext{" +
|
||
"login='" + getLogin() + '\'' +
|
||
", sessionId=" + sessionId +
|
||
", authenticationStatus=" + authenticationStatus +
|
||
'}';
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.entyties;
|
||
|
||
/**
|
||
* Базовый класс для всех событий (event).
|
||
* Общие поля: op и payload.
|
||
*.
|
||
* Формат JSON (event):
|
||
* {
|
||
* "op": "...",
|
||
* "payload": { ... }
|
||
* }
|
||
*/
|
||
public abstract class Net_Event {
|
||
|
||
/** Имя операции / события (op). */
|
||
private String op;
|
||
|
||
/**
|
||
* Произвольные данные.
|
||
* В JSON это поле "payload".
|
||
*/
|
||
private Object payload;
|
||
|
||
// --- getters / setters ---
|
||
|
||
public String getOp() {
|
||
return op;
|
||
}
|
||
|
||
public void setOp(String op) {
|
||
this.op = op;
|
||
}
|
||
|
||
public Object getPayload() {
|
||
return payload;
|
||
}
|
||
|
||
public void setPayload(Object payload) {
|
||
this.payload = payload;
|
||
}
|
||
}
|
||
|
||
package server.logic.ws_protocol.JSON.entyties;
|
||
|
||
/**
|
||
* Ответ с ошибкой (любой отказ).
|
||
*.
|
||
* В payload будет:
|
||
* {
|
||
* "code": "...",
|
||
* "message": "..."
|
||
* }
|
||
*/
|
||
public class Net_Exception_Response extends Net_Response {
|
||
|
||
private String code;
|
||
private String message;
|
||
|
||
public String getCode() {
|
||
return code;
|
||
}
|
||
|
||
public void setCode(String code) {
|
||
this.code = code;
|
||
}
|
||
|
||
public String getMessage() {
|
||
return message;
|
||
}
|
||
|
||
public void setMessage(String message) {
|
||
this.message = message;
|
||
}
|
||
}
|
||
|
||
package server.logic.ws_protocol.JSON.entyties;
|
||
|
||
/**
|
||
* Базовый класс для всех запросов (client → server).
|
||
*.
|
||
* Наследуется от NetEvent и добавляет requestId.
|
||
*.
|
||
* Формат JSON (request):
|
||
* {
|
||
* "op": "...",
|
||
* "requestId": "...",
|
||
* "payload": { ... }
|
||
* }
|
||
*/
|
||
public abstract class Net_Request extends Net_Event {
|
||
|
||
/** Идентификатор запроса, чтобы связать запрос и ответ. */
|
||
private String requestId;
|
||
|
||
// --- getters / setters ---
|
||
|
||
public String getRequestId() {
|
||
return requestId;
|
||
}
|
||
|
||
public void setRequestId(String requestId) {
|
||
this.requestId = requestId;
|
||
}
|
||
}
|
||
|
||
package server.logic.ws_protocol.JSON.entyties;
|
||
|
||
/**
|
||
* Базовый класс для всех ответов (server → client).
|
||
*.
|
||
* Наследуется от NetRequest и добавляет status.
|
||
*.
|
||
* Формат JSON (response):
|
||
* {
|
||
* "op": "...",
|
||
* "requestId": "...",
|
||
* "status": 200,
|
||
* "payload": { ... } // и для успеха, и для ошибки
|
||
* }
|
||
*/
|
||
public abstract class Net_Response extends Net_Request {
|
||
|
||
/** Статус результата (200 — успех, любое другое значение — ошибка). */
|
||
private int status;
|
||
|
||
// --- getters / setters ---
|
||
|
||
public int getStatus() {
|
||
return status;
|
||
}
|
||
|
||
public void setStatus(int status) {
|
||
this.status = status;
|
||
}
|
||
|
||
public boolean isOk() {
|
||
return status == 200;
|
||
}
|
||
}
|
||
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Шаг 1 авторизации: запрос выдачи одноразового nonce (authNonce).
|
||
*
|
||
* Клиент по логину просит сервер сгенерировать случайный authNonce,
|
||
* который будет использован на втором шаге при подписи.
|
||
*
|
||
* Формат входящего JSON:
|
||
* {
|
||
* "op": "AuthChallenge",
|
||
* "requestId": "...",
|
||
* "payload": {
|
||
* "login": "someLogin"
|
||
* }
|
||
* }
|
||
*
|
||
* Формат успешного ответа:
|
||
* {
|
||
* "op": "AuthChallenge",
|
||
* "requestId": "...",
|
||
* "status": 200,
|
||
* "payload": {
|
||
* "authNonce": "base64-строка-от-32-байт"
|
||
* }
|
||
* }
|
||
*/
|
||
public class Net_AuthChallenge_Request extends Net_Request {
|
||
|
||
/**
|
||
* Логин пользователя, для которого запускается авторизация.
|
||
*/
|
||
private String login;
|
||
|
||
public String getLogin() {
|
||
return login;
|
||
}
|
||
public void setLogin(String login) {
|
||
this.login = login;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
/**
|
||
* Ответ на AuthChallenge.
|
||
*
|
||
* При успехе сервер возвращает одноразовый nonce для подписи (authNonce),
|
||
* который клиент обязан использовать на втором шаге при формировании строки
|
||
* для цифровой подписи.
|
||
*
|
||
* JSON:
|
||
* {
|
||
* "op": "AuthChallenge",
|
||
* "requestId": "...",
|
||
* "status": 200,
|
||
* "payload": {
|
||
* "authNonce": "base64-строка-от-32-байт"
|
||
* }
|
||
* }
|
||
*/
|
||
public class Net_AuthChallenge_Response extends Net_Response {
|
||
|
||
/**
|
||
* Одноразовый nonce для авторификации.
|
||
* Строка — это base64-представление 32 случайных байт.
|
||
*/
|
||
private String authNonce;
|
||
|
||
public String getAuthNonce() {
|
||
return authNonce;
|
||
}
|
||
|
||
public void setAuthNonce(String authNonce) {
|
||
this.authNonce = authNonce;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Запрос CloseActiveSession — закрытие активной сессии пользователя.
|
||
*
|
||
* Новая логика (v2):
|
||
* - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER).
|
||
* - Никаких подписей и "AUTH_IN_PROGRESS" здесь больше нет.
|
||
*
|
||
* payload:
|
||
* {
|
||
* "sessionId": "..." // опционально; если пусто — закрываем текущую
|
||
* }
|
||
*/
|
||
public class Net_CloseActiveSession_Request extends Net_Request {
|
||
|
||
/** Идентификатор сессии, которую нужно закрыть. Может быть пустым. */
|
||
private String sessionId;
|
||
|
||
public String getSessionId() {
|
||
return sessionId;
|
||
}
|
||
|
||
public void setSessionId(String sessionId) {
|
||
this.sessionId = sessionId;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
/**
|
||
* Ответ на CloseActiveSession.
|
||
*
|
||
* При успехе:
|
||
* - status = 200;
|
||
* - payload = {}.
|
||
*
|
||
* Закрытие WebSocket-соединения может быть выполнено сразу (для другой сессии)
|
||
* или чуть позже (для текущей сессии) после отправки ответа.
|
||
*/
|
||
public class Net_CloseActiveSession_Response extends Net_Response {
|
||
// Дополнительных полей пока не требуется.
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey.
|
||
*
|
||
* Шаги:
|
||
* 1) AuthChallenge(login) -> authNonce
|
||
* 2) CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo)
|
||
*
|
||
* Подпись deviceKey делается над строкой (UTF-8):
|
||
* AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}:{sessionPubKeyB64}:{storagePwd}
|
||
*
|
||
* Важно:
|
||
* - sessionKey генерируется на клиенте, на сервер отправляется ТОЛЬКО sessionPubKeyB64 (32 bytes base64).
|
||
* - В БД active_sessions.session_key хранится sessionPubKeyB64.
|
||
*/
|
||
public class Net_CreateAuthSession_Request extends Net_Request {
|
||
|
||
/** Клиентский пароль для хранения данных (base64url от 32 байт). */
|
||
private String storagePwd;
|
||
|
||
/** Публичный ключ сессии (sessionPubKey), base64 от 32 байт. */
|
||
private String sessionPubKeyB64;
|
||
|
||
/** Время на стороне клиента (мс с 1970-01-01). */
|
||
private long timeMs;
|
||
|
||
/** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */
|
||
private String signatureB64;
|
||
|
||
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
|
||
private String clientInfo;
|
||
|
||
public String getStoragePwd() {
|
||
return storagePwd;
|
||
}
|
||
|
||
public void setStoragePwd(String storagePwd) {
|
||
this.storagePwd = storagePwd;
|
||
}
|
||
|
||
public String getSessionPubKeyB64() {
|
||
return sessionPubKeyB64;
|
||
}
|
||
|
||
public void setSessionPubKeyB64(String sessionPubKeyB64) {
|
||
this.sessionPubKeyB64 = sessionPubKeyB64;
|
||
}
|
||
|
||
public long getTimeMs() {
|
||
return timeMs;
|
||
}
|
||
|
||
public void setTimeMs(long timeMs) {
|
||
this.timeMs = timeMs;
|
||
}
|
||
|
||
public String getSignatureB64() {
|
||
return signatureB64;
|
||
}
|
||
|
||
public void setSignatureB64(String signatureB64) {
|
||
this.signatureB64 = signatureB64;
|
||
}
|
||
|
||
public String getClientInfo() {
|
||
return clientInfo;
|
||
}
|
||
|
||
public void setClientInfo(String clientInfo) {
|
||
this.clientInfo = clientInfo;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
/**
|
||
* Ответ на CreateAuthSession (v2).
|
||
*
|
||
* При успехе сервер создаёт запись в active_sessions
|
||
* и возвращает идентификатор сессии sessionId.
|
||
*
|
||
* JSON:
|
||
* {
|
||
* "op": "CreateAuthSession",
|
||
* "requestId": "...",
|
||
* "status": 200,
|
||
* "payload": {
|
||
* "sessionId": "base64url(32)"
|
||
* }
|
||
* }
|
||
*/
|
||
public class Net_CreateAuthSession_Response extends Net_Response {
|
||
|
||
/** Идентификатор сессии, base64url от 32 байт. */
|
||
private String sessionId;
|
||
|
||
public String getSessionId() {
|
||
return sessionId;
|
||
}
|
||
|
||
public void setSessionId(String sessionId) {
|
||
this.sessionId = sessionId;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Запрос ListSessions — список активных сессий пользователя.
|
||
*
|
||
* Новая логика (v2):
|
||
* - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER).
|
||
* - Пустой payload.
|
||
*/
|
||
public class Net_ListSessions_Request extends Net_Request {
|
||
// пусто
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
import java.util.List;
|
||
|
||
/**
|
||
* Ответ на ListSessions.
|
||
*
|
||
* При успехе:
|
||
* - status = 200;
|
||
* - payload:
|
||
* {
|
||
* "sessions": [
|
||
* {
|
||
* "sessionId": "...",
|
||
* "clientInfoFromClient": "...",
|
||
* "clientInfoFromRequest": "...",
|
||
* "geo": "Country, City" | "unknown",
|
||
* "lastAuthirificatedAtMs": 1733310000000
|
||
* },
|
||
* ...
|
||
* ]
|
||
* }
|
||
*/
|
||
public class Net_ListSessions_Response extends Net_Response {
|
||
|
||
/**
|
||
* Список активных сессий для текущего пользователя.
|
||
*/
|
||
private List<SessionInfo> sessions;
|
||
|
||
public List<SessionInfo> getSessions() {
|
||
return sessions;
|
||
}
|
||
|
||
public void setSessions(List<SessionInfo> sessions) {
|
||
this.sessions = sessions;
|
||
}
|
||
|
||
/**
|
||
* Описание одной активной сессии.
|
||
*/
|
||
public static class SessionInfo {
|
||
|
||
/** Идентификатор сессии, base64 от 32 байт. */
|
||
private String sessionId;
|
||
|
||
/** Что прислал клиент в CreateAuthSession/RefreshSession (clientInfo). */
|
||
private String clientInfoFromClient;
|
||
|
||
/** Краткая строка, собранная сервером из HTTP-запроса (UA, платформа и т.п.). */
|
||
private String clientInfoFromRequest;
|
||
|
||
/** Строка геолокации вида "Country, City" или "unknown". */
|
||
private String geo;
|
||
|
||
/** Время последней успешной авторизации/refresh (мс с 1970-01-01). */
|
||
private long lastAuthirificatedAtMs;
|
||
|
||
// --- getters / setters ---
|
||
|
||
public String getSessionId() {
|
||
return sessionId;
|
||
}
|
||
|
||
public void setSessionId(String sessionId) {
|
||
this.sessionId = sessionId;
|
||
}
|
||
|
||
public String getClientInfoFromClient() {
|
||
return clientInfoFromClient;
|
||
}
|
||
|
||
public void setClientInfoFromClient(String clientInfoFromClient) {
|
||
this.clientInfoFromClient = clientInfoFromClient;
|
||
}
|
||
|
||
public String getClientInfoFromRequest() {
|
||
return clientInfoFromRequest;
|
||
}
|
||
|
||
public void setClientInfoFromRequest(String clientInfoFromRequest) {
|
||
this.clientInfoFromRequest = clientInfoFromRequest;
|
||
}
|
||
|
||
public String getGeo() {
|
||
return geo;
|
||
}
|
||
|
||
public void setGeo(String geo) {
|
||
this.geo = geo;
|
||
}
|
||
|
||
public long getLastAuthirificatedAtMs() {
|
||
return lastAuthirificatedAtMs;
|
||
}
|
||
|
||
public void setLastAuthirificatedAtMs(long lastAuthirificatedAtMs) {
|
||
this.lastAuthirificatedAtMs = lastAuthirificatedAtMs;
|
||
}
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Шаг 1 входа в существующую сессию (v2):
|
||
* SessionChallenge(sessionId) -> nonce
|
||
*/
|
||
public class Net_SessionChallenge_Request extends Net_Request {
|
||
|
||
private String sessionId;
|
||
|
||
public String getSessionId() {
|
||
return sessionId;
|
||
}
|
||
|
||
public void setSessionId(String sessionId) {
|
||
this.sessionId = sessionId;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
/**
|
||
* Ответ на SessionChallenge (v2).
|
||
* payload: { "nonce": "base64url(32)" }
|
||
*/
|
||
public class Net_SessionChallenge_Response extends Net_Response {
|
||
|
||
private String nonce;
|
||
|
||
public String getNonce() {
|
||
return nonce;
|
||
}
|
||
|
||
public void setNonce(String nonce) {
|
||
this.nonce = nonce;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Шаг 2 входа в существующую сессию (v2):
|
||
* SessionLogin(sessionId, timeMs, signatureB64) -> storagePwd, AUTH_STATUS_USER
|
||
*
|
||
* Подпись делается sessionKey (приватный ключ на устройстве) над строкой (UTF-8):
|
||
* SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
|
||
*
|
||
* nonce берётся из SessionChallenge и хранится в ctx (одноразовый, TTL).
|
||
*/
|
||
public class Net_SessionLogin_Request extends Net_Request {
|
||
|
||
private String sessionId;
|
||
private long timeMs;
|
||
private String signatureB64;
|
||
|
||
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
|
||
private String clientInfo;
|
||
|
||
public String getSessionId() {
|
||
return sessionId;
|
||
}
|
||
|
||
public void setSessionId(String sessionId) {
|
||
this.sessionId = sessionId;
|
||
}
|
||
|
||
public long getTimeMs() {
|
||
return timeMs;
|
||
}
|
||
|
||
public void setTimeMs(long timeMs) {
|
||
this.timeMs = timeMs;
|
||
}
|
||
|
||
public String getSignatureB64() {
|
||
return signatureB64;
|
||
}
|
||
|
||
public void setSignatureB64(String signatureB64) {
|
||
this.signatureB64 = signatureB64;
|
||
}
|
||
|
||
public String getClientInfo() {
|
||
return clientInfo;
|
||
}
|
||
|
||
public void setClientInfo(String clientInfo) {
|
||
this.clientInfo = clientInfo;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
/**
|
||
* Ответ на SessionLogin (v2).
|
||
* payload: { "storagePwd": "base64url(32)" }
|
||
*/
|
||
public class Net_SessionLogin_Response extends Net_Response {
|
||
|
||
private String storagePwd;
|
||
|
||
public String getStoragePwd() {
|
||
return storagePwd;
|
||
}
|
||
|
||
public void setStoragePwd(String storagePwd) {
|
||
this.storagePwd = storagePwd;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth;
|
||
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import shine.db.dao.SolanaUsersDAO;
|
||
import shine.db.entities.SolanaUserEntry;
|
||
|
||
import java.security.SecureRandom;
|
||
import java.util.Base64;
|
||
|
||
/**
|
||
* AuthChallenge (v2) — шаг 1 создания новой сессии.
|
||
*
|
||
* Логика авторизации (v2):
|
||
* - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя.
|
||
* - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге:
|
||
* CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...))
|
||
*
|
||
* Что делает:
|
||
* 1) Проверяет login.
|
||
* 2) Находит пользователя (solana_users).
|
||
* 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS.
|
||
* 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce.
|
||
*/
|
||
public class Net_AuthChallenge_Handler implements JsonMessageHandler {
|
||
|
||
private static final SecureRandom RANDOM = new SecureRandom();
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||
|
||
Net_AuthChallenge_Request req = (Net_AuthChallenge_Request) baseReq;
|
||
|
||
String login = req.getLogin();
|
||
if (login == null || login.isBlank()) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"EMPTY_LOGIN",
|
||
"Пустой логин"
|
||
);
|
||
}
|
||
|
||
// Если по этому соединению уже есть залогиненный пользователь — не даём повторную авторификацию
|
||
if (ctx.getLogin() != null) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"ALREADY_AUTHED",
|
||
"Попытка повторной авторификации для уже заданного login=" + ctx.getLogin()
|
||
);
|
||
}
|
||
|
||
SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login);
|
||
if (solanaUserEntry == null) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.UNVERIFIED,
|
||
"UNKNOWN_USER",
|
||
"Пользователь с таким логином не найден"
|
||
);
|
||
}
|
||
|
||
ctx.setSolanaUser(solanaUserEntry);
|
||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS);
|
||
|
||
byte[] buf = new byte[32];
|
||
RANDOM.nextBytes(buf);
|
||
String authNonce = Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||
|
||
ctx.setAuthNonce(authNonce);
|
||
|
||
Net_AuthChallenge_Response resp = new Net_AuthChallenge_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
resp.setAuthNonce(authNonce);
|
||
|
||
return resp;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import server.ws.WsConnectionUtils;
|
||
import shine.db.dao.ActiveSessionsDAO;
|
||
import shine.db.entities.ActiveSessionEntry;
|
||
import shine.db.entities.SolanaUserEntry;
|
||
|
||
import java.sql.SQLException;
|
||
|
||
/**
|
||
* CloseActiveSession (v2) — закрытие текущей или другой сессии.
|
||
*
|
||
* Логика авторизации (v2):
|
||
* - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER).
|
||
* - Никаких подписей и AUTH_IN_PROGRESS здесь больше нет.
|
||
*
|
||
* Закрытие:
|
||
* - удаляем запись из БД
|
||
* - если по sessionId есть активный WS — закрываем его
|
||
*/
|
||
public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class);
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||
Net_CloseActiveSession_Request req = (Net_CloseActiveSession_Request) baseReq;
|
||
|
||
if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.UNVERIFIED,
|
||
"NOT_AUTHENTICATED",
|
||
"Операция доступна только для авторизованных пользователей"
|
||
);
|
||
}
|
||
|
||
SolanaUserEntry user = ctx.getSolanaUser();
|
||
String currentLogin = user.getLogin();
|
||
|
||
String targetSessionId = req.getSessionId();
|
||
if (targetSessionId == null || targetSessionId.isBlank()) {
|
||
if (ctx.getSessionId() != null && !ctx.getSessionId().isBlank()) {
|
||
targetSessionId = ctx.getSessionId();
|
||
} else if (ctx.getActiveSession() != null && ctx.getActiveSession().getSessionId() != null) {
|
||
targetSessionId = ctx.getActiveSession().getSessionId();
|
||
} else {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"NO_SESSION_TO_CLOSE",
|
||
"Не удалось определить, какую сессию нужно закрыть"
|
||
);
|
||
}
|
||
}
|
||
|
||
ActiveSessionEntry targetSession;
|
||
try {
|
||
targetSession = ActiveSessionsDAO.getInstance().getBySessionId(targetSessionId);
|
||
} catch (SQLException e) {
|
||
log.error("Ошибка БД при поиске сессии для CloseActiveSession sessionId={}", targetSessionId, e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"DB_ERROR",
|
||
"Ошибка доступа к базе данных при поиске сессии"
|
||
);
|
||
}
|
||
|
||
if (targetSession == null) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.UNVERIFIED,
|
||
"SESSION_NOT_FOUND",
|
||
"Сессия для закрытия не найдена"
|
||
);
|
||
}
|
||
|
||
if (currentLogin == null || !currentLogin.equals(targetSession.getLogin())) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.UNVERIFIED,
|
||
"SESSION_OF_ANOTHER_USER",
|
||
"Нельзя закрывать сессию другого пользователя"
|
||
);
|
||
}
|
||
|
||
boolean isCurrentSession = targetSessionId.equals(ctx.getSessionId());
|
||
|
||
closeActiveSession(targetSessionId, ctx, isCurrentSession);
|
||
|
||
Net_CloseActiveSession_Response resp = new Net_CloseActiveSession_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
return resp;
|
||
}
|
||
|
||
private void closeActiveSession(String targetSessionId,
|
||
ConnectionContext currentCtx,
|
||
boolean isCurrentSession) {
|
||
|
||
try {
|
||
ActiveSessionsDAO.getInstance().deleteBySessionId(targetSessionId);
|
||
} catch (SQLException e) {
|
||
log.error("Ошибка БД при удалении сессии sessionId={}", targetSessionId, e);
|
||
}
|
||
|
||
ConnectionContext ctxToClose =
|
||
ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId);
|
||
|
||
if (ctxToClose == null) return;
|
||
|
||
if (isCurrentSession && ctxToClose == currentCtx) {
|
||
new Thread(() -> {
|
||
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
|
||
WsConnectionUtils.closeConnection(
|
||
ctxToClose,
|
||
4000,
|
||
"Session closed by client via CloseActiveSession"
|
||
);
|
||
}, "CloseSession-" + targetSessionId).start();
|
||
} else {
|
||
WsConnectionUtils.closeConnection(
|
||
ctxToClose,
|
||
4000,
|
||
"Session closed by client via CloseActiveSession"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth;
|
||
|
||
import org.eclipse.jetty.websocket.api.Session;
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import server.ws.WsConnectionUtils;
|
||
import shine.db.dao.ActiveSessionsDAO;
|
||
import shine.db.entities.ActiveSessionEntry;
|
||
import shine.db.entities.SolanaUserEntry;
|
||
import shine.geo.ClientInfoService;
|
||
import shine.geo.GeoLookupService;
|
||
import utils.crypto.Ed25519Util;
|
||
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.security.SecureRandom;
|
||
import java.sql.SQLException;
|
||
import java.util.Base64;
|
||
|
||
/**
|
||
* CreateAuthSession (v2) — шаг 2 создания новой сессии (ТОЛЬКО deviceKey).
|
||
*
|
||
* Логика авторизации (v2):
|
||
* - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...)
|
||
* - Клиент генерирует sessionKey (Ed25519), хранит приватный ключ у себя,
|
||
* отправляет на сервер ТОЛЬКО sessionPubKeyB64.
|
||
* - Сервер сохраняет sessionPubKeyB64 в active_sessions.session_key.
|
||
*
|
||
* Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8):
|
||
* AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}
|
||
*
|
||
* На выходе:
|
||
* - создаётся запись active_sessions
|
||
* - ctx становится AUTH_STATUS_USER (вход выполнен как "текущая сессия")
|
||
* - ответ: sessionId
|
||
*/
|
||
public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class);
|
||
private static final SecureRandom RANDOM = new SecureRandom();
|
||
|
||
public static final long ALLOWED_SKEW_MS = 30_000L;
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||
|
||
Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq;
|
||
|
||
if (ctx == null
|
||
|| ctx.getSolanaUser() == null
|
||
|| ctx.getAuthNonce() == null
|
||
|| ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) {
|
||
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"NO_STEP1_CONTEXT",
|
||
"Шаг 1 авторизации не был корректно выполнен для данного соединения"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no step1 context or bad auth state");
|
||
return err;
|
||
}
|
||
|
||
SolanaUserEntry user = ctx.getSolanaUser();
|
||
String login = user.getLogin();
|
||
if (login == null || login.isBlank()) {
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"NO_LOGIN",
|
||
"Для пользователя не задан login в БД"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no login");
|
||
return err;
|
||
}
|
||
|
||
String storagePwd = req.getStoragePwd();
|
||
if (storagePwd == null || storagePwd.isBlank()) {
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"EMPTY_STORAGE_PWD",
|
||
"Пустой storagePwd"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty storagePwd");
|
||
return err;
|
||
}
|
||
|
||
String sessionPubKeyB64 = req.getSessionPubKeyB64();
|
||
if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) {
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"EMPTY_SESSION_PUBKEY",
|
||
"Пустой sessionPubKeyB64"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session pubkey");
|
||
return err;
|
||
}
|
||
|
||
// Проверим, что sessionPubKeyB64 декодируется в 32 байта
|
||
byte[] sessionPubKey32;
|
||
try {
|
||
sessionPubKey32 = decodeBase64Any(sessionPubKeyB64);
|
||
} catch (IllegalArgumentException e) {
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_BASE64",
|
||
"Некорректный base64 в sessionPubKeyB64"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey base64");
|
||
return err;
|
||
}
|
||
if (sessionPubKey32.length != 32) {
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_SESSION_PUBKEY_LEN",
|
||
"sessionPubKey должен быть 32 байта"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey length");
|
||
return err;
|
||
}
|
||
|
||
String signatureB64 = req.getSignatureB64();
|
||
if (signatureB64 == null || signatureB64.isBlank()) {
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"EMPTY_SIGNATURE",
|
||
"Пустая цифровая подпись"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty signature");
|
||
return err;
|
||
}
|
||
|
||
long timeMs = req.getTimeMs();
|
||
long nowMs = System.currentTimeMillis();
|
||
long diff = Math.abs(nowMs - timeMs);
|
||
if (diff > ALLOWED_SKEW_MS) {
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"TIME_SKEW",
|
||
"Время клиента отличается от сервера более чем на 30 секунд"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: time skew");
|
||
return err;
|
||
}
|
||
|
||
String clientInfoFromClient = req.getClientInfo();
|
||
if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) {
|
||
clientInfoFromClient = clientInfoFromClient.substring(0, 50);
|
||
}
|
||
|
||
String devicePubKeyB64 = user.getDeviceKey();
|
||
if (devicePubKeyB64 == null || devicePubKeyB64.isBlank()) {
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"NO_DEVICE_KEY",
|
||
"Отсутствует deviceKey у пользователя"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no deviceKey");
|
||
return err;
|
||
}
|
||
|
||
String authNonce = ctx.getAuthNonce();
|
||
|
||
boolean sigOk;
|
||
try {
|
||
sigOk = verifyCreateSessionSignature(
|
||
user,
|
||
login,
|
||
authNonce,
|
||
timeMs,
|
||
signatureB64
|
||
);
|
||
} catch (IllegalArgumentException ex) {
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_BASE64",
|
||
"Некорректный формат Base64 для ключа или подписи"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad base64");
|
||
return err;
|
||
}
|
||
|
||
if (!sigOk) {
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.UNVERIFIED,
|
||
"BAD_SIGNATURE",
|
||
"Подпись не прошла проверку"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad signature");
|
||
return err;
|
||
}
|
||
|
||
// --- генерируем sessionId ---
|
||
String sessionId = generateRandom32B64Url();
|
||
long now = System.currentTimeMillis();
|
||
|
||
// --- Сбор данных о клиенте (IP, UA, язык) ---
|
||
Session wsSession = ctx.getWsSession();
|
||
String clientInfoFromRequest = ClientInfoService.buildClientInfoString(wsSession);
|
||
String userLanguage = ClientInfoService.extractPreferredLanguageTag(wsSession);
|
||
|
||
String clientIp = "";
|
||
if (wsSession != null) {
|
||
String ip = ClientInfoService.extractClientIp(wsSession);
|
||
if (ip != null) clientIp = ip;
|
||
|
||
if (!clientIp.isBlank()) {
|
||
try {
|
||
GeoLookupService.resolveCountryCityOrIpWithCache(clientIp);
|
||
} catch (Exception e) {
|
||
log.debug("Geo lookup failed for ip={}", clientIp, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- создаём запись ActiveSession и сохраняем в БД ---
|
||
ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance();
|
||
ActiveSessionEntry activeSessionEntry;
|
||
|
||
try {
|
||
activeSessionEntry = new ActiveSessionEntry(
|
||
sessionId,
|
||
login,
|
||
sessionPubKeyB64, // session_key (pubkey)
|
||
storagePwd,
|
||
now,
|
||
now,
|
||
null, // pushEndpoint
|
||
null, // pushP256dhKey
|
||
null, // pushAuthKey
|
||
clientIp,
|
||
clientInfoFromClient,
|
||
clientInfoFromRequest,
|
||
userLanguage
|
||
);
|
||
|
||
dao.insert(activeSessionEntry);
|
||
} catch (SQLException e) {
|
||
log.error("Ошибка БД при создании новой сессии для login={}", login, e);
|
||
Net_Response err = NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"DB_ERROR_SESSION_CREATE",
|
||
"Ошибка БД при создании сессии"
|
||
);
|
||
WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: db error");
|
||
return err;
|
||
}
|
||
|
||
// --- обновляем контекст ---
|
||
ctx.setActiveSession(activeSessionEntry);
|
||
ctx.setSessionId(sessionId);
|
||
ctx.setAuthNonce(null);
|
||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||
|
||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||
|
||
// --- формируем ответ ---
|
||
Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
resp.setSessionId(sessionId);
|
||
return resp;
|
||
}
|
||
|
||
private static boolean verifyCreateSessionSignature(
|
||
SolanaUserEntry user,
|
||
String login,
|
||
String authNonce,
|
||
long timeMs,
|
||
String signatureB64
|
||
) throws IllegalArgumentException {
|
||
|
||
// deviceKey (pub, 32)
|
||
byte[] publicKey32 = Ed25519Util.keyFromBase64(user.getDeviceKey());
|
||
byte[] signature64 = decodeBase64Any(signatureB64);
|
||
|
||
String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce;
|
||
byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
|
||
|
||
return Ed25519Util.verify(preimage, signature64, publicKey32);
|
||
}
|
||
|
||
private static String generateRandom32B64Url() {
|
||
byte[] buf = new byte[32];
|
||
RANDOM.nextBytes(buf);
|
||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||
}
|
||
|
||
private static byte[] decodeBase64Any(String s) throws IllegalArgumentException {
|
||
if (s == null) throw new IllegalArgumentException("base64 is null");
|
||
String x = s.trim();
|
||
if (x.isEmpty()) throw new IllegalArgumentException("base64 is empty");
|
||
|
||
// сначала url-safe, потом обычный
|
||
try {
|
||
return Base64.getUrlDecoder().decode(x);
|
||
} catch (IllegalArgumentException ignore) {
|
||
return Base64.getDecoder().decode(x);
|
||
}
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response.SessionInfo;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import shine.db.dao.ActiveSessionsDAO;
|
||
import shine.db.entities.ActiveSessionEntry;
|
||
import shine.db.entities.SolanaUserEntry;
|
||
import shine.geo.GeoLookupService;
|
||
|
||
import java.sql.SQLException;
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* ListSessions (v2) — список активных сессий.
|
||
*
|
||
* Логика авторизации (v2):
|
||
* - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER).
|
||
* - Никаких подписей здесь больше нет.
|
||
*/
|
||
public class Net_ListSessions_Handler implements JsonMessageHandler {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(Net_ListSessions_Handler.class);
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||
Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq;
|
||
|
||
if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.UNVERIFIED,
|
||
"NOT_AUTHENTICATED",
|
||
"Операция доступна только для авторизованных пользователей"
|
||
);
|
||
}
|
||
|
||
SolanaUserEntry user = ctx.getSolanaUser();
|
||
String currentLogin = user.getLogin();
|
||
|
||
List<ActiveSessionEntry> sessions;
|
||
try {
|
||
sessions = ActiveSessionsDAO.getInstance().getByLogin(currentLogin);
|
||
} catch (SQLException e) {
|
||
log.error("Ошибка БД при получении списка сессий для login={}", currentLogin, e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"DB_ERROR_LIST_SESSIONS",
|
||
"Ошибка доступа к базе данных при получении списка сессий"
|
||
);
|
||
}
|
||
|
||
List<SessionInfo> resultList = new ArrayList<>();
|
||
for (ActiveSessionEntry s : sessions) {
|
||
SessionInfo info = new SessionInfo();
|
||
info.setSessionId(s.getSessionId());
|
||
info.setClientInfoFromClient(s.getClientInfoFromClient());
|
||
info.setClientInfoFromRequest(s.getClientInfoFromRequest());
|
||
info.setLastAuthirificatedAtMs(s.getLastAuthirificatedAtMs());
|
||
|
||
String ip = s.getClientIp();
|
||
String geo = GeoLookupService.resolveCountryCityOrIpWithCache(ip);
|
||
info.setGeo(geo);
|
||
|
||
resultList.add(info);
|
||
}
|
||
|
||
Net_ListSessions_Response resp = new Net_ListSessions_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
resp.setSessions(resultList);
|
||
|
||
return resp;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth;
|
||
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Response;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import shine.db.dao.ActiveSessionsDAO;
|
||
import shine.db.entities.ActiveSessionEntry;
|
||
|
||
import java.security.SecureRandom;
|
||
import java.sql.SQLException;
|
||
import java.util.Base64;
|
||
|
||
/**
|
||
* SessionChallenge (v2) — шаг 1 входа в существующую сессию.
|
||
*
|
||
* Логика авторизации (v2):
|
||
* - Вход в существующую сессию ВСЕГДА в 2 шага:
|
||
* 1) SessionChallenge(sessionId) -> nonce
|
||
* 2) SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...))
|
||
*
|
||
* Что делает:
|
||
* - Проверяет, что sessionId существует в БД.
|
||
* - Генерирует одноразовый nonce (base64url(32)), сохраняет его в ctx:
|
||
* ctx.sessionLoginNonce, ctx.sessionLoginSessionId, ctx.sessionLoginNonceExpiresAtMs.
|
||
*/
|
||
public class Net_SessionChallenge_Handler implements JsonMessageHandler {
|
||
|
||
private static final SecureRandom RANDOM = new SecureRandom();
|
||
private static final long NONCE_TTL_MS = 60_000L;
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||
Net_SessionChallenge_Request req = (Net_SessionChallenge_Request) baseReq;
|
||
|
||
String sessionId = req.getSessionId();
|
||
if (sessionId == null || sessionId.isBlank()) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"EMPTY_SESSION_ID",
|
||
"Пустой sessionId"
|
||
);
|
||
}
|
||
|
||
ActiveSessionEntry session;
|
||
try {
|
||
session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId);
|
||
} catch (SQLException e) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"DB_ERROR",
|
||
"Ошибка доступа к базе данных"
|
||
);
|
||
}
|
||
|
||
if (session == null) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.UNVERIFIED,
|
||
"SESSION_NOT_FOUND",
|
||
"Сессия не найдена"
|
||
);
|
||
}
|
||
|
||
byte[] buf = new byte[32];
|
||
RANDOM.nextBytes(buf);
|
||
String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||
|
||
long now = System.currentTimeMillis();
|
||
ctx.setSessionLoginNonce(nonce);
|
||
ctx.setSessionLoginSessionId(sessionId);
|
||
ctx.setSessionLoginNonceExpiresAtMs(now + NONCE_TTL_MS);
|
||
|
||
Net_SessionChallenge_Response resp = new Net_SessionChallenge_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
resp.setNonce(nonce);
|
||
return resp;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.auth;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import shine.db.dao.ActiveSessionsDAO;
|
||
import shine.db.dao.SolanaUsersDAO;
|
||
import shine.db.entities.ActiveSessionEntry;
|
||
import shine.db.entities.SolanaUserEntry;
|
||
import shine.geo.ClientInfoService;
|
||
import shine.geo.GeoLookupService;
|
||
import utils.crypto.Ed25519Util;
|
||
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.sql.SQLException;
|
||
import java.util.Base64;
|
||
|
||
/**
|
||
* SessionLogin (v2) — шаг 2 входа в существующую сессию (по sessionKey).
|
||
*
|
||
* Логика авторизации (v2):
|
||
* - SessionChallenge(sessionId) выдаёт nonce (одноразовый, TTL).
|
||
* - SessionLogin проверяет подпись sessionKey над строкой:
|
||
* SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
|
||
* - sessionPubKey берём из БД: active_sessions.session_key (base64 32 bytes).
|
||
*
|
||
* При успехе:
|
||
* - ctx становится AUTH_STATUS_USER
|
||
* - обновляем метаданные сессии (lastAuth + clientIp + clientInfo + lang)
|
||
* - возвращаем storagePwd
|
||
*/
|
||
public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class);
|
||
|
||
private static final long ALLOWED_SKEW_MS = 30_000L;
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||
Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq;
|
||
|
||
String sessionId = req.getSessionId();
|
||
if (sessionId == null || sessionId.isBlank()) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"EMPTY_SESSION_ID",
|
||
"Пустой sessionId"
|
||
);
|
||
}
|
||
|
||
// проверка челленджа
|
||
if (ctx.getSessionLoginNonce() == null
|
||
|| ctx.getSessionLoginSessionId() == null
|
||
|| System.currentTimeMillis() > ctx.getSessionLoginNonceExpiresAtMs()) {
|
||
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"NO_CHALLENGE",
|
||
"Нет активного SessionChallenge или nonce истёк"
|
||
);
|
||
}
|
||
|
||
if (!sessionId.equals(ctx.getSessionLoginSessionId())) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"SESSION_ID_MISMATCH",
|
||
"nonce был выдан для другого sessionId"
|
||
);
|
||
}
|
||
|
||
long timeMs = req.getTimeMs();
|
||
long nowMs = System.currentTimeMillis();
|
||
if (Math.abs(nowMs - timeMs) > ALLOWED_SKEW_MS) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"TIME_SKEW",
|
||
"Время клиента отличается от сервера более чем на 30 секунд"
|
||
);
|
||
}
|
||
|
||
String signatureB64 = req.getSignatureB64();
|
||
if (signatureB64 == null || signatureB64.isBlank()) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"EMPTY_SIGNATURE",
|
||
"Пустая подпись"
|
||
);
|
||
}
|
||
|
||
ActiveSessionEntry session;
|
||
try {
|
||
session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId);
|
||
} catch (SQLException e) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"DB_ERROR",
|
||
"Ошибка доступа к базе данных"
|
||
);
|
||
}
|
||
|
||
if (session == null) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.UNVERIFIED,
|
||
"SESSION_NOT_FOUND",
|
||
"Сессия не найдена"
|
||
);
|
||
}
|
||
|
||
String sessionPubKeyB64 = session.getSessionKey(); // это pubKey
|
||
if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"NO_SESSION_KEY",
|
||
"В сессии не задан session_key"
|
||
);
|
||
}
|
||
|
||
String nonce = ctx.getSessionLoginNonce();
|
||
|
||
boolean sigOk;
|
||
try {
|
||
sigOk = verifySessionLoginSignature(sessionPubKeyB64, sessionId, timeMs, nonce, signatureB64);
|
||
} catch (IllegalArgumentException e) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_BASE64",
|
||
"Некорректный Base64 для ключа/подписи"
|
||
);
|
||
}
|
||
|
||
if (!sigOk) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.UNVERIFIED,
|
||
"BAD_SIGNATURE",
|
||
"Подпись не прошла проверку"
|
||
);
|
||
}
|
||
|
||
// сжигаем nonce
|
||
ctx.setSessionLoginNonce(null);
|
||
ctx.setSessionLoginSessionId(null);
|
||
ctx.setSessionLoginNonceExpiresAtMs(0);
|
||
|
||
// подтягиваем пользователя
|
||
SolanaUserEntry user;
|
||
try {
|
||
user = SolanaUsersDAO.getInstance().getByLogin(session.getLogin());
|
||
} catch (SQLException e) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"DB_ERROR_USER_LOOKUP",
|
||
"Ошибка доступа к базе данных при получении пользователя"
|
||
);
|
||
}
|
||
|
||
if (user == null) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.UNVERIFIED,
|
||
"USER_NOT_FOUND_FOR_SESSION",
|
||
"Пользователь для данной сессии не найден"
|
||
);
|
||
}
|
||
|
||
// обновление метаданных
|
||
String clientInfoFromClient = req.getClientInfo();
|
||
if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) {
|
||
clientInfoFromClient = clientInfoFromClient.substring(0, 50);
|
||
}
|
||
|
||
String clientIp = null;
|
||
String clientInfoFromRequest = null;
|
||
String userLanguage = null;
|
||
|
||
if (ctx.getWsSession() != null) {
|
||
clientIp = ClientInfoService.extractClientIp(ctx.getWsSession());
|
||
clientInfoFromRequest = ClientInfoService.buildClientInfoString(ctx.getWsSession());
|
||
userLanguage = ClientInfoService.extractPreferredLanguageTag(ctx.getWsSession());
|
||
|
||
if (clientIp != null && !clientIp.isBlank()) {
|
||
try {
|
||
GeoLookupService.resolveCountryCityOrIpWithCache(clientIp);
|
||
} catch (Exception e) {
|
||
log.debug("Geo lookup failed for ip={}", clientIp, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
long now = System.currentTimeMillis();
|
||
try {
|
||
ActiveSessionsDAO.getInstance().updateOnRefresh(
|
||
sessionId,
|
||
now,
|
||
clientIp,
|
||
clientInfoFromClient,
|
||
clientInfoFromRequest,
|
||
userLanguage
|
||
);
|
||
} catch (SQLException e) {
|
||
log.error("Ошибка БД при updateOnRefresh sessionId={}", sessionId, e);
|
||
}
|
||
|
||
session.setLastAuthirificatedAtMs(now);
|
||
session.setClientIp(clientIp);
|
||
session.setClientInfoFromClient(clientInfoFromClient);
|
||
session.setClientInfoFromRequest(clientInfoFromRequest);
|
||
session.setUserLanguage(userLanguage);
|
||
|
||
// ctx
|
||
ctx.setActiveSession(session);
|
||
ctx.setSolanaUser(user);
|
||
ctx.setSessionId(sessionId);
|
||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||
|
||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||
|
||
// ответ
|
||
Net_SessionLogin_Response resp = new Net_SessionLogin_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
resp.setStoragePwd(session.getStoragePwd());
|
||
return resp;
|
||
}
|
||
|
||
private static boolean verifySessionLoginSignature(
|
||
String sessionPubKeyB64,
|
||
String sessionId,
|
||
long timeMs,
|
||
String nonce,
|
||
String signatureB64
|
||
) throws IllegalArgumentException {
|
||
|
||
byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64);
|
||
byte[] signature64 = decodeBase64Any(signatureB64);
|
||
|
||
String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce;
|
||
byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8);
|
||
|
||
return Ed25519Util.verify(preimage, signature64, publicKey32);
|
||
}
|
||
|
||
private static byte[] decodeBase64Any(String s) throws IllegalArgumentException {
|
||
try {
|
||
return Base64.getUrlDecoder().decode(s);
|
||
} catch (IllegalArgumentException ignore) {
|
||
return Base64.getDecoder().decode(s);
|
||
}
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
public final class Net_AddBlock_Request extends Net_Request {
|
||
|
||
private String blockchainName; // обязателен
|
||
private int blockNumber; // обязателен
|
||
private String prevBlockHash; // HEX(64) или "" для нулевого
|
||
private String blockBytesB64; // байты FULL-блока (raw+sig+hash) в Base64
|
||
|
||
public String getBlockchainName() { return blockchainName; }
|
||
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||
|
||
public int getBlockNumber() { return blockNumber; }
|
||
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||
|
||
public String getPrevBlockHash() { return prevBlockHash; }
|
||
public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; }
|
||
|
||
public String getBlockBytesB64() { return blockBytesB64; }
|
||
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
/**
|
||
* Ответ:
|
||
* - reasonCode (null если ok)
|
||
* - serverLastGlobalNumber / serverLastGlobalHash
|
||
*/
|
||
public final class Net_AddBlock_Response extends Net_Response {
|
||
|
||
/** null если ok, иначе строка причины (bad_block_base64, user_not_found, и т.п.) */
|
||
private String reasonCode;
|
||
|
||
/** что сервер считает последним по глобальной цепочке */
|
||
private int serverLastGlobalNumber;
|
||
private String serverLastGlobalHash;
|
||
|
||
public String getReasonCode() { return reasonCode; }
|
||
public void setReasonCode(String reasonCode) { this.reasonCode = reasonCode; }
|
||
|
||
public int getServerLastGlobalNumber() { return serverLastGlobalNumber; }
|
||
public void setServerLastGlobalNumber(int v) { this.serverLastGlobalNumber = v; }
|
||
|
||
public String getServerLastGlobalHash() { return serverLastGlobalHash; }
|
||
public void setServerLastGlobalHash(String v) { this.serverLastGlobalHash = v; }
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.blockchain;
|
||
|
||
import blockchain.BchBlockEntry;
|
||
import blockchain.BchCryptoVerifier;
|
||
import blockchain.MsgSubType;
|
||
import blockchain.body.BodyHasLine;
|
||
import blockchain.body.BodyHasTarget;
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import shine.db.dao.BlockchainStateDAO;
|
||
import shine.db.dao.BlocksDAO;
|
||
import shine.db.entities.BlockchainStateEntry;
|
||
import shine.db.entities.BlockEntry;
|
||
import utils.blockchain.BlockchainNameUtil;
|
||
|
||
import java.util.Arrays;
|
||
import java.util.Base64;
|
||
import java.util.concurrent.locks.ReentrantLock;
|
||
|
||
/**
|
||
* Net_AddBlock_Handler — единый хэндлер добавления блока (JSON).
|
||
*
|
||
* Новый порядок валидации (ТЗ):
|
||
* 1) Достаём из blockchain_state: last_block_number, last_block_hash
|
||
* 2) Проверяем:
|
||
* - incoming.blockNumber == last+1
|
||
* - incoming.prevHash32 == last_hash (для genesis last_hash = 32 нулей)
|
||
* 3) Проверяем подпись Ed25519.verify(hash32(preimage), signature64, pubKey)
|
||
* 4) Если тип имеет линию:
|
||
* - если prevLineNumber != null:
|
||
* достаём hash блока prevLineNumber из blocks
|
||
* сравниваем с prevLineHash32 из body
|
||
* 5) Сохраняем блок в blocks + обновляем blockchain_state
|
||
*
|
||
* Важно:
|
||
* - Сетевой протокол AddBlock пока оставляем старые поля (globalNumber/prevGlobalHash),
|
||
* но внутренняя логика использует НОВЫЙ формат блока.
|
||
*/
|
||
public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(Net_AddBlock_Handler.class);
|
||
|
||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||
|
||
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO);
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||
|
||
Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq;
|
||
|
||
String blockchainName = req.getBlockchainName();
|
||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||
lock.lock();
|
||
try {
|
||
AddBlockResult r = addBlock(
|
||
blockchainName,
|
||
req.getBlockNumber(), // старое поле, пока оставляем
|
||
req.getPrevBlockHash(), // старое поле, пока оставляем
|
||
req.getBlockBytesB64()
|
||
);
|
||
|
||
Net_AddBlock_Response resp = new Net_AddBlock_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
|
||
if (r.isOk()) {
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
resp.setReasonCode(null);
|
||
} else {
|
||
resp.setStatus(r.httpStatus);
|
||
resp.setReasonCode(r.reasonCode);
|
||
}
|
||
|
||
resp.setServerLastGlobalNumber(r.serverLastBlockNumber);
|
||
resp.setServerLastGlobalHash(r.serverLastBlockHashHex);
|
||
|
||
return resp;
|
||
|
||
} finally {
|
||
lock.unlock();
|
||
}
|
||
}
|
||
|
||
private AddBlockResult addBlock(
|
||
String blockchainName,
|
||
int globalNumberFromReq,
|
||
String prevGlobalHashHexFromReq,
|
||
String blockBytesB64
|
||
) {
|
||
if (blockchainName == null || blockchainName.isBlank()) {
|
||
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "empty_blockchain_name", 0, "");
|
||
}
|
||
|
||
String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName);
|
||
if (login == null || login.isBlank()) {
|
||
log.warn("AddBlock: плохой blockchainName='{}' => login не получился (reqGlobalNumber={})",
|
||
blockchainName, globalNumberFromReq);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_name", 0, "");
|
||
}
|
||
|
||
// 1) state обязателен
|
||
final BlockchainStateEntry st;
|
||
try {
|
||
st = stateDAO.getByBlockchainName(blockchainName);
|
||
} catch (Exception e) {
|
||
log.error("AddBlock: ошибка БД при чтении blockchain_state (login={}, blockchainName={}, reqGlobalNumber={})",
|
||
login, blockchainName, globalNumberFromReq, e);
|
||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error", 0, "");
|
||
}
|
||
|
||
if (st == null) {
|
||
log.warn("AddBlock: blockchain_state_not_found (login={}, blockchainName={}, reqGlobalNumber={})",
|
||
login, blockchainName, globalNumberFromReq);
|
||
return new AddBlockResult(WireCodes.Status.NOT_FOUND, "blockchain_state_not_found", -1, "");
|
||
}
|
||
|
||
final int serverLastNum = st.getLastBlockNumber();
|
||
final byte[] serverLastHash32 = (serverLastNum < 0)
|
||
? new byte[32]
|
||
: require32OrThrow(st.getLastBlockHash(), "state.last_block_hash is null/invalid");
|
||
|
||
final String serverLastHashHex = toHex(serverLastHash32);
|
||
|
||
// 2) decode block
|
||
final byte[] blockBytes;
|
||
try {
|
||
blockBytes = decodeBase64(blockBytesB64);
|
||
} catch (Exception e) {
|
||
log.warn("AddBlock: некорректный base64 блока (login={}, blockchainName={}, reqGlobalNumber={})",
|
||
login, blockchainName, globalNumberFromReq, e);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_base64", serverLastNum, serverLastHashHex);
|
||
}
|
||
|
||
// 3) лимит (оставляем как было)
|
||
try {
|
||
long oldSize = st.getFileSizeBytes();
|
||
long limit = st.getSizeLimit();
|
||
long newSize = safeAdd(oldSize, blockBytes.length);
|
||
|
||
if (limit > 0 && newSize > limit) {
|
||
log.warn("AddBlock: limit_exceeded (login={}, blockchainName={}, oldSize={}, addLen={}, newSize={}, limit={})",
|
||
login, blockchainName, oldSize, blockBytes.length, newSize, limit);
|
||
return new AddBlockResult(413, "limit_exceeded", serverLastNum, serverLastHashHex);
|
||
}
|
||
} catch (Exception e) {
|
||
log.error("AddBlock: limit_check_failed (login={}, blockchainName={})", login, blockchainName, e);
|
||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "limit_check_failed", serverLastNum, serverLastHashHex);
|
||
}
|
||
|
||
// 4) parse block
|
||
final BchBlockEntry block;
|
||
try {
|
||
block = new BchBlockEntry(blockBytes);
|
||
} catch (Exception e) {
|
||
log.warn("AddBlock: не удалось распарсить BchBlockEntry (login={}, blockchainName={}, bytesLen={})",
|
||
login, blockchainName, blockBytes.length, e);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex);
|
||
}
|
||
|
||
// body.check()
|
||
try {
|
||
block.body.check();
|
||
} catch (Exception e) {
|
||
log.warn("AddBlock: body.check() не прошёл (login={}, blockchainName={}, blockNumber={}, type={}, ver={})",
|
||
login, blockchainName, block.blockNumber, (block.type & 0xFFFF), (block.version & 0xFFFF), e);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
|
||
}
|
||
|
||
// 4.2) запрет дырок: blockNumber строго last+1
|
||
int expectedBlockNumber = serverLastNum + 1;
|
||
if (block.blockNumber != expectedBlockNumber) {
|
||
log.warn("AddBlock: bad_block_number (login={}, blockchainName={}, пришёл={}, ожидали={}, serverLastNum={})",
|
||
login, blockchainName, block.blockNumber, expectedBlockNumber, serverLastNum);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_number", serverLastNum, serverLastHashHex);
|
||
}
|
||
|
||
// (временная совместимость) req.globalNumber должен совпасть с block.blockNumber
|
||
if (globalNumberFromReq != block.blockNumber) {
|
||
log.warn("AddBlock: req_global_mismatch (login={}, blockchainName={}, reqGlobal={}, blockNumber={})",
|
||
login, blockchainName, globalNumberFromReq, block.blockNumber);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "req_global_mismatch", serverLastNum, serverLastHashHex);
|
||
}
|
||
|
||
// 4.3) проверка цепочки по prevHash32
|
||
if (!Arrays.equals(block.prevHash32, serverLastHash32)) {
|
||
log.warn("AddBlock: bad_prev_hash (login={}, blockchainName={}, blockNumber={}, clientPrev={}, serverPrev={})",
|
||
login, blockchainName, block.blockNumber, toHex(block.prevHash32), serverLastHashHex);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_hash", serverLastNum, serverLastHashHex);
|
||
}
|
||
|
||
// 5) pubKey
|
||
final byte[] pubKey32 = st.getBlockchainKeyBytes();
|
||
if (pubKey32 == null || pubKey32.length != 32) {
|
||
log.warn("AddBlock: bad_blockchain_key_len (login={}, blockchainName={}, blockNumber={}, keyLen={})",
|
||
login, blockchainName, block.blockNumber, (pubKey32 == null ? -1 : pubKey32.length));
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
|
||
}
|
||
|
||
// 6) подпись по hash32(preimage)
|
||
boolean sigOk;
|
||
try {
|
||
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
|
||
} catch (Exception e) {
|
||
log.warn("AddBlock: signature_verify_failed (login={}, blockchainName={}, blockNumber={})",
|
||
login, blockchainName, block.blockNumber, e);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex);
|
||
}
|
||
|
||
if (!sigOk) {
|
||
log.warn("AddBlock: bad_signature (login={}, blockchainName={}, blockNumber={})",
|
||
login, blockchainName, block.blockNumber);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex);
|
||
}
|
||
|
||
// 7) line columns (only for BodyHasLine)
|
||
Integer lineCode = null;
|
||
Integer prevLineNumber = null;
|
||
byte[] prevLineHash32 = null;
|
||
Integer thisLineNumber = null;
|
||
|
||
if (block.body instanceof BodyHasLine bl) {
|
||
lineCode = bl.lineCode();
|
||
prevLineNumber = bl.prevLineBlockGlobalNumber();
|
||
prevLineHash32 = bl.prevLineBlockHash32();
|
||
thisLineNumber = bl.lineSeq();
|
||
|
||
// Нормализация: -1 не пишем в БД (для совместимости со старым TextBody)
|
||
if (prevLineNumber != null && prevLineNumber == -1) {
|
||
prevLineNumber = null;
|
||
prevLineHash32 = null;
|
||
thisLineNumber = null;
|
||
}
|
||
|
||
// Если prevLineNumber задан — проверяем его хэш
|
||
if (prevLineNumber != null) {
|
||
try {
|
||
byte[] dbPrevHash = blocksDAO.getHashByNumber(blockchainName, prevLineNumber);
|
||
if (dbPrevHash == null) {
|
||
log.warn("AddBlock: prev_line_block_not_found (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})",
|
||
login, blockchainName, block.blockNumber, prevLineNumber);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "prev_line_block_not_found", serverLastNum, serverLastHashHex);
|
||
}
|
||
if (!Arrays.equals(dbPrevHash, require32OrThrow(prevLineHash32, "prevLineHash32 invalid"))) {
|
||
log.warn("AddBlock: bad_prev_line_hash (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})",
|
||
login, blockchainName, block.blockNumber, prevLineNumber);
|
||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_line_hash", serverLastNum, serverLastHashHex);
|
||
}
|
||
} catch (Exception e) {
|
||
log.error("AddBlock: db_error_prev_line_check (login={}, blockchainName={}, blockNumber={})",
|
||
login, blockchainName, block.blockNumber, e);
|
||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error_prev_line_check", serverLastNum, serverLastHashHex);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 8) сформировать запись и записать (DB + state + файл)
|
||
try {
|
||
BlockEntry be = new BlockEntry();
|
||
be.setLogin(login);
|
||
be.setBchName(blockchainName);
|
||
|
||
be.setBlockNumber(block.blockNumber);
|
||
be.setMsgType(block.type & 0xFFFF);
|
||
be.setMsgSubType(block.subType & 0xFFFF);
|
||
|
||
be.setBlockBytes(block.toBytes());
|
||
be.setBlockHash(block.getHash32());
|
||
be.setBlockSignature(block.getSignature64());
|
||
|
||
// line columns (optional)
|
||
be.setLineCode(lineCode);
|
||
be.setPrevLineNumber(prevLineNumber);
|
||
be.setPrevLineHash(prevLineHash32);
|
||
be.setThisLineNumber(thisLineNumber);
|
||
|
||
// target columns (optional)
|
||
if (block.body instanceof BodyHasTarget t) {
|
||
be.setToLogin(t.toLogin());
|
||
be.setToBchName(t.toBchName());
|
||
be.setToBlockNumber(t.toBlockGlobalNumber());
|
||
be.setToBlockHash(t.toBlockHashBytes());
|
||
}
|
||
|
||
// edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели"
|
||
int type = block.type & 0xFFFF;
|
||
int sub = block.subType & 0xFFFF;
|
||
|
||
if (type == 1
|
||
&& (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
||
&& be.getToBlockNumber() != null) {
|
||
be.setEditedByBlockNumber(be.getToBlockNumber());
|
||
}
|
||
|
||
dbWriter.appendBlockAndState(blockchainName, block, st, be);
|
||
|
||
} catch (Exception e) {
|
||
log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, blockNumber={})",
|
||
login, blockchainName, block.blockNumber, e);
|
||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||
}
|
||
|
||
String newHashHex = toHex(block.getHash32());
|
||
|
||
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
|
||
login, blockchainName, block.blockNumber, newHashHex);
|
||
|
||
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
|
||
}
|
||
|
||
/* ===================================================================== */
|
||
/* ====================== Helpers ====================================== */
|
||
/* ===================================================================== */
|
||
|
||
private static byte[] decodeBase64(String b64) {
|
||
if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null");
|
||
return Base64.getDecoder().decode(b64);
|
||
}
|
||
|
||
private static long safeAdd(long a, long b) {
|
||
long r = a + b;
|
||
if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow");
|
||
return r;
|
||
}
|
||
|
||
private static byte[] require32OrThrow(byte[] b, String msg) {
|
||
if (b == null || b.length != 32) throw new IllegalArgumentException(msg);
|
||
return b;
|
||
}
|
||
|
||
private static String toHex(byte[] bytes) {
|
||
if (bytes == null) return "null";
|
||
char[] HEX = "0123456789abcdef".toCharArray();
|
||
char[] out = new char[bytes.length * 2];
|
||
for (int i = 0; i < bytes.length; i++) {
|
||
int v = bytes[i] & 0xFF;
|
||
out[i * 2] = HEX[v >>> 4];
|
||
out[i * 2 + 1] = HEX[v & 0x0F];
|
||
}
|
||
return new String(out);
|
||
}
|
||
|
||
private static final class AddBlockResult {
|
||
final int httpStatus;
|
||
final String reasonCode;
|
||
final int serverLastBlockNumber;
|
||
final String serverLastBlockHashHex;
|
||
|
||
AddBlockResult(int httpStatus, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {
|
||
this.httpStatus = httpStatus;
|
||
this.reasonCode = reasonCode;
|
||
this.serverLastBlockNumber = serverLastBlockNumber;
|
||
this.serverLastBlockHashHex = serverLastBlockHashHex;
|
||
}
|
||
|
||
boolean isOk() { return httpStatus == WireCodes.Status.OK; }
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils;
|
||
|
||
import java.util.concurrent.ConcurrentHashMap;
|
||
import java.util.concurrent.locks.ReentrantLock;
|
||
|
||
public final class BlockchainLocks {
|
||
private static final ConcurrentHashMap<String, ReentrantLock> MAP = new ConcurrentHashMap<>();
|
||
|
||
private BlockchainLocks() {}
|
||
|
||
public static ReentrantLock lockFor(String blockchainName) {
|
||
return MAP.computeIfAbsent(blockchainName, id -> new ReentrantLock(true)); // fair=true
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils;
|
||
|
||
import blockchain.BchBlockEntry;
|
||
import shine.db.dao.BlockchainStateDAO;
|
||
import shine.db.dao.BlocksDAO;
|
||
import shine.db.entities.BlockchainStateEntry;
|
||
import shine.db.entities.BlockEntry;
|
||
import utils.files.FileStoreUtil;
|
||
|
||
import java.sql.Connection;
|
||
import java.sql.SQLException;
|
||
|
||
/**
|
||
* BlockchainWriter — запись блока в DB + обновление state + запись в файл.
|
||
*
|
||
* ВАЖНО:
|
||
* - Это минимальный рабочий вариант под новый формат.
|
||
* - Если у тебя уже есть "атомарность" сложнее (tmp_bch + commit/recovery) — можно усилить потом.
|
||
*/
|
||
public final class BlockchainWriter {
|
||
|
||
private final BlocksDAO blocksDAO;
|
||
private final BlockchainStateDAO stateDAO;
|
||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||
|
||
public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO) {
|
||
this.blocksDAO = blocksDAO;
|
||
this.stateDAO = stateDAO;
|
||
}
|
||
|
||
public void appendBlockAndState(String blockchainName,
|
||
BchBlockEntry block,
|
||
BlockchainStateEntry st,
|
||
BlockEntry be) throws SQLException {
|
||
|
||
long nowMs = System.currentTimeMillis();
|
||
|
||
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
||
c.setAutoCommit(false);
|
||
try {
|
||
// 1) insert block
|
||
blocksDAO.insert(c, be);
|
||
|
||
// 2) update state
|
||
st.setLastBlockNumber(block.blockNumber);
|
||
st.setLastBlockHash(block.getHash32());
|
||
st.setFileSizeBytes(st.getFileSizeBytes() + block.toBytes().length);
|
||
st.setUpdatedAtMs(nowMs);
|
||
|
||
stateDAO.upsert(c, st);
|
||
|
||
c.commit();
|
||
} catch (Exception e) {
|
||
try { c.rollback(); } catch (Exception ignored) {}
|
||
if (e instanceof SQLException se) throw se;
|
||
throw new SQLException("appendBlockAndState failed", e);
|
||
} finally {
|
||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||
}
|
||
}
|
||
|
||
// 3) append to file (минимально: просто дописать)
|
||
// Если у тебя уже есть логика tmp_bch+atomicReplace — можно заменить тут.
|
||
String fileName = fs.buildBlockchainFileName(blockchainName);
|
||
fs.addDataToFile(fileName, block.toBytes());
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers;
|
||
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
/**
|
||
* Общий интерфейс для всех JSON-хэндлеров.
|
||
*/
|
||
public interface JsonMessageHandler {
|
||
|
||
/**
|
||
* Обработать запрос и вернуть ответ.
|
||
*
|
||
* @param request распарсенный запрос
|
||
* @param ctx контекст текущего WebSocket-соединения
|
||
*/
|
||
Net_Response handle(Net_Request request, ConnectionContext ctx) throws Exception;
|
||
}
|
||
|
||
package server.logic.ws_protocol.JSON.handlers.subscriptions.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Запрос GetSubscribedChannels.
|
||
*
|
||
* Клиент отправляет:
|
||
* {
|
||
* "op": "GetSubscribedChannels",
|
||
* "requestId": "....",
|
||
* "payload": {
|
||
* "login": "anya"
|
||
* }
|
||
* }
|
||
*/
|
||
public class Net_GetSubscribedChannels_Request extends Net_Request {
|
||
|
||
private String login;
|
||
|
||
public String getLogin() { return login; }
|
||
public void setLogin(String login) { this.login = login; }
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.subscriptions.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
import java.util.List;
|
||
|
||
/**
|
||
* Ответ GetSubscribedChannels.
|
||
*
|
||
* payload:
|
||
* {
|
||
* "channels": [
|
||
* {
|
||
* "channelLogin": "dima",
|
||
* "channelBchName": "dima-001",
|
||
* "publicationsCount": 123,
|
||
* "lastPublicationTimestampSec": 1736371200,
|
||
* "lastTextPreview": "...."
|
||
* }
|
||
* ]
|
||
* }
|
||
*/
|
||
public class Net_GetSubscribedChannels_Response extends Net_Response {
|
||
|
||
private List<ChannelInfo> channels;
|
||
|
||
public List<ChannelInfo> getChannels() { return channels; }
|
||
public void setChannels(List<ChannelInfo> channels) { this.channels = channels; }
|
||
|
||
public static class ChannelInfo {
|
||
|
||
private String channelLogin;
|
||
private String channelBchName;
|
||
|
||
private Integer publicationsCount;
|
||
|
||
/** Unix seconds времени ПУБЛИКАЦИИ (оригинального TEXT_NEW). Nullable, если публикаций нет. */
|
||
private Long lastPublicationTimestampSec;
|
||
|
||
/** Первые 50 символов актуального текста (edit или orig). Nullable, если публикаций нет. */
|
||
private String lastTextPreview;
|
||
|
||
public String getChannelLogin() { return channelLogin; }
|
||
public void setChannelLogin(String channelLogin) { this.channelLogin = channelLogin; }
|
||
|
||
public String getChannelBchName() { return channelBchName; }
|
||
public void setChannelBchName(String channelBchName) { this.channelBchName = channelBchName; }
|
||
|
||
public Integer getPublicationsCount() { return publicationsCount; }
|
||
public void setPublicationsCount(Integer publicationsCount) { this.publicationsCount = publicationsCount; }
|
||
|
||
public Long getLastPublicationTimestampSec() { return lastPublicationTimestampSec; }
|
||
public void setLastPublicationTimestampSec(Long lastPublicationTimestampSec) { this.lastPublicationTimestampSec = lastPublicationTimestampSec; }
|
||
|
||
public String getLastTextPreview() { return lastTextPreview; }
|
||
public void setLastTextPreview(String lastTextPreview) { this.lastTextPreview = lastTextPreview; }
|
||
}
|
||
}
|
||
//package server.logic.ws_protocol.JSON.handlers.subscriptions;
|
||
//
|
||
//import blockchain.BchBlockEntry;
|
||
//import blockchain.body.TextBody;
|
||
//import org.slf4j.Logger;
|
||
//import org.slf4j.LoggerFactory;
|
||
//import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
//import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
//import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
//import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
//import server.logic.ws_protocol.JSON.handlers.subscriptions.entyties.Net_GetSubscribedChannels_Request;
|
||
//import server.logic.ws_protocol.JSON.handlers.subscriptions.entyties.Net_GetSubscribedChannels_Response;
|
||
//import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
//import server.logic.ws_protocol.WireCodes;
|
||
//import shine.db.SqliteDbController;
|
||
//import shine.db.dao.SubscriptionsDAO;
|
||
//
|
||
//import java.sql.Connection;
|
||
//import java.sql.SQLException;
|
||
//import java.util.ArrayList;
|
||
//import java.util.List;
|
||
//
|
||
///**
|
||
// * Handler: GetSubscribedChannels
|
||
// *
|
||
// * Логика:
|
||
// * - DAO возвращает last publication orig bytes (+ edit bytes если есть)
|
||
// * - Handler парсит FULL bytes блока:
|
||
// * timestamp берём из ОРИГИНАЛА (publication)
|
||
// * текст берём из EDIT (если есть) иначе из оригинала
|
||
// * - формируем превью первых 50 символов
|
||
// */
|
||
//public class Net_GetSubscribedChannels_Handler implements JsonMessageHandler {
|
||
//
|
||
// private static final Logger log = LoggerFactory.getLogger(Net_GetSubscribedChannels_Handler.class);
|
||
//
|
||
// @Override
|
||
// public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||
// Net_GetSubscribedChannels_Request req = (Net_GetSubscribedChannels_Request) baseRequest;
|
||
//
|
||
// if (req.getLogin() == null || req.getLogin().isBlank()) {
|
||
// return NetExceptionResponseFactory.error(
|
||
// req,
|
||
// WireCodes.Status.BAD_REQUEST,
|
||
// "BAD_FIELDS",
|
||
// "Некорректное поле: login"
|
||
// );
|
||
// }
|
||
//
|
||
// // Если хочешь жёстче:
|
||
// // if (!req.getLogin().matches("^[A-Za-z0-9_]+$")) ...
|
||
//
|
||
// SubscriptionsDAO dao = SubscriptionsDAO.getInstance();
|
||
// SqliteDbController db = SqliteDbController.getInstance();
|
||
//
|
||
// try (Connection c = db.getConnection()) {
|
||
//
|
||
// List<SubscriptionsDAO.ChannelRow> rows = dao.getSubscribedChannels(c, req.getLogin());
|
||
// List<Net_GetSubscribedChannels_Response.ChannelInfo> out = new ArrayList<>(rows.size());
|
||
//
|
||
// for (SubscriptionsDAO.ChannelRow r : rows) {
|
||
// Net_GetSubscribedChannels_Response.ChannelInfo dto =
|
||
// new Net_GetSubscribedChannels_Response.ChannelInfo();
|
||
//
|
||
// dto.setChannelLogin(r.getChannelLogin());
|
||
// dto.setChannelBchName(r.getChannelBchName());
|
||
// dto.setPublicationsCount(r.getPublicationsCount());
|
||
//
|
||
// byte[] pubBytes = r.getLastPublicationBlockBytes();
|
||
// byte[] editBytes = r.getLastEditBlockBytes();
|
||
//
|
||
// if (pubBytes == null || pubBytes.length == 0) {
|
||
// dto.setLastPublicationTimestampSec(null);
|
||
// dto.setLastTextPreview(null);
|
||
// out.add(dto);
|
||
// continue;
|
||
// }
|
||
//
|
||
// // 1) timestamp берём из ОРИГИНАЛЬНОЙ публикации
|
||
// BchBlockEntry pubBlock = new BchBlockEntry(pubBytes);
|
||
// dto.setLastPublicationTimestampSec(pubBlock.timestamp);
|
||
//
|
||
// // 2) текст — из EDIT (если есть) иначе из оригинала
|
||
// byte[] actualBytes = (editBytes != null && editBytes.length > 0) ? editBytes : pubBytes;
|
||
// BchBlockEntry actualBlock = new BchBlockEntry(actualBytes);
|
||
//
|
||
// if (!(actualBlock.body instanceof TextBody)) {
|
||
// // Это уже нарушение данных: last publication должен быть текстовым блоком.
|
||
// throw new IllegalStateException("Last publication is not TextBody: type="
|
||
// + (actualBlock.body == null ? "null" : (actualBlock.body.type() & 0xFFFF)));
|
||
// }
|
||
//
|
||
// String msg = ((TextBody) actualBlock.body).message;
|
||
// dto.setLastTextPreview(firstNCharsSafe(msg, 50));
|
||
//
|
||
// out.add(dto);
|
||
// }
|
||
//
|
||
// Net_GetSubscribedChannels_Response resp = new Net_GetSubscribedChannels_Response();
|
||
// resp.setOp(req.getOp());
|
||
// resp.setRequestId(req.getRequestId());
|
||
// resp.setStatus(WireCodes.Status.OK);
|
||
// resp.setChannels(out);
|
||
//
|
||
// return resp;
|
||
//
|
||
// } catch (SQLException e) {
|
||
// log.error("❌ DB error GetSubscribedChannels", e);
|
||
// return NetExceptionResponseFactory.error(
|
||
// req,
|
||
// WireCodes.Status.SERVER_DATA_ERROR,
|
||
// "DB_ERROR",
|
||
// "Ошибка БД"
|
||
// );
|
||
// } catch (IllegalArgumentException e) {
|
||
// // сюда попадёт, например, если BchBlockEntry не смог распарсить block_byte
|
||
// log.error("❌ Bad block bytes in DB (cannot parse BchBlockEntry)", e);
|
||
// return NetExceptionResponseFactory.error(
|
||
// req,
|
||
// WireCodes.Status.SERVER_DATA_ERROR,
|
||
// "BAD_BLOCK_BYTES",
|
||
// "В БД обнаружен повреждённый блок"
|
||
// );
|
||
// } catch (Exception e) {
|
||
// log.error("❌ Internal error GetSubscribedChannels", e);
|
||
// return NetExceptionResponseFactory.error(
|
||
// req,
|
||
// WireCodes.Status.INTERNAL_ERROR,
|
||
// "INTERNAL_ERROR",
|
||
// "Внутренняя ошибка сервера"
|
||
// );
|
||
// }
|
||
// }
|
||
//
|
||
// /**
|
||
// * Берём первые N "символов" безопасно для emoji/суррогатных пар:
|
||
// * режем по code points.
|
||
// */
|
||
// private static String firstNCharsSafe(String s, int n) {
|
||
// if (s == null) return null;
|
||
// if (n <= 0) return "";
|
||
// int cp = s.codePointCount(0, s.length());
|
||
// if (cp <= n) return s;
|
||
// int end = s.offsetByCodePoints(0, n);
|
||
// return s.substring(0, end);
|
||
// }
|
||
//}
|
||
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Запрос AddUser — временная/тестовая регистрация локального пользователя.
|
||
*
|
||
* Клиент отправляет:
|
||
*
|
||
* {
|
||
* "op": "AddUser",
|
||
* "requestId": "test-add-1",
|
||
* "payload": {
|
||
* "login": "anya",
|
||
* "blockchainName": "anya-001",
|
||
* "solanaKey": "base64-ed25519-public-key-login",
|
||
* "blockchainKey": "base64-ed25519-public-key-blockchain",
|
||
* "deviceKey": "base64-ed25519-public-key-device",
|
||
* "bchLimit": 1000000
|
||
* }
|
||
* }
|
||
*
|
||
* Все поля лежат внутри payload.
|
||
*/
|
||
public class Net_AddUser_Request extends Net_Request {
|
||
|
||
private String login;
|
||
private String blockchainName;
|
||
|
||
/** Ключ пользователя Solana (публичный ключ логина) */
|
||
private String solanaKey;
|
||
|
||
/** Ключ блокчейна (публичный ключ блокчейна) */
|
||
private String blockchainKey;
|
||
|
||
/** Ключ устройства (публичный ключ устройства) */
|
||
private String deviceKey;
|
||
|
||
private Integer bchLimit;
|
||
|
||
public String getLogin() { return login; }
|
||
public void setLogin(String login) { this.login = login; }
|
||
|
||
public String getBlockchainName() { return blockchainName; }
|
||
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||
|
||
public String getSolanaKey() { return solanaKey; }
|
||
public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; }
|
||
|
||
public String getBlockchainKey() { return blockchainKey; }
|
||
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
|
||
|
||
public String getDeviceKey() { return deviceKey; }
|
||
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; }
|
||
|
||
public Integer getBchLimit() { return bchLimit; }
|
||
public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; }
|
||
}
|
||
// file: server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java
|
||
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
/**
|
||
* Успешный ответ на AddUser.
|
||
*
|
||
* Сейчас дополнительных полей нет — достаточно status=200.
|
||
*
|
||
* Пример:
|
||
* {
|
||
* "op": "AddUser",
|
||
* "requestId": "test-add-1",
|
||
* "status": 200,
|
||
* "payload": { }
|
||
* }
|
||
*/
|
||
public class Net_AddUser_Response extends Net_Response {
|
||
// При необходимости сюда можно добавить, например, флаг created/updated и т.п.
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Запрос GetUser — проверка/получение пользователя по login.
|
||
*
|
||
* Клиент отправляет:
|
||
*
|
||
* {
|
||
* "op": "GetUser",
|
||
* "requestId": "u-1",
|
||
* "payload": {
|
||
* "login": "AnYa"
|
||
* }
|
||
* }
|
||
*
|
||
* Поиск по login выполняется без учёта регистра.
|
||
* В ответе возвращаем login/blockchainName с тем регистром, как в БД.
|
||
*/
|
||
public class Net_GetUser_Request extends Net_Request {
|
||
|
||
private String login;
|
||
|
||
public String getLogin() { return login; }
|
||
public void setLogin(String login) { this.login = login; }
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
/**
|
||
* Ответ GetUser.
|
||
*
|
||
* Всегда status=200.
|
||
*
|
||
* Пример (нет пользователя):
|
||
* {
|
||
* "op": "GetUser",
|
||
* "requestId": "u-1",
|
||
* "status": 200,
|
||
* "payload": { "exists": false }
|
||
* }
|
||
*
|
||
* Пример (есть пользователь):
|
||
* {
|
||
* "op": "GetUser",
|
||
* "requestId": "u-1",
|
||
* "status": 200,
|
||
* "payload": {
|
||
* "exists": true,
|
||
* "login": "Anya",
|
||
* "blockchainName": "anya-001",
|
||
* "solanaKey": "...",
|
||
* "blockchainKey": "...",
|
||
* "deviceKey": "..."
|
||
* }
|
||
* }
|
||
*/
|
||
public class Net_GetUser_Response extends Net_Response {
|
||
|
||
private Boolean exists;
|
||
|
||
private String login;
|
||
private String blockchainName;
|
||
private String solanaKey;
|
||
private String blockchainKey;
|
||
private String deviceKey;
|
||
|
||
public Boolean getExists() { return exists; }
|
||
public void setExists(Boolean exists) { this.exists = exists; }
|
||
|
||
public String getLogin() { return login; }
|
||
public void setLogin(String login) { this.login = login; }
|
||
|
||
public String getBlockchainName() { return blockchainName; }
|
||
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||
|
||
public String getSolanaKey() { return solanaKey; }
|
||
public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; }
|
||
|
||
public String getBlockchainKey() { return blockchainKey; }
|
||
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
|
||
|
||
public String getDeviceKey() { return deviceKey; }
|
||
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; }
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Запрос SearchUsers — поиск логинов по префиксу.
|
||
*
|
||
* Клиент отправляет:
|
||
* {
|
||
* "op": "SearchUsers",
|
||
* "requestId": "su-1",
|
||
* "payload": { "prefix": "any" }
|
||
* }
|
||
*
|
||
* Поиск по prefix выполняется без учёта регистра.
|
||
* В ответе возвращаем логины с тем регистром, как в БД.
|
||
*/
|
||
public class Net_SearchUsers_Request extends Net_Request {
|
||
|
||
private String prefix;
|
||
|
||
public String getPrefix() { return prefix; }
|
||
public void setPrefix(String prefix) { this.prefix = prefix; }
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* Ответ SearchUsers.
|
||
*
|
||
* Всегда status=200.
|
||
*
|
||
* Пример:
|
||
* {
|
||
* "op": "SearchUsers",
|
||
* "requestId": "su-1",
|
||
* "status": 200,
|
||
* "payload": {
|
||
* "logins": ["Anya", "andrew", "Angel"]
|
||
* }
|
||
* }
|
||
*/
|
||
public class Net_SearchUsers_Response extends Net_Response {
|
||
|
||
private List<String> logins = new ArrayList<>();
|
||
|
||
public List<String> getLogins() { return logins; }
|
||
public void setLogins(List<String> logins) { this.logins = logins; }
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.tempToTest;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Response;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import shine.db.SqliteDbController;
|
||
import shine.db.dao.BlockchainStateDAO;
|
||
import shine.db.dao.SolanaUsersDAO;
|
||
import shine.db.entities.BlockchainStateEntry;
|
||
import shine.db.entities.SolanaUserEntry;
|
||
import utils.blockchain.BlockchainNameUtil;
|
||
|
||
import java.sql.Connection;
|
||
import java.sql.SQLException;
|
||
import java.util.Base64;
|
||
|
||
public class Net_AddUser_Handler implements JsonMessageHandler {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(Net_AddUser_Handler.class);
|
||
|
||
/** TEST ONLY */
|
||
private static final int TEST_BCH_LIMIT = 1_000_000;
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||
Net_AddUser_Request req = (Net_AddUser_Request) baseRequest;
|
||
|
||
if (req.getLogin() == null || req.getLogin().isBlank()
|
||
|| req.getBlockchainName() == null || req.getBlockchainName().isBlank()
|
||
|| req.getSolanaKey() == null || req.getSolanaKey().isBlank()
|
||
|| req.getBlockchainKey() == null || req.getBlockchainKey().isBlank()
|
||
|| req.getDeviceKey() == null || req.getDeviceKey().isBlank()) {
|
||
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_FIELDS",
|
||
"Некорректные поля: login/blockchainName/solanaKey/blockchainKey/deviceKey"
|
||
);
|
||
}
|
||
|
||
// blockchainName должен быть вида: <login>-NNN
|
||
if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_BLOCKCHAIN_NAME",
|
||
"blockchainName должен быть вида <login>-NNN (пример: anya-001)"
|
||
);
|
||
}
|
||
|
||
int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0)
|
||
? TEST_BCH_LIMIT
|
||
: req.getBchLimit();
|
||
|
||
try {
|
||
// базовая валидация форматов ключей: Base64(32 bytes)
|
||
byte[] solanaKey32 = Base64.getDecoder().decode(req.getSolanaKey());
|
||
if (solanaKey32.length != 32) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_SOLANA_KEY",
|
||
"solanaKey должен быть Base64(32 bytes)"
|
||
);
|
||
}
|
||
|
||
byte[] blockchainKey32 = Base64.getDecoder().decode(req.getBlockchainKey());
|
||
if (blockchainKey32.length != 32) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_BLOCKCHAIN_KEY",
|
||
"blockchainKey должен быть Base64(32 bytes)"
|
||
);
|
||
}
|
||
|
||
byte[] deviceKey32 = Base64.getDecoder().decode(req.getDeviceKey());
|
||
if (deviceKey32.length != 32) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_DEVICE_KEY",
|
||
"deviceKey должен быть Base64(32 bytes)"
|
||
);
|
||
}
|
||
|
||
SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance();
|
||
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||
|
||
SqliteDbController db = SqliteDbController.getInstance();
|
||
|
||
try (Connection c = db.getConnection()) {
|
||
c.setAutoCommit(false);
|
||
|
||
// 1. Проверяем, что пользователя нет (case-insensitive)
|
||
if (usersDAO.getByLogin(c, req.getLogin()) != null) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
409,
|
||
"USER_ALREADY_EXISTS",
|
||
"Пользователь с таким login уже существует"
|
||
);
|
||
}
|
||
|
||
// 2. Проверяем, что blockchainName ещё нет (case-sensitive, как в БД)
|
||
if (usersDAO.existsByBlockchainName(c, req.getBlockchainName())) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
409,
|
||
"BLOCKCHAIN_ALREADY_EXISTS",
|
||
"Пользователь с таким blockchainName уже существует"
|
||
);
|
||
}
|
||
|
||
// 3. На всякий случай оставляем старую проверку blockchain_state,
|
||
// потому что эта таблица нужна серверу (состояние цепочки/лимиты).
|
||
if (stateDAO.getByBlockchainName(c, req.getBlockchainName()) != null) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
409,
|
||
"BLOCKCHAIN_STATE_ALREADY_EXISTS",
|
||
"blockchain_state уже существует"
|
||
);
|
||
}
|
||
|
||
// 4. Создаём пользователя (все поля теперь лежат в solana_users)
|
||
SolanaUserEntry user = new SolanaUserEntry();
|
||
user.setLogin(req.getLogin());
|
||
user.setBlockchainName(req.getBlockchainName());
|
||
user.setSolanaKey(req.getSolanaKey());
|
||
user.setBlockchainKey(req.getBlockchainKey());
|
||
user.setDeviceKey(req.getDeviceKey());
|
||
|
||
usersDAO.insert(c, user);
|
||
|
||
// 5. Создаём INITIAL blockchain_state (для работы сервера)
|
||
BlockchainStateEntry st = new BlockchainStateEntry();
|
||
st.setBlockchainName(req.getBlockchainName());
|
||
st.setLogin(req.getLogin());
|
||
st.setBlockchainKey(req.getBlockchainKey()); // Base64(32)
|
||
st.setLastBlockNumber(-1);
|
||
st.setLastBlockHash(new byte[32]);
|
||
st.setFileSizeBytes(0);
|
||
st.setSizeLimit(limit);
|
||
st.setUpdatedAtMs(System.currentTimeMillis());
|
||
|
||
stateDAO.upsert(c, st);
|
||
|
||
c.commit();
|
||
}
|
||
|
||
Net_AddUser_Response resp = new Net_AddUser_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
|
||
log.info("✅ AddUser ok: login={}, blockchainName={}, limit={}",
|
||
req.getLogin(), req.getBlockchainName(), limit);
|
||
|
||
return resp;
|
||
|
||
} catch (IllegalArgumentException e) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_KEY_FORMAT",
|
||
e.getMessage()
|
||
);
|
||
} catch (SQLException e) {
|
||
log.error("❌ DB error AddUser", e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"DB_ERROR",
|
||
"Ошибка БД"
|
||
);
|
||
} catch (Exception e) {
|
||
log.error("❌ Internal error AddUser", e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.INTERNAL_ERROR,
|
||
"INTERNAL_ERROR",
|
||
"Внутренняя ошибка сервера"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.tempToTest;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Response;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import shine.db.dao.SolanaUsersDAO;
|
||
import shine.db.entities.SolanaUserEntry;
|
||
|
||
import java.sql.SQLException;
|
||
|
||
public class Net_GetUser_Handler implements JsonMessageHandler {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(Net_GetUser_Handler.class);
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||
Net_GetUser_Request req = (Net_GetUser_Request) baseRequest;
|
||
|
||
if (req.getLogin() == null || req.getLogin().isBlank()) {
|
||
// тут логичнее BAD_REQUEST, но ты просил: "нет пользователя" тоже 200.
|
||
// Поэтому BAD_REQUEST оставляем только на реально пустой login.
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_FIELDS",
|
||
"Некорректные поля: login"
|
||
);
|
||
}
|
||
|
||
SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance();
|
||
|
||
try {
|
||
SolanaUserEntry u = usersDAO.getByLogin(req.getLogin());
|
||
|
||
Net_GetUser_Response resp = new Net_GetUser_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
|
||
if (u == null) {
|
||
resp.setExists(false);
|
||
log.info("ℹ️ GetUser: not found for login={}", req.getLogin());
|
||
return resp;
|
||
}
|
||
|
||
// ВАЖНО:
|
||
// - Поиск по login был case-insensitive,
|
||
// - а тут возвращаем login/blockchainName как в БД (с исходным регистром).
|
||
resp.setExists(true);
|
||
resp.setLogin(u.getLogin());
|
||
resp.setBlockchainName(u.getBlockchainName());
|
||
resp.setSolanaKey(u.getSolanaKey());
|
||
resp.setBlockchainKey(u.getBlockchainKey());
|
||
resp.setDeviceKey(u.getDeviceKey());
|
||
|
||
log.info("✅ GetUser: found login={}, blockchainName={}", u.getLogin(), u.getBlockchainName());
|
||
return resp;
|
||
|
||
} catch (SQLException e) {
|
||
log.error("❌ DB error GetUser", e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"DB_ERROR",
|
||
"Ошибка БД"
|
||
);
|
||
} catch (Exception e) {
|
||
log.error("❌ Internal error GetUser", e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.INTERNAL_ERROR,
|
||
"INTERNAL_ERROR",
|
||
"Внутренняя ошибка сервера"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.tempToTest;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Response;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import shine.db.dao.SolanaUsersDAO;
|
||
import shine.db.entities.SolanaUserEntry;
|
||
|
||
import java.sql.SQLException;
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
|
||
public class Net_SearchUsers_Handler implements JsonMessageHandler {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(Net_SearchUsers_Handler.class);
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||
Net_SearchUsers_Request req = (Net_SearchUsers_Request) baseRequest;
|
||
|
||
if (req.getPrefix() == null || req.getPrefix().isBlank()) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_FIELDS",
|
||
"Некорректные поля: prefix"
|
||
);
|
||
}
|
||
|
||
String prefix = req.getPrefix().trim();
|
||
|
||
try {
|
||
SolanaUsersDAO dao = SolanaUsersDAO.getInstance();
|
||
List<SolanaUserEntry> users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5
|
||
|
||
List<String> logins = new ArrayList<>();
|
||
for (SolanaUserEntry u : users) {
|
||
if (u != null && u.getLogin() != null) {
|
||
logins.add(u.getLogin()); // регистр как в БД
|
||
}
|
||
}
|
||
|
||
Net_SearchUsers_Response resp = new Net_SearchUsers_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
resp.setLogins(logins);
|
||
|
||
log.info("✅ SearchUsers ok: prefix='{}' -> {}", prefix, logins.size());
|
||
return resp;
|
||
|
||
} catch (SQLException e) {
|
||
log.error("❌ DB error SearchUsers", e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"DB_ERROR",
|
||
"Ошибка БД"
|
||
);
|
||
} catch (Exception e) {
|
||
log.error("❌ Internal error SearchUsers", e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.INTERNAL_ERROR,
|
||
"INTERNAL_ERROR",
|
||
"Внутренняя ошибка сервера"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Запрос GetUserParam — получить один параметр пользователя.
|
||
*
|
||
* {
|
||
* "op": "GetUserParam",
|
||
* "requestId": "req-1",
|
||
* "payload": {
|
||
* "login": "anya",
|
||
* "param": "feed:lastSeenGlobal"
|
||
* }
|
||
* }
|
||
*
|
||
* ПРО ДОСТУП (на будущее):
|
||
* ---------------------------------------------------------------------------------
|
||
* Сейчас (MVP) этот запрос не ограничивает просмотр параметров, т.к. проект в тестовом режиме.
|
||
* Позже, вероятно, потребуется ограничить: кто и какие параметры может читать (сессия/права).
|
||
* Но для MVP эти проверки не нужны.
|
||
* ---------------------------------------------------------------------------------
|
||
*/
|
||
public class Net_GetUserParam_Request extends Net_Request {
|
||
|
||
private String login;
|
||
private String param;
|
||
|
||
public String getLogin() { return login; }
|
||
public void setLogin(String login) { this.login = login; }
|
||
|
||
public String getParam() { return param; }
|
||
public void setParam(String param) { this.param = param; }
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
/**
|
||
* Ответ GetUserParam.
|
||
*
|
||
* Если найден:
|
||
* {
|
||
* "op": "GetUserParam",
|
||
* "requestId": "req-1",
|
||
* "status": 200,
|
||
* "payload": {
|
||
* "login": "anya",
|
||
* "param": "feed:lastSeenGlobal",
|
||
* "time_ms": 1736000000123,
|
||
* "value": "105",
|
||
* "device_key": "base64-32",
|
||
* "signature": "base64-64"
|
||
* }
|
||
* }
|
||
*
|
||
* Если не найден:
|
||
* status=404, payload пустой.
|
||
*/
|
||
public class Net_GetUserParam_Response extends Net_Response {
|
||
|
||
private String login;
|
||
private String param;
|
||
private Long time_ms;
|
||
private String value;
|
||
private String device_key;
|
||
private String signature;
|
||
|
||
public String getLogin() { return login; }
|
||
public void setLogin(String login) { this.login = login; }
|
||
|
||
public String getParam() { return param; }
|
||
public void setParam(String param) { this.param = param; }
|
||
|
||
public Long getTime_ms() { return time_ms; }
|
||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||
|
||
public String getValue() { return value; }
|
||
public void setValue(String value) { this.value = value; }
|
||
|
||
public String getDevice_key() { return device_key; }
|
||
public void setDevice_key(String device_key) { this.device_key = device_key; }
|
||
|
||
public String getSignature() { return signature; }
|
||
public void setSignature(String signature) { this.signature = signature; }
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Запрос ListUserParams — получить все сохранённые параметры пользователя.
|
||
*
|
||
* {
|
||
* "op": "ListUserParams",
|
||
* "requestId": "req-2",
|
||
* "payload": {
|
||
* "login": "anya"
|
||
* }
|
||
* }
|
||
*
|
||
* ПРО ДОСТУП (на будущее):
|
||
* ---------------------------------------------------------------------------------
|
||
* Сейчас (MVP) запрос не ограничивает просмотр параметров.
|
||
* В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры.
|
||
* Для MVP эти проверки не нужны.
|
||
* ---------------------------------------------------------------------------------
|
||
*/
|
||
public class Net_ListUserParams_Request extends Net_Request {
|
||
|
||
private String login;
|
||
|
||
public String getLogin() { return login; }
|
||
public void setLogin(String login) { this.login = login; }
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* Ответ ListUserParams — список всех параметров пользователя.
|
||
*
|
||
* {
|
||
* "op": "ListUserParams",
|
||
* "requestId": "req-2",
|
||
* "status": 200,
|
||
* "payload": {
|
||
* "login": "anya",
|
||
* "params": [
|
||
* {
|
||
* "login": "anya",
|
||
* "param": "feed:lastSeenGlobal",
|
||
* "time_ms": 1736000000123,
|
||
* "value": "105",
|
||
* "device_key": "base64-32",
|
||
* "signature": "base64-64"
|
||
* },
|
||
* ...
|
||
* ]
|
||
* }
|
||
* }
|
||
*/
|
||
public class Net_ListUserParams_Response extends Net_Response {
|
||
|
||
private String login;
|
||
private List<Item> params = new ArrayList<>();
|
||
|
||
public String getLogin() { return login; }
|
||
public void setLogin(String login) { this.login = login; }
|
||
|
||
public List<Item> getParams() { return params; }
|
||
public void setParams(List<Item> params) { this.params = params; }
|
||
|
||
public static class Item {
|
||
private String login;
|
||
private String param;
|
||
private Long time_ms;
|
||
private String value;
|
||
private String device_key;
|
||
private String signature;
|
||
|
||
public String getLogin() { return login; }
|
||
public void setLogin(String login) { this.login = login; }
|
||
|
||
public String getParam() { return param; }
|
||
public void setParam(String param) { this.param = param; }
|
||
|
||
public Long getTime_ms() { return time_ms; }
|
||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||
|
||
public String getValue() { return value; }
|
||
public void setValue(String value) { this.value = value; }
|
||
|
||
public String getDevice_key() { return device_key; }
|
||
public void setDevice_key(String device_key) { this.device_key = device_key; }
|
||
|
||
public String getSignature() { return signature; }
|
||
public void setSignature(String signature) { this.signature = signature; }
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Запрос UpsertUserParam — добавить/обновить сохранённый параметр пользователя.
|
||
*
|
||
* Клиент отправляет:
|
||
*
|
||
* {
|
||
* "op": "UpsertUserParam",
|
||
* "requestId": "req-123",
|
||
* "payload": {
|
||
* "login": "anya",
|
||
* "param": "feed:lastSeenGlobal",
|
||
* "time_ms": 1736000000123,
|
||
* "value": "105",
|
||
* "device_key": "base64-ed25519-public-key-32",
|
||
* "signature": "base64-ed25519-signature-64"
|
||
* }
|
||
* }
|
||
*
|
||
* Подпись считается от UTF-8 строки:
|
||
* USER_PARAMETER_PREFIX + login + param + time_ms + value
|
||
*/
|
||
public class Net_UpsertUserParam_Request extends Net_Request {
|
||
|
||
private String login;
|
||
private String param;
|
||
private Long time_ms;
|
||
private String value;
|
||
|
||
private String device_key;
|
||
private String signature;
|
||
|
||
public String getLogin() { return login; }
|
||
public void setLogin(String login) { this.login = login; }
|
||
|
||
public String getParam() { return param; }
|
||
public void setParam(String param) { this.param = param; }
|
||
|
||
public Long getTime_ms() { return time_ms; }
|
||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||
|
||
public String getValue() { return value; }
|
||
public void setValue(String value) { this.value = value; }
|
||
|
||
public String getDevice_key() { return device_key; }
|
||
public void setDevice_key(String device_key) { this.device_key = device_key; }
|
||
|
||
public String getSignature() { return signature; }
|
||
public void setSignature(String signature) { this.signature = signature; }
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
|
||
/**
|
||
* Ответ на UpsertUserParam.
|
||
*
|
||
* Успех:
|
||
* {
|
||
* "op": "UpsertUserParam",
|
||
* "requestId": "req-123",
|
||
* "status": 200,
|
||
* "payload": { }
|
||
* }
|
||
*/
|
||
public class Net_UpsertUserParam_Response extends Net_Response {
|
||
// MVP: без payload. При желании позже можно добавить created/updated.
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.userParams;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Response;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import shine.db.SqliteDbController;
|
||
import shine.db.dao.UserParamsDAO;
|
||
import shine.db.entities.UserParamEntry;
|
||
|
||
import java.sql.Connection;
|
||
|
||
/**
|
||
* GetUserParam — получить один параметр пользователя.
|
||
*
|
||
* ПРО ДОСТУП (на будущее):
|
||
* ---------------------------------------------------------------------------------
|
||
* Сейчас (MVP) запрос не ограничивает просмотр параметров.
|
||
* В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры.
|
||
* Для MVP эти проверки не нужны.
|
||
* ---------------------------------------------------------------------------------
|
||
*/
|
||
public class Net_GetUserParam_Handler implements JsonMessageHandler {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(Net_GetUserParam_Handler.class);
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||
Net_GetUserParam_Request req = (Net_GetUserParam_Request) baseRequest;
|
||
|
||
if (req.getLogin() == null || req.getLogin().isBlank()
|
||
|| req.getParam() == null || req.getParam().isBlank()) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_FIELDS",
|
||
"Некорректные поля: login/param"
|
||
);
|
||
}
|
||
|
||
String login = req.getLogin().trim();
|
||
String param = req.getParam().trim();
|
||
|
||
try {
|
||
SqliteDbController db = SqliteDbController.getInstance();
|
||
UserParamsDAO dao = UserParamsDAO.getInstance();
|
||
|
||
try (Connection c = db.getConnection()) {
|
||
UserParamEntry e = dao.getByLoginAndParam(c, login, param);
|
||
|
||
if (e == null) {
|
||
Net_GetUserParam_Response resp = new Net_GetUserParam_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(404);
|
||
return resp;
|
||
}
|
||
|
||
Net_GetUserParam_Response resp = new Net_GetUserParam_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
|
||
resp.setLogin(e.getLogin());
|
||
resp.setParam(e.getParam());
|
||
resp.setTime_ms(e.getTimeMs());
|
||
resp.setValue(e.getValue());
|
||
resp.setDevice_key(e.getDeviceKey());
|
||
resp.setSignature(e.getSignature());
|
||
|
||
return resp;
|
||
}
|
||
|
||
} catch (Exception e) {
|
||
log.error("❌ Internal error GetUserParam", e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.INTERNAL_ERROR,
|
||
"INTERNAL_ERROR",
|
||
"Внутренняя ошибка сервера"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.userParams;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Response;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import shine.db.SqliteDbController;
|
||
import shine.db.dao.UserParamsDAO;
|
||
import shine.db.entities.UserParamEntry;
|
||
|
||
import java.sql.Connection;
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* ListUserParams — получить все параметры пользователя.
|
||
*
|
||
* ПРО ДОСТУП (на будущее):
|
||
* ---------------------------------------------------------------------------------
|
||
* Сейчас (MVP) запрос не ограничивает просмотр параметров.
|
||
* В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры.
|
||
* Для MVP эти проверки не нужны.
|
||
* ---------------------------------------------------------------------------------
|
||
*/
|
||
public class Net_ListUserParams_Handler implements JsonMessageHandler {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(Net_ListUserParams_Handler.class);
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||
Net_ListUserParams_Request req = (Net_ListUserParams_Request) baseRequest;
|
||
|
||
if (req.getLogin() == null || req.getLogin().isBlank()) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_FIELDS",
|
||
"Некорректные поля: login"
|
||
);
|
||
}
|
||
|
||
String login = req.getLogin().trim();
|
||
|
||
try {
|
||
SqliteDbController db = SqliteDbController.getInstance();
|
||
UserParamsDAO dao = UserParamsDAO.getInstance();
|
||
|
||
List<UserParamEntry> entries;
|
||
try (Connection c = db.getConnection()) {
|
||
entries = dao.getByLogin(c, login);
|
||
}
|
||
|
||
Net_ListUserParams_Response resp = new Net_ListUserParams_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
|
||
resp.setLogin(login);
|
||
|
||
List<Net_ListUserParams_Response.Item> items = new ArrayList<>();
|
||
for (UserParamEntry e : entries) {
|
||
Net_ListUserParams_Response.Item it = new Net_ListUserParams_Response.Item();
|
||
it.setLogin(e.getLogin());
|
||
it.setParam(e.getParam());
|
||
it.setTime_ms(e.getTimeMs());
|
||
it.setValue(e.getValue());
|
||
it.setDevice_key(e.getDeviceKey());
|
||
it.setSignature(e.getSignature());
|
||
items.add(it);
|
||
}
|
||
resp.setParams(items);
|
||
|
||
return resp;
|
||
|
||
} catch (Exception e) {
|
||
log.error("❌ Internal error ListUserParams", e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.INTERNAL_ERROR,
|
||
"INTERNAL_ERROR",
|
||
"Внутренняя ошибка сервера"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.handlers.userParams;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Response;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
import shine.db.SqliteDbController;
|
||
import shine.db.dao.SolanaUsersDAO;
|
||
import shine.db.dao.UserParamsDAO;
|
||
import shine.db.entities.SolanaUserEntry;
|
||
import shine.db.entities.UserParamEntry;
|
||
import utils.config.ShineSignatureConstants;
|
||
import utils.crypto.Ed25519Util;
|
||
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.sql.Connection;
|
||
import java.sql.SQLException;
|
||
import java.util.Base64;
|
||
|
||
/**
|
||
* Net_UpsertUserParam_Handler
|
||
*
|
||
* Делает (MVP, без "сессий"):
|
||
* 1) Проверка входных полей.
|
||
* 2) Проверка подписи Ed25519 по device_key.
|
||
* 3) Проверка, что пользователь существует и что device_key принадлежит этому login.
|
||
* 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE).
|
||
*
|
||
* ВАЖНО:
|
||
* - НИКАКИХ ручных транзакций / BEGIN здесь нет.
|
||
* - autoCommit=true, каждый statement завершённый сам по себе.
|
||
* - Гонки не страшны: если за время проверок кто-то записал более новый time_ms,
|
||
* наш финальный UPSERT просто вернёт 0 обновлённых строк.
|
||
*/
|
||
public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserParam_Handler.class);
|
||
|
||
@Override
|
||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||
Net_UpsertUserParam_Request req = (Net_UpsertUserParam_Request) baseRequest;
|
||
|
||
if (req.getLogin() == null || req.getLogin().isBlank()
|
||
|| req.getParam() == null || req.getParam().isBlank()
|
||
|| req.getTime_ms() == null || req.getTime_ms() <= 0
|
||
|| req.getValue() == null
|
||
|| req.getDevice_key() == null || req.getDevice_key().isBlank()
|
||
|| req.getSignature() == null || req.getSignature().isBlank()) {
|
||
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_FIELDS",
|
||
"Некорректные поля: login/param/time_ms/value/device_key/signature"
|
||
);
|
||
}
|
||
|
||
final String login = req.getLogin().trim();
|
||
final String param = req.getParam().trim();
|
||
final long timeMs = req.getTime_ms();
|
||
final String value = req.getValue();
|
||
final String deviceKeyB64 = req.getDevice_key().trim();
|
||
final String signatureB64 = req.getSignature().trim();
|
||
|
||
try {
|
||
// ---------------- Base64 decode ----------------
|
||
byte[] pubKey32;
|
||
byte[] sig64;
|
||
try {
|
||
pubKey32 = Base64.getDecoder().decode(deviceKeyB64);
|
||
sig64 = Base64.getDecoder().decode(signatureB64);
|
||
} catch (IllegalArgumentException e) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_BASE64",
|
||
"device_key/signature должны быть Base64"
|
||
);
|
||
}
|
||
|
||
if (pubKey32.length != 32) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_DEVICE_KEY",
|
||
"device_key должен быть Base64(32 bytes)"
|
||
);
|
||
}
|
||
if (sig64.length != 64) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_SIGNATURE",
|
||
"signature должна быть Base64(64 bytes)"
|
||
);
|
||
}
|
||
|
||
// ---------------- Signature verify ----------------
|
||
String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX
|
||
+ login
|
||
+ param
|
||
+ timeMs
|
||
+ value;
|
||
|
||
byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8);
|
||
|
||
boolean sigOk = Ed25519Util.verify(signBytes, sig64, pubKey32);
|
||
if (!sigOk) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
403,
|
||
"SIGNATURE_INVALID",
|
||
"Подпись не прошла проверку"
|
||
);
|
||
}
|
||
|
||
// ---------------- DB checks + upsert ----------------
|
||
SqliteDbController db = SqliteDbController.getInstance();
|
||
SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance();
|
||
UserParamsDAO paramsDAO = UserParamsDAO.getInstance();
|
||
|
||
try (Connection c = db.getConnection()) {
|
||
// 1) user exists
|
||
SolanaUserEntry user = usersDAO.getByLogin(c, login);
|
||
if (user == null) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
404,
|
||
"USER_NOT_FOUND",
|
||
"Пользователь не найден"
|
||
);
|
||
}
|
||
|
||
// 2) device key must match the user's stored deviceKey
|
||
String userDeviceKey = user.getDeviceKey();
|
||
if (userDeviceKey == null || userDeviceKey.isBlank()) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"USER_DEVICE_KEY_EMPTY",
|
||
"У пользователя не задан deviceKey в БД"
|
||
);
|
||
}
|
||
|
||
if (!userDeviceKey.trim().equals(deviceKeyB64)) {
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
403,
|
||
"DEVICE_KEY_MISMATCH",
|
||
"device_key не соответствует пользователю"
|
||
);
|
||
}
|
||
|
||
// 3) atomic upsert-if-newer
|
||
UserParamEntry e = new UserParamEntry(
|
||
login,
|
||
param,
|
||
timeMs,
|
||
value,
|
||
deviceKeyB64,
|
||
signatureB64
|
||
);
|
||
|
||
int changed = paramsDAO.upsertIfNewer(c, e);
|
||
|
||
Net_UpsertUserParam_Response resp = new Net_UpsertUserParam_Response();
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
resp.setStatus(WireCodes.Status.OK);
|
||
|
||
if (changed == 1) {
|
||
log.info("✅ UpsertUserParam applied: login={}, param={}, time_ms={}", login, param, timeMs);
|
||
} else {
|
||
// 0 строк — значит в БД уже есть time_ms >= incoming
|
||
log.info("ℹ️ UpsertUserParam ignored (not newer): login={}, param={}, time_ms={}", login, param, timeMs);
|
||
}
|
||
|
||
return resp;
|
||
}
|
||
|
||
} catch (SQLException e) {
|
||
log.error("❌ DB error UpsertUserParam", e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.SERVER_DATA_ERROR,
|
||
"DB_ERROR",
|
||
"Ошибка БД"
|
||
);
|
||
} catch (Exception e) {
|
||
log.error("❌ Internal error UpsertUserParam", e);
|
||
return NetExceptionResponseFactory.error(
|
||
req,
|
||
WireCodes.Status.INTERNAL_ERROR,
|
||
"INTERNAL_ERROR",
|
||
"Внутренняя ошибка сервера"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
|
||
import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler;
|
||
|
||
// --- NEW v2 session login ---
|
||
import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionChallenge_Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionLogin_Handler;
|
||
|
||
// --- auth entities ---
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
|
||
|
||
// --- NEW v2 entities ---
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
|
||
|
||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
|
||
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||
|
||
// --- NEW: SearchUsers ---
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Request;
|
||
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.Net_GetUserParam_Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.Net_ListUserParams_Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request;
|
||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
|
||
|
||
// !!! подставь реальные пакеты/имена, как у тебя в проекте:
|
||
//import server.logic.ws_protocol.JSON.handlers.subscriptions.Net_GetSubscribedChannels_Handler;
|
||
import server.logic.ws_protocol.JSON.handlers.subscriptions.entyties.Net_GetSubscribedChannels_Request;
|
||
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* JsonHandlerRegistry — единое место, где руками регистрируются
|
||
* JSON-операции: op → handler и op → requestClass.
|
||
*/
|
||
public final class JsonHandlerRegistry {
|
||
|
||
// Map.of(...) поддерживает максимум 10 пар => используем Map.ofEntries(...)
|
||
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
|
||
Map.entry("AddUser", new Net_AddUser_Handler()),
|
||
Map.entry("GetUser", new Net_GetUser_Handler()),
|
||
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
|
||
|
||
// --- auth ---
|
||
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
|
||
Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()),
|
||
Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()),
|
||
Map.entry("ListSessions", new Net_ListSessions_Handler()),
|
||
|
||
// --- login to existing session in 2 steps ---
|
||
Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()),
|
||
Map.entry("SessionLogin", new Net_SessionLogin_Handler()),
|
||
|
||
// --- blockchain ---
|
||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||
|
||
// --- userParams ---
|
||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||
Map.entry("GetUserParam", new Net_GetUserParam_Handler()),
|
||
Map.entry("ListUserParams", new Net_ListUserParams_Handler())
|
||
|
||
// --- subscriptions ---
|
||
// Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler())
|
||
);
|
||
|
||
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(
|
||
Map.entry("AddUser", Net_AddUser_Request.class),
|
||
Map.entry("GetUser", Net_GetUser_Request.class),
|
||
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
|
||
|
||
// --- auth ---
|
||
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
|
||
Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class),
|
||
Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class),
|
||
Map.entry("ListSessions", Net_ListSessions_Request.class),
|
||
|
||
// --- NEW v2 ---
|
||
Map.entry("SessionChallenge", Net_SessionChallenge_Request.class),
|
||
Map.entry("SessionLogin", Net_SessionLogin_Request.class),
|
||
|
||
// --- blockchain ---
|
||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||
|
||
// --- userParams ---
|
||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
||
Map.entry("GetUserParam", Net_GetUserParam_Request.class),
|
||
Map.entry("ListUserParams", Net_ListUserParams_Request.class),
|
||
|
||
// --- subscriptions ---
|
||
Map.entry("ListSubscribedChannels", Net_GetSubscribedChannels_Request.class)
|
||
);
|
||
|
||
private JsonHandlerRegistry() { }
|
||
|
||
public static Map<String, JsonMessageHandler> getHandlers() {
|
||
return HANDLERS;
|
||
}
|
||
|
||
public static Map<String, Class<? extends Net_Request>> getRequestTypes() {
|
||
return REQUEST_TYPES;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON;
|
||
|
||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||
import com.fasterxml.jackson.databind.JsonNode;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||
import server.logic.ws_protocol.WireCodes;
|
||
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* JsonInboundProcessor — обработка JSON-сообщений.
|
||
*
|
||
* 1) Парсит общий пакет (op, requestId, payload).
|
||
* 2) По op выбирает класс запроса и хэндлер.
|
||
* 3) Собирает "плоский" объект: op + requestId + поля из payload.
|
||
* 4) Маппит его в NetRequest через ObjectMapper.
|
||
* 5) Вызывает хэндлер, получает NetResponse.
|
||
* 6) Собирает JSON-ответ:
|
||
* {
|
||
* "op": ...,
|
||
* "requestId": ...,
|
||
* "status": ...,
|
||
* "payload": { все поля response, кроме op/requestId/status/payload }
|
||
* }
|
||
*/
|
||
public final class JsonInboundProcessor {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(JsonInboundProcessor.class);
|
||
|
||
private static final ObjectMapper JSON_MAPPER = new ObjectMapper()
|
||
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||
|
||
private static final Map<String, JsonMessageHandler> JSON_HANDLERS =
|
||
JsonHandlerRegistry.getHandlers();
|
||
|
||
private static final Map<String, Class<? extends Net_Request>> JSON_REQUEST_TYPES =
|
||
JsonHandlerRegistry.getRequestTypes();
|
||
|
||
private JsonInboundProcessor() {
|
||
// utility
|
||
}
|
||
|
||
public static String processJson(String json, ConnectionContext ctx) {
|
||
String op = null;
|
||
String requestId = null;
|
||
|
||
// Для лога полезно знать, кто прислал (хотя бы login/sessionId, если есть)
|
||
String ctxLogin = safe(ctx != null ? ctx.getLogin() : null);
|
||
String ctxSessionId = safe(ctx != null ? ctx.getSessionId() : null);
|
||
|
||
try {
|
||
if (json == null || json.isBlank()) {
|
||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||
null,
|
||
null,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"EMPTY_JSON",
|
||
"Пустое JSON-сообщение"
|
||
);
|
||
|
||
String out = writeResponse(err);
|
||
|
||
// DEBUG: что пришло / что ушло
|
||
if (log.isDebugEnabled()) {
|
||
log.debug("JSON IN (login={}, sessionId={}): <empty>", ctxLogin, ctxSessionId);
|
||
log.debug("JSON OUT (login={}, sessionId={}): {}", ctxLogin, ctxSessionId, shorten(out, 1200));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// DEBUG: сырой вход (обрезаем, чтобы не убить лог)
|
||
if (log.isDebugEnabled()) {
|
||
log.debug("JSON IN (login={}, sessionId={}): {}", ctxLogin, ctxSessionId, shorten(json, 1200));
|
||
}
|
||
|
||
// 1) Парсим общий пакет
|
||
JsonNode root = JSON_MAPPER.readTree(json);
|
||
|
||
// 2) op и requestId из корня
|
||
op = getTextOrNull(root, "op");
|
||
requestId = getTextOrNull(root, "requestId");
|
||
|
||
if (op == null || op.isEmpty()) {
|
||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||
null,
|
||
requestId,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"NO_OP",
|
||
"Поле 'op' отсутствует или пустое"
|
||
);
|
||
|
||
String out = writeResponse(err);
|
||
if (log.isDebugEnabled()) {
|
||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
JsonMessageHandler handler = JSON_HANDLERS.get(op);
|
||
Class<? extends Net_Request> reqClass = JSON_REQUEST_TYPES.get(op);
|
||
|
||
if (handler == null || reqClass == null) {
|
||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||
op,
|
||
requestId,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"UNKNOWN_OP",
|
||
"Неизвестная операция: " + op
|
||
);
|
||
|
||
String out = writeResponse(err);
|
||
if (log.isDebugEnabled()) {
|
||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// 3) Берём payload
|
||
JsonNode payloadNode = root.get("payload");
|
||
if (payloadNode == null || payloadNode.isNull()) {
|
||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||
op,
|
||
requestId,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"NO_PAYLOAD",
|
||
"Поле 'payload' отсутствует"
|
||
);
|
||
|
||
String out = writeResponse(err);
|
||
if (log.isDebugEnabled()) {
|
||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||
}
|
||
return out;
|
||
}
|
||
if (!payloadNode.isObject()) {
|
||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||
op,
|
||
requestId,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_PAYLOAD",
|
||
"Поле 'payload' должно быть объектом"
|
||
);
|
||
|
||
String out = writeResponse(err);
|
||
if (log.isDebugEnabled()) {
|
||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// 3.1 Собираем "плоский" объект для маппинга в NetRequest:
|
||
// op + requestId + поля из payload
|
||
ObjectNode merged = JSON_MAPPER.createObjectNode();
|
||
|
||
// Добавляем op и requestId, чтобы они попали в NetRequest
|
||
merged.put("op", op);
|
||
if (requestId != null) merged.put("requestId", requestId);
|
||
|
||
// Добавляем все поля из payload внутрь
|
||
merged.setAll((ObjectNode) payloadNode);
|
||
|
||
// 4) Маппим в конкретный класс NetRequest
|
||
Net_Request request;
|
||
try {
|
||
request = JSON_MAPPER.treeToValue(merged, reqClass);
|
||
} catch (Exception mapErr) {
|
||
// Важно: вот это часто “теряется”, если не логировать отдельно
|
||
log.error("❌ JSON map error (op={}, requestId={}, login={}, sessionId={}): merged={}",
|
||
op, safe(requestId), ctxLogin, ctxSessionId, shorten(merged.toString(), 1200), mapErr);
|
||
|
||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||
op,
|
||
requestId,
|
||
WireCodes.Status.BAD_REQUEST,
|
||
"BAD_REQUEST_FORMAT",
|
||
"Некорректный формат запроса: не удалось распарсить поля payload"
|
||
);
|
||
|
||
String out = writeResponse(err);
|
||
if (log.isDebugEnabled()) {
|
||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// DEBUG: нормализованный запрос (уже распарсен)
|
||
if (log.isDebugEnabled()) {
|
||
log.debug("REQ OBJ (login={}, sessionId={}, op={}, requestId={}): {}",
|
||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(safeToString(request), 1200));
|
||
}
|
||
|
||
// 5) Вызываем хэндлер
|
||
Net_Response response;
|
||
try {
|
||
response = handler.handle(request, ctx);
|
||
} catch (Exception handlerError) {
|
||
// ✅ Вот тут как раз и должны “появляться ошибки в логере”
|
||
log.error("💥 Handler error (op={}, requestId={}, login={}, sessionId={})",
|
||
op, safe(requestId), ctxLogin, ctxSessionId, handlerError);
|
||
|
||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||
op,
|
||
requestId,
|
||
WireCodes.Status.INTERNAL_ERROR,
|
||
"INTERNAL_HANDLER_ERROR",
|
||
"Неожиданная ошибка при обработке операции: " + op
|
||
);
|
||
|
||
String out = writeResponse(err);
|
||
if (log.isDebugEnabled()) {
|
||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// На всякий случай: если хэндлер не выставил op/requestId
|
||
if (response.getOp() == null) response.setOp(op);
|
||
if (response.getRequestId() == null) response.setRequestId(requestId);
|
||
|
||
// 6) Универсальная сборка ответа
|
||
String out = writeResponse(response);
|
||
|
||
// DEBUG: ответ ушёл
|
||
if (log.isDebugEnabled()) {
|
||
log.debug("RESP OBJ (login={}, sessionId={}, op={}, requestId={}, status={}): {}",
|
||
ctxLogin, ctxSessionId, safe(op), safe(requestId), response.getStatus(), shorten(safeToString(response), 1200));
|
||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}, status={}): {}",
|
||
ctxLogin, ctxSessionId, safe(op), safe(requestId), response.getStatus(), shorten(out, 1200));
|
||
}
|
||
|
||
return out;
|
||
|
||
} catch (Exception e) {
|
||
// ✅ Любая неожиданная ошибка парсинга/обработки — в лог
|
||
log.error("❌ JSON processing error (op={}, requestId={}, login={}, sessionId={})",
|
||
safe(op), safe(requestId), safe(ctxLogin), safe(ctxSessionId), e);
|
||
|
||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||
op != null ? op : "Unknown",
|
||
requestId,
|
||
WireCodes.Status.INTERNAL_ERROR,
|
||
"INTERNAL_ERROR",
|
||
"Внутренняя ошибка сервера"
|
||
);
|
||
|
||
String out = writeResponse(err);
|
||
|
||
if (log.isDebugEnabled()) {
|
||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||
}
|
||
|
||
return out;
|
||
}
|
||
}
|
||
|
||
// --- helpers ---
|
||
|
||
private static String getTextOrNull(JsonNode node, String field) {
|
||
if (node == null || !node.has(field) || node.get(field).isNull()) return null;
|
||
return node.get(field).asText();
|
||
}
|
||
|
||
/**
|
||
* Унифицированная сериализация любого NetResponse в формат:
|
||
* {
|
||
* "op": ...,
|
||
* "requestId": ...,
|
||
* "status": ...,
|
||
* "payload": { ... }
|
||
* }
|
||
*/
|
||
private static String writeResponse(Net_Response response) {
|
||
try {
|
||
// Конвертируем полный объект ответа в ObjectNode
|
||
ObjectNode full = JSON_MAPPER.convertValue(response, ObjectNode.class);
|
||
|
||
// То, что должно остаться наверху:
|
||
String op = full.hasNonNull("op") ? full.get("op").asText() : null;
|
||
String requestId = full.hasNonNull("requestId") ? full.get("requestId").asText() : null;
|
||
int status = full.hasNonNull("status") ? full.get("status").asInt() : 0;
|
||
|
||
// Удаляем базовые поля и payload из "полного" объекта,
|
||
// всё остальное отправляем внутрь payload.
|
||
full.remove("op");
|
||
full.remove("requestId");
|
||
full.remove("status");
|
||
full.remove("payload");
|
||
|
||
ObjectNode root = JSON_MAPPER.createObjectNode();
|
||
if (op != null) root.put("op", op); else root.putNull("op");
|
||
if (requestId != null) root.put("requestId", requestId); else root.putNull("requestId");
|
||
root.put("status", status);
|
||
|
||
// payload — это всё, что осталось от full (может быть пустым объектом {})
|
||
root.set("payload", full);
|
||
|
||
return JSON_MAPPER.writeValueAsString(root);
|
||
|
||
} catch (Exception e) {
|
||
// Совсем аварийный случай — сериализация ответа сломалась.
|
||
log.error("❌ Response serialization error (op={}, requestId={})",
|
||
safe(response != null ? response.getOp() : null),
|
||
safe(response != null ? response.getRequestId() : null),
|
||
e);
|
||
|
||
return "{\"op\":\"" + safe(response != null ? response.getOp() : null) +
|
||
"\",\"requestId\":\"" + safe(response != null ? response.getRequestId() : null) +
|
||
"\",\"status\":" + (response != null ? response.getStatus() : 500) +
|
||
",\"payload\":{\"code\":\"SERIALIZATION_ERROR\",\"message\":\"Ошибка сериализации ответа\"}}";
|
||
}
|
||
}
|
||
|
||
private static String safe(String s) {
|
||
return s != null ? s : "";
|
||
}
|
||
|
||
private static String shorten(String s, int max) {
|
||
if (s == null) return "";
|
||
if (s.length() <= max) return s;
|
||
return s.substring(0, Math.max(0, max)) + "...(+" + (s.length() - max) + " chars)";
|
||
}
|
||
|
||
private static String safeToString(Object o) {
|
||
if (o == null) return "null";
|
||
try {
|
||
// Чтобы не плодить огромные логи и не утыкаться в циклические ссылки —
|
||
// логируем как JSON, если возможно.
|
||
return JSON_MAPPER.writeValueAsString(o);
|
||
} catch (Exception ignore) {
|
||
return String.valueOf(o);
|
||
}
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.utils;
|
||
|
||
import shine.db.entities.SolanaUserEntry;
|
||
import utils.crypto.Ed25519Util;
|
||
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.util.Base64;
|
||
|
||
public final class AuthSignatures {
|
||
|
||
private AuthSignatures() {}
|
||
|
||
/** preimage для CreateAuthSession(v2): "AUTH_CREATE_SESSION:login:timeMs:authNonce" */
|
||
public static byte[] preimageCreateAuthSession(String login, long timeMs, String authNonce) {
|
||
String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce;
|
||
return preimageStr.getBytes(StandardCharsets.UTF_8);
|
||
}
|
||
|
||
/** Декод base64 / base64url (если надо — подстрой под твой decodeBase64Any) */
|
||
public static byte[] decodeBase64Any(String s) throws IllegalArgumentException {
|
||
if (s == null) throw new IllegalArgumentException("base64 is null");
|
||
String x = s.trim();
|
||
if (x.isEmpty()) throw new IllegalArgumentException("base64 is empty");
|
||
|
||
try {
|
||
return Base64.getDecoder().decode(x);
|
||
} catch (IllegalArgumentException e1) {
|
||
// пробуем base64url без паддинга
|
||
return Base64.getUrlDecoder().decode(x);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Проверка подписи CreateAuthSession(v2) по deviceKey пользователя.
|
||
* Подпись проверяется над preimageCreateAuthSession(...).
|
||
*/
|
||
public static boolean verifyCreateAuthSessionSignature(
|
||
SolanaUserEntry user,
|
||
String login,
|
||
String authNonce,
|
||
long timeMs,
|
||
String signatureB64
|
||
) throws IllegalArgumentException {
|
||
|
||
// user.getDeviceKey() — base64 публичного ключа (32 байта)
|
||
byte[] publicKey32 = decodeBase64Any(user.getDeviceKey());
|
||
byte[] signature64 = decodeBase64Any(signatureB64);
|
||
|
||
byte[] preimage = preimageCreateAuthSession(login, timeMs, authNonce);
|
||
return Ed25519Util.verify(preimage, signature64, publicKey32);
|
||
}
|
||
}
|
||
package server.logic.ws_protocol.JSON.utils;
|
||
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response;
|
||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||
|
||
/**
|
||
* Фабрика ошибок для JSON-протокола.
|
||
* Создаёт единообразные NetExceptionResponse.
|
||
*/
|
||
public final class NetExceptionResponseFactory {
|
||
|
||
private NetExceptionResponseFactory() {
|
||
// запрет на создание объектов
|
||
}
|
||
|
||
public static Net_Exception_Response error(Net_Request req,
|
||
int status,
|
||
String code,
|
||
String message) {
|
||
|
||
Net_Exception_Response resp = new Net_Exception_Response();
|
||
|
||
// ✅ НЕ падаем, даже если req == null
|
||
if (req != null) {
|
||
resp.setOp(req.getOp());
|
||
resp.setRequestId(req.getRequestId());
|
||
} else {
|
||
resp.setOp(null);
|
||
resp.setRequestId(null);
|
||
}
|
||
|
||
resp.setStatus(status);
|
||
resp.setCode(code);
|
||
resp.setMessage(message);
|
||
return resp;
|
||
}
|
||
|
||
/**
|
||
* Вариант для случаев, когда NetRequest ещё не распарсен,
|
||
* но мы уже знаем op и requestId (или они null).
|
||
*/
|
||
public static Net_Exception_Response error(String op,
|
||
String requestId,
|
||
int status,
|
||
String code,
|
||
String message) {
|
||
|
||
Net_Exception_Response resp = new Net_Exception_Response();
|
||
resp.setOp(op);
|
||
resp.setRequestId(requestId);
|
||
resp.setStatus(status);
|
||
resp.setCode(code);
|
||
resp.setMessage(message);
|
||
return resp;
|
||
}
|
||
}
|
||
package server.logic.ws_protocol;
|
||
|
||
/**
|
||
* WireCodes — константы бинарного протокола поверх WebSocket.
|
||
*.
|
||
* Формат входящего сообщения:
|
||
* [4] int opCode (big-endian)
|
||
* [*] payload
|
||
*.
|
||
* Ответ сервера:
|
||
* ровно [4] int statusCode (big-endian)
|
||
*/
|
||
public final class WireCodes {
|
||
private WireCodes() {}
|
||
|
||
public static final class Op {
|
||
public static final int PING = 0;
|
||
public static final int ADD_BLOCK = 1;
|
||
public static final int GET_BLOCKCHAIN = 2;
|
||
public static final int SEARCH_USERS = 30;
|
||
public static final int GET_LAST_BLOCK_INFO = 31;
|
||
private Op() {}
|
||
}
|
||
|
||
public static final class Status {
|
||
public static final int PONG = 100; // ответ на PING
|
||
// public static final int OK = 200; // успех
|
||
|
||
public static final int ALREADY_EXISTS = 409; // пришёл блок < N+1
|
||
public static final int NON_SEQUENTIAL = 412; // пришёл блок > N+1
|
||
public static final int NOT_FOUND = 422; // Нет такого полбзователя - типо добавляем блок к которому нет пользователя - хотя на деле такой статус наверное никогда не вернётся, тк это раньше проверяется
|
||
|
||
|
||
private Status() {}
|
||
|
||
|
||
|
||
|
||
// ============================================================
|
||
// 🟢 УСПЕШНЫЕ ОПЕРАЦИИ
|
||
// ============================================================
|
||
|
||
/** ✅ Блок успешно добавлен в цепочку. */
|
||
public static final int OK = 200;
|
||
|
||
/** 🌱 Создана новая цепочка (первый блок-заголовок принят). */
|
||
public static final int CHAIN_CREATED = 201;
|
||
|
||
/**
|
||
* 🔁 Такой блок уже существует.
|
||
* Клиент может считать это успешным ответом:
|
||
* - сервер возвращает 8 байт: [4] код (202) + [4] номер последнего блока (int)
|
||
* - клиент обновляет свой lastBlockNumber и не пересылает этот блок снова. */
|
||
public static final int BLOCK_ALREADY_EXISTS = 202; // плюс к кодуследом возвращается номер последнего блока на сервере
|
||
|
||
|
||
// ============================================================
|
||
// 🟡 ЛОГИЧЕСКИЕ / ПРОТОКОЛЬНЫЕ ОШИБКИ
|
||
// ============================================================
|
||
|
||
/** ⚠️ Нарушена последовательность — пришёл блок с номером > ожидаемого.
|
||
* Сервер вернёт 8 байт: [4] код (409) + [4] последний номер блока.
|
||
* Клиент должен дослать недостающие блоки. */
|
||
public static final int OUT_OF_SEQUENCE = 409; // плюс к кодуследом возвращается номер последнего блока на сервере
|
||
|
||
/** ❌ Некорректные или неполные данные в запросе. */
|
||
public static final int BAD_REQUEST = 400;
|
||
|
||
/** 🚫 Цепочка с указанным blockchainId не найдена. */
|
||
public static final int CHAIN_NOT_FOUND = 404;
|
||
|
||
/** 🧩 Несовпадение blockchainId между заголовком блока и телом. */
|
||
public static final int INVALID_BLOCKCHAIN_ID = 421;
|
||
|
||
/** ❌ Ошибка верификации блока — хэш или подпись не совпали.
|
||
* 🔐 Ошибка хэша: SHA-256(preimage) не совпал с переданным hash32.
|
||
* 🔏 Ошибка подписи Ed25519 — блок не прошёл криптографическую проверку. */
|
||
public static final int UNVERIFIED = 422;
|
||
|
||
|
||
/** 🙅 Некорректный логин (пустой, неверный формат, недопустимые символы). По сути вообще не может быть, тк логин проверяют при создании в другом блокчейне*/
|
||
public static final int BAD_LOGIN = 462;
|
||
|
||
|
||
// ============================================================
|
||
// 🔴 СИСТЕМНЫЕ ОШИБКИ / ОГРАНИЧЕНИЯ
|
||
// ============================================================
|
||
|
||
// ============================================================
|
||
// 🔴 СИСТЕМНЫЕ ОШИБКИ / ОГРАНИЧЕНИЯ
|
||
// ============================================================
|
||
|
||
/** 💾 Достигнут лимит размера блокчейна. */
|
||
public static final int BLOCKCHAIN_FULL = 507;
|
||
|
||
/** 🧱 Ошибка при сохранении или обновлении данных на сервере (файлы, JSON и т.п.). */
|
||
public static final int SERVER_DATA_ERROR = 501;
|
||
|
||
/** 💥 Общая внутренняя ошибка сервера (необработанное исключение). */
|
||
public static final int INTERNAL_ERROR = 500;
|
||
}
|
||
|
||
}
|
||
|
||
package server.ws;
|
||
|
||
import org.eclipse.jetty.websocket.api.Session;
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||
import shine.db.entities.SolanaUserEntry;
|
||
|
||
import java.net.SocketAddress;
|
||
import java.util.concurrent.atomic.AtomicLong;
|
||
|
||
/**
|
||
* Утилита для работы с WebSocket-подключениями.
|
||
*
|
||
* Цель этой версии:
|
||
* - всегда логировать "кто закрыл" / "что закрывали" / "в каком состоянии был WS";
|
||
* - логировать исключения так, чтобы было видно первопричину;
|
||
* - не терять контекст из-за ctx.reset() (сначала снимаем "снимок" полей).
|
||
*/
|
||
public final class WsConnectionUtils {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(WsConnectionUtils.class);
|
||
|
||
/** Счётчик событий закрытия (удобно коррелировать логи). */
|
||
private static final AtomicLong CLOSE_SEQ = new AtomicLong(0);
|
||
|
||
private WsConnectionUtils() {
|
||
// utility
|
||
}
|
||
|
||
public static void closeConnection(ConnectionContext ctx, int statusCode, String reason) {
|
||
closeConnection(ctx, statusCode, reason, null, "UNKNOWN");
|
||
}
|
||
|
||
/**
|
||
* Расширенное закрытие с указанием инициатора и причины (Throwable).
|
||
*
|
||
* @param ctx контекст
|
||
* @param statusCode код закрытия
|
||
* @param reason причина (пойдёт в close frame + логи)
|
||
* @param cause исключение/первопричина (если закрываем из catch)
|
||
* @param initiator строка "кто инициировал" (handler/op/requestId/etc.)
|
||
*/
|
||
public static void closeConnection(ConnectionContext ctx,
|
||
int statusCode,
|
||
String reason,
|
||
Throwable cause,
|
||
String initiator) {
|
||
if (ctx == null) return;
|
||
|
||
final long closeId = CLOSE_SEQ.incrementAndGet();
|
||
|
||
// --- СНИМОК КОНТЕКСТА ДО reset() ---
|
||
final Session ws = ctx.getWsSession();
|
||
|
||
final String sessionId = safeString(ctx.getSessionId());
|
||
final int authStatus = safeAuthStatus(ctx);
|
||
|
||
final SolanaUserEntry user = ctx.getSolanaUser();
|
||
final String login = (user != null ? safeString(user.getLogin()) : "");
|
||
|
||
final String activeSessionId =
|
||
(ctx.getActiveSession() != null ? safeString(ctx.getActiveSession().getSessionId()) : "");
|
||
|
||
final boolean wsPresent = (ws != null);
|
||
final boolean wsOpen = (ws != null && safeIsOpen(ws));
|
||
final String wsInfo = formatWsInfo(ws);
|
||
|
||
final String threadName = Thread.currentThread().getName();
|
||
final int ctxId = System.identityHashCode(ctx);
|
||
|
||
// Логируем "начало закрытия" всегда, чтобы видеть даже случаи "ws уже закрыт"
|
||
if (cause != null) {
|
||
log.warn("WS_CLOSE#{} BEGIN initiator={} thread={} ctxId={} login={} sessionId={} activeSessionId={} authStatus={} statusCode={} reason={} wsPresent={} wsOpen={} wsInfo={}",
|
||
closeId, initiator, threadName, ctxId, login, sessionId, activeSessionId, authStatus, statusCode, reason, wsPresent, wsOpen, wsInfo, cause);
|
||
} else {
|
||
log.info("WS_CLOSE#{} BEGIN initiator={} thread={} ctxId={} login={} sessionId={} activeSessionId={} authStatus={} statusCode={} reason={} wsPresent={} wsOpen={} wsInfo={}",
|
||
closeId, initiator, threadName, ctxId, login, sessionId, activeSessionId, authStatus, statusCode, reason, wsPresent, wsOpen, wsInfo);
|
||
}
|
||
|
||
// --- ШАГ 1: убрать из реестра (чтобы новые сообщения не шли в мёртвый контекст) ---
|
||
try {
|
||
ActiveConnectionsRegistry.getInstance().remove(ctx);
|
||
log.debug("WS_CLOSE#{} registry.remove OK ctxId={} sessionId={} login={}", closeId, ctxId, sessionId, login);
|
||
} catch (Exception e) {
|
||
log.warn("WS_CLOSE#{} registry.remove FAIL ctxId={} sessionId={} login={}", closeId, ctxId, sessionId, login, e);
|
||
}
|
||
|
||
// --- ШАГ 2: закрыть WS (если открыт) ---
|
||
if (ws != null) {
|
||
if (safeIsOpen(ws)) {
|
||
try {
|
||
ws.close(statusCode, safeString(reason));
|
||
log.info("WS_CLOSE#{} ws.close OK ctxId={} sessionId={} login={} statusCode={} reason={}",
|
||
closeId, ctxId, sessionId, login, statusCode, reason);
|
||
} catch (Exception e) {
|
||
log.warn("WS_CLOSE#{} ws.close FAIL ctxId={} sessionId={} login={} statusCode={} reason={} wsInfo={}",
|
||
closeId, ctxId, sessionId, login, statusCode, reason, wsInfo, e);
|
||
}
|
||
} else {
|
||
log.info("WS_CLOSE#{} ws already closed ctxId={} sessionId={} login={} wsInfo={}",
|
||
closeId, ctxId, sessionId, login, wsInfo);
|
||
}
|
||
}
|
||
|
||
// --- ШАГ 3: очистить контекст (в конце, чтобы не потерять поля в логах выше) ---
|
||
try {
|
||
ctx.reset();
|
||
log.debug("WS_CLOSE#{} ctx.reset OK ctxId={} (was sessionId={}, login={})", closeId, ctxId, sessionId, login);
|
||
} catch (Exception e) {
|
||
log.warn("WS_CLOSE#{} ctx.reset FAIL ctxId={} (was sessionId={}, login={})", closeId, ctxId, sessionId, login, e);
|
||
}
|
||
|
||
log.info("WS_CLOSE#{} END initiator={} ctxId={} sessionId={} login={}", closeId, initiator, ctxId, sessionId, login);
|
||
}
|
||
|
||
private static String safeString(String s) {
|
||
return (s == null ? "" : s);
|
||
}
|
||
|
||
private static int safeAuthStatus(ConnectionContext ctx) {
|
||
try {
|
||
return ctx.getAuthenticationStatus();
|
||
} catch (Exception e) {
|
||
return -999;
|
||
}
|
||
}
|
||
|
||
private static boolean safeIsOpen(Session ws) {
|
||
try {
|
||
return ws.isOpen();
|
||
} catch (Exception e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private static String formatWsInfo(Session ws) {
|
||
if (ws == null) return "null";
|
||
|
||
String remote = "";
|
||
String local = "";
|
||
try {
|
||
SocketAddress ra = ws.getRemoteAddress();
|
||
remote = (ra != null ? ra.toString() : "");
|
||
} catch (Exception ignored) { }
|
||
|
||
try {
|
||
SocketAddress la = ws.getLocalAddress();
|
||
local = (la != null ? la.toString() : "");
|
||
} catch (Exception ignored) { }
|
||
|
||
return "remote=" + remote + ", local=" + local;
|
||
}
|
||
}
|