From 18bf5d65d7dce5faff8cb287bce16b7dce8d4026264f2aa33a689afac99b9eb9 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 18 Mar 2026 22:28:13 +0300 Subject: [PATCH] Initial commit --- .idea/.name | 1 + create_git.sh | 246 + data/TestUser1-001.bch | Bin 0 -> 2178 bytes data/TestUser2-001.bch | Bin 0 -> 1001 bytes data/TestUser3-001.bch | Bin 0 -> 137 bytes data/shine.sqlite | Bin 0 -> 155648 bytes logs/app.2026-03-04.log | 1296 +++++ logs/app.log | 3240 +++++++++++ shine-server-blockchain/all_files.txt | 2884 ++++++++++ shine-server-crypto/build.gradle | 29 + shine-server-db/all_files.txt | 2832 ++++++++++ shine-server-net-protocol/all_files.txt | 4739 ++++++++++++++++ .../src/main/java/server/all_files.txt | 4742 +++++++++++++++++ .../logic/ws_protocol/JSON/all_files.txt | 4548 ++++++++++++++++ .../logic/ws_protocol/JSON/concat_to_file.sh | 20 + .../ws_protocol/JSON/entyties/all_files.txt | 140 + .../JSON/entyties/concat_to_file.sh | 20 + .../ws_protocol/JSON/handlers/all_files.txt | 3475 ++++++++++++ .../JSON/handlers/auth/all_files.txt | 1439 +++++ .../JSON/handlers/auth/concat_to_file.sh | 20 + .../JSON/handlers/concat_to_file.sh | 20 + .../JSON/handlers/connections/all_files.txt | 180 + .../handlers/connections/concat_to_file.sh | 20 + .../JSON/handlers/tempToTest/all_files.txt | 240 + .../JSON/handlers/userParams/all_files.txt | 640 +++ .../handlers/userParams/concat_to_file.sh | 20 + src/main/all_files.txt | 552 ++ src/main/concat_to_file.sh | 20 + src/main/concat_to_file2.sh | 38 + src/main/doc_to_client_writer.txt | 172 + src/test/all_files.txt | 2951 ++++++++++ 31 files changed, 34524 insertions(+) create mode 100644 .idea/.name create mode 100644 create_git.sh create mode 100644 data/TestUser1-001.bch create mode 100644 data/TestUser2-001.bch create mode 100644 data/TestUser3-001.bch create mode 100644 data/shine.sqlite create mode 100644 logs/app.2026-03-04.log create mode 100644 logs/app.log create mode 100644 shine-server-blockchain/all_files.txt create mode 100644 shine-server-crypto/build.gradle create mode 100644 shine-server-db/all_files.txt create mode 100644 shine-server-net-protocol/all_files.txt create mode 100644 shine-server-net-protocol/src/main/java/server/all_files.txt create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/all_files.txt create mode 100755 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/concat_to_file.sh create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/all_files.txt create mode 100755 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/concat_to_file.sh create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/all_files.txt create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/all_files.txt create mode 100755 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/concat_to_file.sh create mode 100755 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/concat_to_file.sh create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/all_files.txt create mode 100755 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/concat_to_file.sh create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/all_files.txt create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/all_files.txt create mode 100755 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/concat_to_file.sh create mode 100644 src/main/all_files.txt create mode 100755 src/main/concat_to_file.sh create mode 100755 src/main/concat_to_file2.sh create mode 100644 src/main/doc_to_client_writer.txt create mode 100644 src/test/all_files.txt diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..77cff8d --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +shine-server-server \ No newline at end of file diff --git a/create_git.sh b/create_git.sh new file mode 100644 index 0000000..ac98d37 --- /dev/null +++ b/create_git.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash + +set -euo pipefail + +GITHUB_USER="ai5590" +TOKEN_VAR_NAME="GIT_AI5590_CLASSIC_API_KEY" + +print_line() { + echo "------------------------------------------------------------" +} + +abort() { + echo + echo "Ошибка: $1" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || abort "Не найдена команда '$1'. Установи её и запусти скрипт снова." +} + +get_token() { + if [[ -z "${GIT_AI5590_CLASSIC_API_KEY:-}" ]]; then + abort "Не задана переменная окружения ${TOKEN_VAR_NAME}. +Перед запуском выполни: +export ${TOKEN_VAR_NAME}=\"ТВОЙ_GITHUB_TOKEN\"" + fi +} + +show_intro() { + print_line + echo "Этот скрипт создаст новый репозиторий в GitHub в аккаунте '${GITHUB_USER}'," + echo "затем инициализирует git в текущей папке (если нужно)," + echo "добавит файлы, кроме самого этого скрипта, создаст первый commit и отправит проект в GitHub." + echo + echo "Скрипт работает с содержимым ТЕКУЩЕЙ папки:" + echo " $(pwd)" + echo + echo "Для авторизации используется переменная окружения:" + echo " ${TOKEN_VAR_NAME}" + print_line + echo +} + +ask_repo_name() { + local repo_name + read -r -p "Введите имя нового репозитория в GitHub: " repo_name + repo_name="$(echo "$repo_name" | xargs)" + + [[ -n "$repo_name" ]] || abort "Имя репозитория не может быть пустым." + + if [[ ! "$repo_name" =~ ^[A-Za-z0-9._-]+$ ]]; then + abort "Имя репозитория содержит недопустимые символы. +Разрешены: буквы, цифры, точка, дефис, подчёркивание." + fi + + REPO_NAME="$repo_name" +} + +ask_visibility() { + local answer + echo + read -r -p "Сделать репозиторий публичным? [y/N]: " answer + answer="${answer:-N}" + + case "$answer" in + y|Y|yes|YES|да|Да|ДА) + REPO_PRIVATE="false" + REPO_VISIBILITY_TEXT="public" + ;; + *) + REPO_PRIVATE="true" + REPO_VISIBILITY_TEXT="private" + ;; + esac +} + +ask_confirmation() { + echo + print_line + echo "Будет выполнено:" + echo "1. Создание GitHub-репозитория '${GITHUB_USER}/${REPO_NAME}' (${REPO_VISIBILITY_TEXT})" + echo "2. Подготовка git в текущей папке" + echo "3. Commit файлов из текущей папки, кроме самого этого скрипта" + echo "4. Push в ветку main" + print_line + echo + read -r -p "Продолжить? [y/N]: " confirm + confirm="${confirm:-N}" + + case "$confirm" in + y|Y|yes|YES|да|Да|ДА) ;; + *) echo "Отменено пользователем."; exit 0 ;; + esac +} + +check_not_inside_wrong_git_repo() { + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + local top + top="$(git rev-parse --show-toplevel)" + if [[ "$top" != "$(pwd)" ]]; then + abort "Ты запустил скрипт внутри уже существующего git-репозитория, но не в его корне. +Корень репозитория: + $top + +Либо перейди в корень этого репозитория, либо запусти скрипт в папке, которая не вложена в другой git-репозиторий." + fi + fi +} + +create_github_repo() { + echo + echo "Создаю репозиторий в GitHub..." + + local http_code + local response_body_file + + response_body_file="$(mktemp)" + + http_code="$( + curl -sS \ + -o "$response_body_file" \ + -w "%{http_code}" \ + -X POST "https://api.github.com/user/repos" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GIT_AI5590_CLASSIC_API_KEY}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + -d "$(cat </dev/null 2>&1; then + echo "Git уже инициализирован." + else + git init + echo "Git инициализирован." + fi + + get_script_paths + + if [[ "$SCRIPT_INSIDE_PROJECT" == "true" ]]; then + echo "Скрипт находится внутри проекта и будет исключён из commit:" + echo " $SCRIPT_RELATIVE_PATH" + git add . ":!$SCRIPT_RELATIVE_PATH" + else + git add . + fi + + if git diff --cached --quiet; then + echo "В staged нет изменений. Возможно, файлы уже были закоммичены ранее." + else + git commit -m "Initial commit" + echo "Создан commit: Initial commit" + fi + + git branch -M main + + local remote_url="https://${GITHUB_USER}:${GIT_AI5590_CLASSIC_API_KEY}@github.com/${GITHUB_USER}/${REPO_NAME}.git" + + if git remote get-url origin >/dev/null 2>&1; then + echo "Remote 'origin' уже существует. Обновляю URL..." + git remote set-url origin "$remote_url" + else + git remote add origin "$remote_url" + fi +} + +push_to_github() { + echo + echo "Отправляю проект в GitHub..." + + git push -u origin main + + echo + echo "Готово." + echo "Репозиторий: https://github.com/${GITHUB_USER}/${REPO_NAME}" +} + +cleanup_remote_url() { + echo + echo "Убираю токен из remote URL, чтобы он не светился в git config..." + + local safe_url="https://github.com/${GITHUB_USER}/${REPO_NAME}.git" + git remote set-url origin "$safe_url" + + echo "Теперь origin = ${safe_url}" +} + +main() { + require_command git + require_command curl + require_command realpath + get_token + check_not_inside_wrong_git_repo + show_intro + ask_repo_name + ask_visibility + ask_confirmation + create_github_repo + prepare_git_repo + push_to_github + cleanup_remote_url +} + +main "$@" \ No newline at end of file diff --git a/data/TestUser1-001.bch b/data/TestUser1-001.bch new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..21a705b152494a756b9f8c81526c0f704e0a769b245cbdafcf5bb830d03f1887 GIT binary patch literal 2178 zcmZQzAOg6<)MW0un*(Mu276@srE-R(7MFw;rxqD9GQ5yj9LxTTK~BJVd*jKeo8LVR zDmJxPIe9{2ne54VPj*Q2KgkMbs+ulx)AjQFk15&@-7U)>q{YlBXNhT)oy7K*oq=J# zy~@eH<5DZuym+U*E=*MKZNtvPyi#GN%X-{cWPen=16te&#Ec*ZfE~ifzy;xfM2OJM zz#tuJXr)kGl3!G*Uyxs1qL7)Vkerd2mzSEOV8FJji86Oic6owN|=IN4#Z3#2SdY>kt8oLviPNz7c(-b z9{+!|H z#@HWiyK+S=RioA^?%-lzsGoGp?P}Ph$Y-o2^>dTC3bg(NB!2zM`k=smfv)Oz-w!|+ zmH{y{+^Mk8205OI2;B?}0-(?axmN)k*vh~l{%hc6aqd#?WYzj4Zi|+{-R6le@@58n z&XNx6bopv-GkgBi`8Ctu{_&SQ-DVu8-7lZVD}LQ3GoW|b+7&JZWgTWW+p;6BZj+ysk@FRpAt=8*2TZ#e-yLoqa$2+D^C?j`gjTn5m#f`gk&`xiS5rav_v~(mvsRYz z8TZct-JS5kUd3$TfA=f-el~X(ypv#k{$=?*+m+&4%x0{+yXxiu-MbKofhystn;V=o zK;g|ogl>>J!u}ISj}rwAR~OF^O-6X z&4vbr0)SqS(UnH{dAg5V% z$$s`@kykYQ__@xutcX@QyngSJI#9}=2E=SY3`zaW405EUeqLCCW29?fUnFTOvaj8B z%jW!MKbFfjKDu>EyH!JSHubY?Ik?&6VfKFWYtJVxkhR-maC!0U%%3_p85{W+7+xJL zKi;(|Q|neJ*XiR+{FP#~j4LLt(Kbj|xpHfp--Ak^GeG_V=6|@q5Csz(5xSYk@z=Jn zx)jN@_%b_-(%YwHP8h6qNw}|f|8G&_KCgsWURJ*LnuvJjODpxepM-g5Jo~2ota+h0 zNAjc>awS_+Pbc^R-FjB$5zmvKAMBs4-tadfdGpbk&+Iz&x4)3)a`}|l#U?8TR08rB z2N1*JO9UQY>_q5hCdXgKYi7Lc+rLxLMmJluHS691tzwX zSeBKpxqF5GKHu)zHT5gJxaWr)tx$Q?c5K-qPhfmW6)l=@N$~PtMh^}drgOUucuqL* zc3TPETvpdUx9;uz&rpAHV)YjX5xQC6{xXK8L*4y3n>W8NUYBC-w>L$C^>W?b>Sg(y z|5raPZ!IZ3M$JlPMsnREYS4dy7wv+)nDRlJ>OgE$--Zz^B`FB zw8*S0zh?@clwo=T^%t;khsT#FJia)I(9H_>7YX?*fj=%#BXQ9$x5@p+7W;n2Hw0x* z$o>$^oq0cHdGS-5H+xPzWj8T*W7c&L-56lF`JjjUd4n3QbqCesRW~Reci{s72ktf8 literal 0 HcmV?d00001 diff --git a/data/TestUser2-001.bch b/data/TestUser2-001.bch new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..f3b103c8a442095a722c19a6f9fe284dc4f98c51798025020e023d83f316ceea GIT binary patch literal 1001 zcmZQzAOg6<)MW0un*(Mu276@srE-R(7MFw;rxqD8GL#C(mCNm_zQXe4+JW;bUoLo< zWoAwgh*3Voe@jf+C1TASM>r=2Yc)4e>Ije%k2 z2bLFsGxA#n5(^(yOP=vRaxLcnkJnDYFZq+Ty}TIj0xg~f#Ec*ZfE~ijAP3=rM2OJM z$P06?p{{{}Ay5-j{iIuNSHmVnK4UGZpPS58p!Fvp@#|OC2L<*EbXC9meqdx+Sg~MY z&$-JUIa{U`Z(1*-{n;aazr_c>$gIkXTP`x%7ZHhW+K!i_Z*i{I^;mdr3#WdEQF3ZB2Vp-Ww*kii81`fc(V- z#4vwx!TrTZgl;Abe=*FrS2@{tTx!Ld7w@#!g^B9DZPVcmU!F5*+H#A@JzaawCGN_a$!^kT@lEc|YKHWM4c6c0A5u}d{Y~Bbq1Bu%%`en~ zkL~O|^m*3NB?@n)#DMX2zww#sZ26AUZLfEJ0rw3%MoIE?Fe>NUNFgtw<@XxL0N& z_vDTC(J2gCp+;5;MX3cjl?o;K3ZaGyeyQcf3I+MaB?`(03L42p`NhS$$r*{6d76w2 zwjO><)f<{0>@?G_akv%0t@q=mfb+7PsXD9=FJ^D|?5e%_X6pJ#^R2??(&ov%U3K{t UlWg?WqOHgH{LAAcR-EJn03lt03;+NC literal 0 HcmV?d00001 diff --git a/data/TestUser3-001.bch b/data/TestUser3-001.bch new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..c75c31a2f534df76d880142ab9d9d087026fa5f5a3e8ee54af2c014f9d0c655d GIT binary patch literal 137 zcmZQzAOg6<)MW0un*(Mu276@srE-R(7MFw;rxqDAGHg6H@%GNjYr*=zJN}t*mi4q~ zD4*vzba%f=kF}&5x4-TQt8ZIxH*S=S6LowcooW=>ztNAcOJeHno$=R9_gF`>004Sb BC7l2O literal 0 HcmV?d00001 diff --git a/data/shine.sqlite b/data/shine.sqlite new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..b79a9a3242325e3e2385f5b61ab91d8d1801aca834f382ff02b2a97e990df149 GIT binary patch literal 155648 zcmeI531CxI*7&nFZA$Z44V9%&rP9)tHr-hYbV)bb1k!~T!H_m-o6;t2l5U7VTR>S9 z0mZ=$P=|q0Q8q!KAmb?Vq1Xb02pwGAxnkmLEHi?*MSMqm9Z)Wh^Ihln58oJBhU`b5!;*bI4@)MAw~7~v7j{(@ zqyvH9CV{X4KFW!KLV?+0G&S05tI&Yd8|oZZ>RMk?Wi6d;*Q@B)fz5sT$&`~Lgv}9D zw%OQ7OO#d^%oe@fVL*lJYK-W`s5dyMLY%DrsXBFXzM9O^q^XNYFNNh=4e4G18RDv3 zUu_Sa(^sZUM%oPMTAM0sx!K}gPe1R9P~|$;fyxSP%~Hse5fQ@0YKNhu%H-aJ`!(Tg zGCw&bN8L%skRff_Ai?9#( zGPE40?%Ad$EbJ*qRcJTQG3l$!)n*4%W0odgouSsTO3)RSnX62CO1PxSVX`A(PdQpc zgWW;9iQZCIU1G9zB8Z!+a>-Q~>=h^_B}bbQE~hHt86Hb_QVAuPuGOitGBg+v8A6+i z)Tz_eI<+QMok!ZORR)VeUuQSj?9Oa?=tROtnHO=}F~D(S=V;)h^qm_ZQ>F$Bn}@NE zXs*$ho2>d$LumzfNN|%C9$v`J<%}cjhTJUoVbC1qFH;5w3tR4?T#+lvecy-Yg4`T3 zghFSop};av?*8HFd#HzuFn+}aXy0(Nmb+ufLlgS=C?^MZMVzafUd6o%sFcf;V-RUE zOPVsJzQ$lPRNM8G!MQ2^9+0`Yv^?g^O=bM1Wx6lLbzuX=l`F!1@5kfH&2e&d8=~%9 zX=GFfTr@iua+s@4DDW|2?p*1b>J3$OICeN6o!4YE)tgIAdS@u$n9go4w-_9CHfIek zoA!KQ(dravvZfZOolL_?s-L0A1N?610bGiD757SBFOw-Jh6$Sq)&mTs4s$(1gT6J7A9O8OnHNwl-s2b&nMh6oo?aOS8p~lrea^+ zZRR=j+^###o$FqKg6!tIbeu($_S2=+{L7n!mo(W^yh7SXb@bQK^8B z)*2ONo4L$f$}QA+ftosdh2CT_)}Yl1J3G-G!74ci5*Dh7j*B-|u zFX9b%ZcgV@fX5i+4sbG6o0Egr$r^2Ha-N#@YB$9^DsT>RdUeB&aqKtPiJ0Z1M_rU; z=TdGnghz`ggo|z$!nqXmD*6g-q3*d0J>3z6o(A;`K+j@@Qb5d9>_IQ^00KY&2mk>f z00e*l5C8%|00;nq-xq;qQtT5n(quB_qa%_6bSe_VzKMv6Qca7@GiGazjpfnPrm1s{ z8S(K}RYh(|W%(FWWmHjE`Lw3kx@^lFQ==(1>0c$=Op5p_Ml&i#V-@p^WkzFMk#1W0 zoa*@aqO{t?xTqpiSmX4RrUHYyD7L9$TFjWH>?TK}J?UR1+e`}iDykS2RTRZJ6%K1! zZFE*$f^BM@v0!pzy(V#TdRbL=LvdY&+7=sUn?9wfA-O<3HEG(vNLG&gT1Ag&Xa z&IcBj#R3i~5Lz{Fpoyz5hQV zAig5b5=Z`fItZEq0U!VbfB+Bx0zd!=00AHX1b_e#_?;5yCm!kA^W)DKh`A}$hayCM z#UovNe=Gy{|9_|60^J4zKmZ5;0U!VbfB+Bx0zd!=0D<2E0q!0D+ur}DzW*QajDUz$ zR0KTJr~S9kdFTTW00KY&2mk>f00e*l5C8(d4+70u5=Ci{DN>4mbUbKSc|c@pV*b=d zi?%qsDlsoUrh4`mb!|pUd1h_KlmuJ#^w_Lyd$!3^UXYbtz4g&CcpoUlI~k(PI*05~HbJtfHS;wf@Pzi1>X;wPvf;@ugGJOU)UX^~D*s^u(~3 zgr=hG*zCxu#fi4b#dD^pnEa+`tg#5nbl~z4GLsA?>1EK!AJXEn_!DDGWfViMzH;-aJCsa{l5&!6?K zFZBKYS^=?v=pbr|+TTZ%fL;OtAOHk_01yBIKmZ5;0U!VbfB+EaA%RG7sjvrUOcE^L zgL51)mhZt?iDfY1p<0iOv1K2xmwHPwZ-fB+Bx0zd!=00AHX1b_e#00KbZ zUqqleM(7(9EDbg_C$XPFud!Ln%vGkb7DKfOf0+Esv&j~Vfh?#o8XP8LPre0_mzz;% zi;SC^m7vO4u#2qN$Y)$dhCbhc3UT3e+7S`ogY8>V<`3Z5=27C47f@oE4dTx1q`D|lmO>9^}irG<`X^crO zHYLUyDvb%1Ds6pjYN8=*>Xg*fl$64P%Jd3LSZvDl>8WW*TYUdtgnsy+I6_?h7kM63 z3j}}w5C8%|00;m9AOHk_01yBIKmZ8*as=*_1O>By06@i@?ch@qQ6S24{q8@Lg-;{s z{r@Eaahf00e*l5C8(dIsx9d0o*?SkCo~Bt7`-e z0s$ZZ1b_e#00KY&2mk>f00e*l5C8(dA_08=ANK#hB5-I52mk>f00e*l5C8%|00;m9 zAOHk_z`ub2?En7_(4k2n00e*l5C8%|00;m9AOHk_01yBIzcPUU#UBJ>#c+XQII)-r z_>~)n=70bY00KY&2mk>f00e*l5C8%|;6H>wqtI_;kVzsE=1AE;8W0f`rD897@)P!= zAFnO&ZILg0X#J=Q{##RqeG)h-ukee*Z=G>$x^VW!x$z~3W*w+GSxWt+{{aM`4z=vd zX*q`5a?G^IJY%-j*jOGtZJIjQm=PauRaNAcRF;o1RYnzsl}~Gmt;@E|F*TZElW@yF zdqaxaZ4lGl^>uZ(cZ~{`L>#EvzWpov7Gr{@%{W+kd|ulF8?AmnzjSHC{t!RWH}_G! z{bQrZgL5>ux6ygVGNUoBNH;BgPIY{IQCe+cTvU-MtZ{luQ-MKU6x&oWEoMwpc9Wyg zPW4v&nWX5b((#n~W^G_ycKVDY^U9Bni>1#ep3i$OXXL5=sx@y?Ew9D*|Hb(C|Gy&6 zq8E4o0U!VbfB+Bx0zd!=00AHX1b_e#00RHn1p0|bvOoV%9b1UGDb#s_sIPdW>+b;I z69#(!{~`7J|HOy?*&PJ!fB+Bx0zd!=00AHX1b_e#00KY&2mk>d0q!sU$B4OC(fr^4 zr}zKo1;ppXdEz@>9dH2zfB+Bx0zd!=00AHX1b_e#00KY&2>h22kc*{4`a1?<_U&4c zpIGWgr5=!?eh7(MxHtC_x8%G0G_ogsnk8?cV(R~rF8=$~Y)z663>qmE3R}kqjr7gQ zY3?IHUtD27jN$sa4F1H0OpMN4{(i9G(9&(@QKt*!M-IKHy?e%}n8uYaheef-IC1)Q z&G{zOr#0xG4EN2v=WLZgERYL?_zFFIx=~sbzn9}EojEe}-Kdw+W}O>-?k8LG{_I&Z z1Eh+F<`(J2$2LbUyI456;_`Q4ms>X|eM?te8SdC=Ix*!h4Nvd=DcTvna>dF-ZP z$HIeJpWA%z>n;0?U#VuP2Nw2LEpPtQI?mT)^l_}m?~M;OU*wwcdfUJa2mRt7a2$}m zQ+y&sL&)D>xaqzTZ@t>~;ykm{d_L%v8GRh{t(!RF@S}(B-1PF5FT=JJ4jO%R!5eP{ z+*ug6ae2CA*!PWJqCzh7^r9 zA-Q<+%@0O=+nyP3Hb2&P#$6?=Dz`pA>sr|7x7P0oniKQWicbdSjBU^D2ac8Gm;D zmxHCBe*35Ak~S+xiQ}bvm(F_zQP_a~;pv`sX`#TMibx`qAn8VFEGM>|hztHhl24x{37h_#|6p))mTzvYOs?vR=b0|CyESFr)A}b3I)6O zzVhyth&!J6`s3$fH;&pmPHBEG{lf`2{B*~ce6(NzStH-Er+D@M1?~DD;px$b+J^a^ zob{fv|JR%Pc1+9&S^N5Qq1U#(4eDMiAo7Yy)KVs;&DJs(gT~H`%*Hw%KNLyOI>0MX zfuJ562-ilrv(UK?=$<){?+E(3nM5TN%SoP}Q`6d2|3#aZ)FkG>$ zopQZ$Na2#S8wul|UUzBT+$XR8LmPNxam@W;D}pQm$`8kzr#!Opl}$frYY#}hxkqp7 z7kTYjOQ9Hp_VsHi!F{Bbwp~uH$SD^cohO@f=G*9+K5GAG|N8wavM1MX`bK?9cj2_X z?&8eBai|VFy<+#J9U8kRv#=F-n=DWvp#+B)p&3`EACzGqk%O!o7J;KPv5O0 zAG=v}v>o{LLF1C?!kOPM-cvnrOz`wAckPyYbBNy7 z@2x8>77+sLha!A`%)$w_3lByP?5#djM>EEQE8|59oY0-p0>Ka*DeMlbttpa<-ewC~ zT4AtQOjRWE<;t7WBBNG3^_Fhz=NDdlHLym>}%>-PqzWq=R??{cBR4;d6RZ%f00e*l5C8%|;FlyYK!WbHvIl+m zu7JFsXPRH%{*sZttQ_@yc!DQJ-IT5^g}I{Zn=7j0|f}E7Ttyv10G9p5_SnV*BRGHj$biXE? zI_D>+=Q^;bdHldV!bx}Uf(P;BYO+ii$IYpP1o2)A)C##FYsWMb|un7Bb zFGI_5>YildCxW=CDwkY^!CrwI=F$Bn}@NEXs*$ho2>d$LumzfNN|%C z9$v`J<%}cjhTJUoVbC1qFH;5w3tR4?Jc%pHecy-YLEIcNgzCAuhT_6JxtrJ1bx;o( zVf=~<(57{R?QU0jXhI(!<>cV5Mh{mvy^4DkP$`!w#~{*TmNaDveT~6psJ81VdvR0z zJs@*)X?e_-o64BIWx6lLbzuX=l`F!1@5kfH&2e&d8)5ETX=GFfTr@iua+s@4DDW|2 z?p*1b>J3$Oc<4JHo!4YE)tgIAdgn;yn9go4w-_9CHfIekoA!KM(CQRuvZfZOolL_? zs-L0A1N?610bGiD757SBFOw-Jh6$Sq)&mTs4s$&UB__MwY_&L@z%9|&!w=j9T(01j z!Fa=>1gT6J7A9O8OnHNwl-s2b&nMh6oo?aOS8p~lrea^+ZRR=j+^# z##o$FqKg9PUaXm-3BrH@D9T#t`VBOo#5+By^Zj1pG}d$}G>F6DIyJJkyNovhKOCg-VXuXa<+qXOq3rxz#e7{`8torqcedDKNob}r>MLwK}^Lb&L5A)HG= zui{?e&pG+xy+?rblt3YvE|5JS)A;<&r$~BU(ogz?c%@h-nkSepN+rq_-(tC*KU&L% z%9JBV3Lo;Pf;Dm#JC0>|hhpbl1o14#%W|5|VWnM`9gH3%XsSrAq_jfsTz6yX&beYa zysKi?vYjnNacNf0xt66@vpC0PO={UzWu9%a>r1V5Y)o{Io@AOjJ-Hw!pNyhawV7&A zXycagR>EX7JGkcZI}YSyB4aHcTG_=y}oon&AjRIO+kF=m*LGIwNG zv5jX>y^429Hv0@g+s8;@b11u#D7B&w9HG?&UPaKm*&2h{=8-zwBf@!zparSfhU1Gz z4!aU!94oY0cBf34g%r8xHi|IP9`z0DhB8dyE)-(2L+1seeGF1Wc^gG28+vFlv>RGb zCA(08$_s53DrL&ZNZ}$CHEcav@kqFnH+(&^yBU3%QBkFLG}W+Eultywr5IM&fAOR1 zLCy^ZT}yFXo!ODH4bgGJb1&QTMSyb?%#2BT9FjH@-Q#c^Y-kI}Lz4&lC<`LH8c`mI z^(v2~A$_jU@xA^InNk%fY>s5ziRzJEU($pQ2rTrOhdt24b@)=)W9Sq#KRcm@lejzz zdPWmczQT+a{rH+b9teG4kW3ke+KqE>7j@HNu$7zWqbd8~u3Bd0xNS3xC|WK>P%>>N zXX6b6QB*;V-{syo>NI`+LkrSfQg)?+n>1GuSKGXXdvubww6ecU85k&hB9ausm-9w1 zRZ-_d7>nBLO8DZQqSO>a>&6^VIkm&bO8BkYL>v)8(%wae;2E|l63@($cL5x9@M?+R@z6(!Q;IXM0sZ;|Mz$Y7Qn<)Y zq-7h*+(%-Yj6_BDQM%$QbYP7}9M2Q^M7t7lf$c(q>!b)ERvl@vv;7V9jP&Yg=~zyN zAYBf0EJiu|QR-{$dpnlnT-sUKNQYrLAAH?h5*$0b5;+#Gv68~< zr!~8bPP-0n!#zZMw(B9mY1bo<%k-a_3~}?ka!j-e7j4qB7O(hSUK*u~;8h8)7}ct49Iz7#JrwAb6E$_=AQ1|m|L^(929y8- zKmZ5;0U!VbfB+Bx0zd!=0D)hFfagcu;q(9h{OAAa{r^D$@gs4a_=31Zd`x_xxJ8^K zrV}LbI3Xgo5e-C=;-KQ=U&FT01`q%OKmZ5;0U!VbfB+Bx0zd!=0D&$D43(lE^>cmU zp7~(_>MQmA_?a*Uaa8P!2glJ0DdO_XTtmNGx;U` z`I-EZ^4n=7^1EG8U%F2+8S0u)DE6T*rSyx0ei73zA}Q{^AHDy7K|ow3J|f;GW)iW4 zT(Mj60`VI0G_i=NRa_vlh#f00e*l5C8(d41s}C zw83Y0_|(2!+OLyDN#6m|k!at`?BJch`NvD6js+C_!c>|#fS*Wj|NHP0DM^t(Kau`r zJvnOI*W-u(cxjZNFJDKhM%f@K{^N0s-gJc`AJh!`13>&#DNb}9chI5|e%n-{7^k@l z_Qx#Te*aIgO5mdq2#8%oH8DbQMv9? zilW(y`1iiO{Y5)OII9bd`1i@Z{SDp>iMvK}rfWUZC`0U4k&3OTXBuUSsEULv?C9a0 zYvlFMa%KqKRbp%EnM!hEzQBi=EI@zxZ!+;Iv6e6q?-OvZ|F<^Gp+7(X2mk>f00e*l z5C8%|00;m9An?l(2$Z6e6xaQF_V^=4#?Rp#jQF6|@9eBhwA8H(d*Yyy@N+n45n_H0 z=WszZ80+S`^Y1z=kP~~+{(qeSef$4IVjcSa|6$@4;+0f00e*l5C8%|00{hY1h_l*7~cM0#oPZ!^Y;HL-u^$DxBpl1 z_W#jxVhOtcUo0R#BihlA zC?!FhTldU;`WP86o&BypwVNO8`tkrHA1&=dUcyUff6pLV%u9FGFS-Niiwg!2bU?ZeZvh5C8%|00;m9AOHk_01yBIKmZ5;0Ym`y|KJ8d00;m9 zAOHk_01yBIKmZ5;0U!Vbe)|OQ_y0x0NP%n;dV+^H0?QS5$%bSF`aY?4*v#eSCY!@n zt}m&wmd>{8ORW}*snlV%TJ&~@!C}%H%ydqwPMw^uCi8V!85wFFdHW*rg2 z>+(shhNSC|Ps>zmNR4{h=xTep-qBQJB9q7%d5StCOCu-IBO-!K*JY_SX_2W~O@4A} zeq_2fCr3MNER(AWCEIs)G`H_<-%YmfYJaW$K>KcVi7whudJFlxsgY*+TGG{5JKb3_gp$UZR9VZ-mT=N$s-pA^CmmM3GY!2fDXq|3 z4Amwond+O~QdeDKvT+hB4E74Dx=`vlPo1Mq&1dV!5a{}NNtlbhu7qxrm&3O2p33Ut zk%2AB=+${9t0OWDqQY2)(Ewp0LsU`WWHkC06GeA~B$G91lqjPTnUzOswE3i_AScJ! z4o8I1k1I7!PRSv2M|Wz_j^&h@J6g~iY*J*I+nHX#6}uL)m$(o4STDHg{jOuwaX41V`8%-f5Z?tso~BZk+^cMsX3yZf@Z}r zWM0jbxEp%P7o1g6@14zJkc<;Lv$;OPkwWcpD`74<68{GC7HFK~xj@5=Xq3bs)5wNs ztg?oshVTB47G!HQqC1-LyN=e5M?01vyYD3LMnfJC@7*YUHwC8ilx#MR@x(x0v!7)&3a-Qcna@qj<7Zm|jDl#8OLRFM| zPP8*8+9M~%nG?g!QRkr6nAV+jq3G#+#|%QQ!_Y%og@!cx7o!RtCr9!tlStVRb)fI_ zBRwaCDzn9;H(MO0a+}%Fq%ZM09e5N#@jac6xt+`gl&VxP#O;(?jV5lKpmMc3YE0JH zO!a!Iayry{m!k%rdu(#n=BO~+yR6MMA<$)=$gI&(!1Wk#wwdhE)_BLzuxw&3ZVlqL7MB5KhL1A}dfmy?tv_ zl6Co6$vOJ;EOky=9=`=VoA3a-LNR$eezy~2@p%e$N)L&3s~;Mzeu_?A$nO9`q%O+J z%g=L7!A=?~r#F-ID5h5uWDT22Gp-rU&NFlfXD(Sedfq~EoM`PF((bOsmdVM=&B`Zf z4Z3M0)H8i%a$crBH!CkUIX^Y?zoDOeHk9FMiY_wje1)S}&$&=zCRLlJ?t%+)U{_nt z0X}{LHSk%-cAA9>YyX-4!f<3&W@dC|Y8-$tdDvW_d;d z4~VoKS%tU^@j}Nv3?k+s)Eq`lRJ@>-L^`LSD3Y3YR3w@@x~Dl-m5Pg$;eiTQd_~$J6Bn9uJ_Mu(YLumU& z?WxgOO2;CUxP#hHzd@p1_g-|lC!Fkf2<^A_qg{FXcJzWbVLLk(psPI{3((GcFWP`T zf(6hK3|5_r49-0`yMIk~*V8NHMSW{ZyNs zpG#Gn>~=%Bi8_4P>v_#wAzU( z-H~K>JkG-`m$hR;$uO0NYx4|6^@H(nCrjjR9I_;uSvkFBP`Z55{ak_DQICTuswTR- zSdK&8RUtREGkMNRpRI$l(B}aE53&iG@E@9Wk8FrG(D%_&&p697iXQuUcLHwbrGAmf z8_fy4FyQ|i2E23T;`szSym^rm4K@BX3~{$ubo(&QP7B=dOr1$S9D28GNN%9-iUw~( z9M5arSW@wVWAP9#Q!Ea?nfR+Q;hp4b{+jzlQ zcA;p&y@FWr3&je&|CbBZ0>$40n);CPU49zblRnLox5QGE2#@X&Xibt23>qmE3R}kq zjr7gQY3?KN73O&UH3wBhRFq04M&G&k`@x1oOShRvoi30cIrO6T?ir(E8dts?7F9mt z#Oc>H=bKQwYtTO#Zr{A;Y?VNazNsO^SLor>jnbk3_D?+EMscyV*Cbb_{=vGauc7vn z2Lj*I{`KUHpTGY+CGRt$G%Pz?cvdKQy>Ol}uuNY+A+hevk)iKKy_7cV+~{*Z*_!uf z&zcz^RXjAeNH0FNIda*>!pRkvzYDwExM)p?XBJShknxM z;t$s*UViDdpNdMizrFVIge8%?t_+cD-yHU@ zWw3umBN}%{Jls1rrrGMLkR!&&orH3Tlnnve1slp>nH>U8?w3Re|hmOAD?TPm*^ z^^Ylr8#koqYbLIb2)>znO(@vVxPImGqsKF=cC4{)dv$2o^-TSN#A}M;%BGH;W9BY= z=8-Rs?r7UJp|x$&t^wzW@-qj&8gw&j`szyw);9}{J8i7_*uN%1we7XxW9I{A$UeK& zaanch=dqiH9SaX?eQxu;ueaRH>o{MJ(Z{hKzc)VEe35I$>um!! z9Q2ESz;QtKPVtEl4IzJj;imgWy!C3^i}TD*^Wnd+%8Wja`PNMwarn_gcW!$5%9mkV z3I~n8y5Nns0`4q~+qgVkGVJ@tFHs?v`6!pc^?2)rP!O^8yHnS9jGtX0+LHO|V^>Nh zKDg)f_z$*gB*(|+M9jN;Z*YFqwiS{c2e-#wn0+AOi7#!n|}!ZNZ71k z2OaySJEPBAEa|OQ-e|Q9kRj{&`uesY!w3X8cH#%y2lfpU#;EMuJ!LSN)X2jZ!n{ng z#^f5$LP6bt`x}PudEkWP;>kBZ81Zd;X1v+_Sl=0Um8`1V`uwbGVV~bxzb9x;%ug#m z8JIJ+J+~vIqkr9~?TZ7~yzeNCIKJA)n>Bk|e`~A{_Gv$1s}8A;BNCcjx|&g>{sO_Q zYZFJrZ}>UmgjF;C?D{VUOF#YgPtPT7R*n+KOZP6F_Y9)20sWI=926I!z@LgpB9tKM zMrkZ3ww;Iz{zK&R8~g{C9ZQ_Ge8|1=icQAa#*gb0hhO|6?Cr+|$^z9`Qe$eclQmYm zgS;n-3{j_L<%bFdyZ65G?v{ui-Mc^*_SXqYt$W^E)}~J!Su|H}&n9m=Ut}_31*dZF?Kky;eZv6_co?OiG)rWiAGd zof(;pbv%A3lAv{fSD*qxJvI=o&1p7z9nd{h`xy z9B2Pg|GixrZhd*qS=;D`H?L_`efs$HQ~h6BwvPtic9iy)} zcU0=lU3y!;$g8ni$|RkD_GN+vA2eQYd7C{EUkqB@DQK@#ATH$rI!Y+`DJnbh=&|b6 z!ShS}6Bkb1n_#$NSv%!=<&eT9X*UwaKfUhKy17qY{f9R2$l{p$!&U@Y0+b()H&1zF z<13qf(AFN1dUKE7)-Uqfvz9_J2JP$DQiA(PEp5A;T#-{QIyz4_=gha!HGS0n&;IrM zS7cAF-}H_8lvEgX2&gczVU|OFJ}nQD$K)@HSbXLP7}+FG4}^p`YJ=_*UGz z+D8LxayF}HiJrb&M?QA5=*E+Kmds3SJbL|D?5?KRxy82@t!X>(>4U~4(}gp?U%aP! z;F#d)TkhH|_vR42t>0T$S}Y<2)(=Ja{+NXmY!@Dk9N1fZsE%fg2Uo_66gZ(fr3Hc^ zI8xXhR$Egf6}`00AHX1b$rttx5gxVxxQKXmar&w3EM@@d#;~f6Q;u2anWcNr?LJ^Le31 z20nG-*4hDwhl(yD{qaEznm?VhqF6A9U2gbwqqLMgi0Q#jeim{6)DXk-Kc=r<5tF$8 z2mJ!w?8jzbo9S;pXZ(}>()cg-y?aR(n~*M!m^5hX6xH^FnHe8M%^kJn;7EP&Yj+(= z`zLqu3m;lP>Vp5)lwqF)j>;?i;_zE%9Gfnjy>V`Q$)Q;XYEG8+Rx7W#q^(JPyKg@~ z@j!v#PTTX39qV`eCt;@VP|?x7QGMQ>6tHY;|Bp7#d+6DDSI=E{+Ru+;zk#g%e7jLv z!rCu}IY5XwP_=#gSN1K&1WlW9u=M!6wg)y^{eFJw(uVyZexh&g6AC7MHB#I5zt6@_ z`7UqB@n!#~I4(IqG+|v;;E;wgi{(^tTHMvIUpy`+5B_o6(Qp6yW6*&)2VS`&D{ah2 z2hTQXk?_bUyh+RG|Fx_7p@$T5~cAjb88hHPpqd@u}tUb6;N69^AI<9^*qZRy~gj69hEup^|}cbj4Fs8OWj0oLf^0= z3si0=FQqoC-5!CK@Ma4-fJw^K{Bh)h*7I+~N6wvedW!$(??39By0PksaOs7P*}Jlq zKKRiWPrO>3uyerCvgd|h{qym^i-z6zH`~s46xxRS2X8vu#UX}QC5w(K9Z#um)&|yP zr_V?-ul(4!So(b8`Ml?HMxN@gTJvUa)oM)&xLwTQTStsz&bj8x!A}Q0d}Q(GyBA8| zB2*2J|M9`EE_OstTr)}ems5Y$IL+fa3J?oiF-O#m(qa!&dfk5s1$UbyM~vI<+I@QB z31KoZ;@CSYR;Ete{N99zQxepEd$p>iFULN0vZ%jo(NiCP^7)O52JMA5(VjQsb04@h z?Eo>|pLIw6KE~Vj!JDi0Rx7W##H~qvZf8G!cYLw^yxlKy+Rqog(mP?U{bb!J?Xq7F z=KPqK|M1zP2j1v6K4SKuMU@}F8Pjpc@Fjm9yn1kk_3qfr?Gql64Ctt@EDzngNxMIH z*@G|7-<0jYF8}Su5uYr6XXEo(|KyzSZ3gVER$g(5TgUr**w4>(Qq3QKB779*?S7Hd zey$@T?!8Z8H%hzieKMi68{<#E@U_Rc&i_-@sx^N~T)lkhzN3acm1||Ok0yRM{C{5( zly6ut?z?q=88PC_cOxfX82ikQ)+_hsz4OK+f4TnD+b@z=@4Ul@nGX04>uvmbizdC* J${Q`w{|6=Ewrl_Z literal 0 HcmV?d00001 diff --git a/logs/app.2026-03-04.log b/logs/app.2026-03-04.log new file mode 100644 index 0000000..2325fdf --- /dev/null +++ b/logs/app.2026-03-04.log @@ -0,0 +1,1296 @@ +17:24:06.308 [wsServer-thread] INFO s.ws.BlockchainTmpRecoveryOnStartup - 🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется. +17:24:06.375 [wsServer-thread] INFO org.eclipse.jetty.server.Server - jetty-11.0.20; built: 2024-01-29T21:04:22.394Z; git: 922f8dc188f7011e60d0361de585fd4ac4d63064; jvm 17.0.18+8-Ubuntu-124.04.1 +17:24:06.437 [wsServer-thread] INFO o.e.j.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@6e1b7ed5{/,null,AVAILABLE} +17:24:06.445 [wsServer-thread] INFO o.e.jetty.server.AbstractConnector - Started ServerConnector@30b62ac{HTTP/1.1, (http/1.1)}{0.0.0.0:7070} +17:24:06.452 [wsServer-thread] INFO org.eclipse.jetty.server.Server - Started Server@79a62b9c{STARTING}[11.0.20,sto=0] @926ms +17:24:06.452 [wsServer-thread] INFO server.ws.WsServer - ✅ WS сервер запущен на ws://localhost:7070/ws +17:24:07.175 [qtp587747978-42] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38734 +17:24:07.210 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-1", + "payload": { + "login": "TestUser1", + "blockchainName": "TestUser1-001", + "solanaKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "blockchainKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "deviceKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "bchLimit": 50000000 + } +} + +17:24:07.279 [ws-worker-1] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser1, blockchainName=TestUser1-001, limit=50000000 +17:24:07.293 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-1","status":200,"payload":{"ok":true}} +17:24:07.298 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-2", + "payload": { + "login": "TestUser1" + } +} + +17:24:07.301 [ws-worker-2] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +17:24:07.303 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-2","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +17:24:07.307 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-3", + "payload": { + "login": "TestUser2", + "blockchainName": "TestUser2-001", + "solanaKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "blockchainKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "deviceKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "bchLimit": 50000000 + } +} + +17:24:07.312 [ws-worker-3] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser2, blockchainName=TestUser2-001, limit=50000000 +17:24:07.313 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-3","status":200,"payload":{"ok":true}} +17:24:07.315 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-4", + "payload": { + "login": "TestUser2" + } +} + +17:24:07.317 [ws-worker-4] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser2, blockchainName=TestUser2-001 +17:24:07.317 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-4","status":200,"payload":{"exists":true,"login":"TestUser2","blockchainName":"TestUser2-001","solanaKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","blockchainKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","deviceKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","ok":true}} +17:24:07.320 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-5", + "payload": { + "login": "TestUser3", + "blockchainName": "TestUser3-001", + "solanaKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "blockchainKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "deviceKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "bchLimit": 50000000 + } +} + +17:24:07.325 [ws-worker-5] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser3, blockchainName=TestUser3-001, limit=50000000 +17:24:07.326 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-5","status":200,"payload":{"ok":true}} +17:24:07.328 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-6", + "payload": { + "login": "TestUser3" + } +} + +17:24:07.330 [ws-worker-6] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser3, blockchainName=TestUser3-001 +17:24:07.330 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-6","status":200,"payload":{"exists":true,"login":"TestUser3","blockchainName":"TestUser3-001","solanaKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","blockchainKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","deviceKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","ok":true}} +17:24:07.335 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-7", + "payload": { + "login": "Testuser1" + } +} + +17:24:07.336 [ws-worker-7] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +17:24:07.337 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-7","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +17:24:07.340 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-8", + "payload": { + "login": "NoSuchUser_987654321" + } +} + +17:24:07.343 [ws-worker-8] INFO s.l.w.J.h.t.Net_GetUser_Handler - ℹ️ GetUser: not found for login=NoSuchUser_987654321 +17:24:07.343 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-8","status":200,"payload":{"exists":false,"ok":true}} +17:24:07.346 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SearchUsers", + "requestId": "it-searchusers-9", + "payload": { + "prefix": "Tes" + } +} + +17:24:07.349 [ws-worker-9] INFO s.l.w.J.h.t.Net_SearchUsers_Handler - ✅ SearchUsers ok: prefix='Tes' -> 3 +17:24:07.352 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SearchUsers","requestId":"it-searchusers-9","status":200,"payload":{"logins":["TestUser1","TestUser2","TestUser3"],"ok":true}} +17:24:07.357 [qtp587747978-38] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.362 [qtp587747978-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38746 +17:24:07.363 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-10", + "payload": { "login": "TestUser1" } +} + +17:24:07.368 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-10","status":200,"payload":{"authNonce":"7J0gCbClpXFegl3rATic0Hom6AQeBJEb/tr80utehfI","ok":true}} +17:24:07.376 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-11", + "payload": { + "storagePwd": "pwd-703441157847947", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1772634247371, + "signatureB64": "HG3VL+hm4YKFEz+ew8MSCSyPwIvnJcQqOQ5DOuN12bqfR7nGhvID+59WLlctWHzBKmEzwcIzrrd2mqXFFiwUAQ==", + "clientInfo": "it-tests" + } +} + +17:24:07.387 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-11","status":200,"payload":{"sessionId":"pzInHbo8fC/HcqfZN16eDGZflRoBl9HiHFS1JnQCp9w","ok":true}} +17:24:07.390 [qtp587747978-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.393 [qtp587747978-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38752 +17:24:07.394 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-12", + "payload": { "login": "TestUser1" } +} + +17:24:07.398 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-12","status":200,"payload":{"authNonce":"4AfJawYtV8x5Yf7uerYzzQ4b3d+F3ghb42u31WUvtqI","ok":true}} +17:24:07.400 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-13", + "payload": { + "storagePwd": "pwd-703441186060889", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1772634247399, + "signatureB64": "RmIP575wz9S+Zq1hLSethOQy6dBvpnxbgvdkhGuNkmtwyOv5IsOND9PVtkRQFI8EywgDpxtOGG0HxGOoUF1EAA==", + "clientInfo": "it-tests" + } +} + +17:24:07.408 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-13","status":200,"payload":{"sessionId":"jcjPUfsTMOJX02lJHKp8OzVUmgFkG9wxW8n8pItS2Oc","ok":true}} +17:24:07.409 [qtp587747978-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.412 [qtp587747978-30] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38756 +17:24:07.414 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-14", + "payload": { "login": "TestUser1" } +} + +17:24:07.416 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-14","status":200,"payload":{"authNonce":"IgmBpTnO94hBCoPY91ZKIItHlWrbCSyBxWSbS/UxbZI","ok":true}} +17:24:07.418 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-15", + "payload": { + "storagePwd": "pwd-703441203727427", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1772634247417, + "signatureB64": "UpX6TZDmCZWPUQGY3JRloe+xBrG1hy1GvmuppB3IDg/7mPO7Q3jtEEhWdkNko0mZfeMEksBLkYKfLF8ylu0HAA==", + "clientInfo": "it-tests" + } +} + +17:24:07.425 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-15","status":200,"payload":{"sessionId":"cWl0luB9lWuUpMaJd/JKxlwOE79boyJ6+R5HYglEjZA","ok":true}} +17:24:07.426 [qtp587747978-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.429 [qtp587747978-31] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38758 +17:24:07.431 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-16", + "payload": { + "sessionId": "pzInHbo8fC/HcqfZN16eDGZflRoBl9HiHFS1JnQCp9w" + } +} + +17:24:07.440 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-16","status":200,"payload":{"nonce":"dVLqdaFvqyomJSAcfMlLg/tdFz0R+rHMhHPVoG73tkg","ok":true}} +17:24:07.444 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-17", + "payload": { + "sessionId": "pzInHbo8fC/HcqfZN16eDGZflRoBl9HiHFS1JnQCp9w", + "timeMs": 1772634247442, + "signatureB64": "ZnqSgduhAmvyYS6GD8oN03s4uefrfCZ3Ke6z0ZHNtL+jaFeDPvJ8bUrF/QD5Lkblu2+14Qk9mfncaIyC1HxECw==", + "clientInfo": "it-tests" + } +} + +17:24:07.460 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-17","status":200,"payload":{"storagePwd":"pwd-703441157847947","ok":true}} +17:24:07.462 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-18", + "payload": { + } +} + +17:24:07.468 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-18","status":200,"payload":{"sessions":[{"sessionId":"pzInHbo8fC/HcqfZN16eDGZflRoBl9HiHFS1JnQCp9w","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634247453},{"sessionId":"jcjPUfsTMOJX02lJHKp8OzVUmgFkG9wxW8n8pItS2Oc","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634247402},{"sessionId":"cWl0luB9lWuUpMaJd/JKxlwOE79boyJ6+R5HYglEjZA","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634247419}],"ok":true}} +17:24:07.470 [qtp587747978-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.473 [qtp587747978-32] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38774 +17:24:07.475 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-19", + "payload": { "login": "TestUser1" } +} + +17:24:07.477 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-19","status":200,"payload":{"authNonce":"/fbfIqWoqEaJXk/uWSYCclKBVPWYrHzFJYMj9zbza/o","ok":true}} +17:24:07.479 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-20", + "payload": { + "storagePwd": "pwd-703441264996047", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1772634247478, + "signatureB64": "v7uJtvVjPKDx5m9LlsEro7eVlL3a/omRpCx6dar/wWTAVL1FUz5GJkZ5izOsYAWt21VRIvPjEX526GYtGccyCg==", + "clientInfo": "it-tests" + } +} + +17:24:07.485 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-20","status":200,"payload":{"sessionId":"PZrBZtq5SNjs0GZHVx6BZ4lF0TRH1t5JtC3MYtDzhLU","ok":true}} +17:24:07.487 [qtp587747978-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.491 [qtp587747978-33] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38776 +17:24:07.492 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-21", + "payload": { + "sessionId": "pzInHbo8fC/HcqfZN16eDGZflRoBl9HiHFS1JnQCp9w" + } +} + +17:24:07.494 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-21","status":200,"payload":{"nonce":"w+RUuMebXJHiP6Sx4c8FTCijnRkjV9fyG2AYfD2xXL0","ok":true}} +17:24:07.496 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-22", + "payload": { + "sessionId": "pzInHbo8fC/HcqfZN16eDGZflRoBl9HiHFS1JnQCp9w", + "timeMs": 1772634247495, + "signatureB64": "dmvaHUX+beBBM+/zg3UyeRqQ0eFZ2nGAUAJ5rTdDXdjM8ZQOrrgg3yuZL4p5H5pLFOvxfoon4WGPcunABMgiCA==", + "clientInfo": "it-tests" + } +} + +17:24:07.504 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-22","status":200,"payload":{"storagePwd":"pwd-703441157847947","ok":true}} +17:24:07.505 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CloseActiveSession", + "requestId": "it-close-23", + "payload": { + "sessionId": "PZrBZtq5SNjs0GZHVx6BZ4lF0TRH1t5JtC3MYtDzhLU" + } +} + +17:24:07.516 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CloseActiveSession","requestId":"it-close-23","status":200,"payload":{"ok":true}} +17:24:07.517 [qtp587747978-30] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.521 [qtp587747978-26] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38778 +17:24:07.523 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-24", + "payload": { + "sessionId": "pzInHbo8fC/HcqfZN16eDGZflRoBl9HiHFS1JnQCp9w" + } +} + +17:24:07.526 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-24","status":200,"payload":{"nonce":"0cnpcBWyN0r5iGVMFJDSsg9Z6hQDp2WaRUBNsQKKrG4","ok":true}} +17:24:07.528 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-25", + "payload": { + "sessionId": "pzInHbo8fC/HcqfZN16eDGZflRoBl9HiHFS1JnQCp9w", + "timeMs": 1772634247527, + "signatureB64": "yNaUTBhwKn0KP7Lke71V5EGQI2OfI2CoKqOVrCPmd9O+Bkf0WTVPNsm1FxfOj5WPmnSWkGSk91N5HoAIVTEZDg==", + "clientInfo": "it-tests" + } +} + +17:24:07.537 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-25","status":200,"payload":{"storagePwd":"pwd-703441157847947","ok":true}} +17:24:07.539 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-26", + "payload": { + } +} + +17:24:07.541 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-26","status":200,"payload":{"sessions":[{"sessionId":"pzInHbo8fC/HcqfZN16eDGZflRoBl9HiHFS1JnQCp9w","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634247532},{"sessionId":"jcjPUfsTMOJX02lJHKp8OzVUmgFkG9wxW8n8pItS2Oc","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634247402},{"sessionId":"cWl0luB9lWuUpMaJd/JKxlwOE79boyJ6+R5HYglEjZA","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634247419}],"ok":true}} +17:24:07.543 [qtp587747978-31] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.547 [qtp587747978-43] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38784 +17:24:07.559 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-27", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGmoQIcAAAAAAAFTSGlOZQlUZXN0VXNlcjEBAPqFPDTDI8g/3E4pMShBs4Jo8Ziy7LaG3hTmhHWvAZuuJ9yU0Z8mAV3dem5NkwoNpPm2utg6OTdcB+yrrnvJsgc=" + } +} + +17:24:07.565 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +17:24:07.570 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=0, newHash=c77bc6b44e27b73bd4117dcd374baa58125bc6309be6fca0b856c3d0eb093d9c +17:24:07.572 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-27","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"c77bc6b44e27b73bd4117dcd374baa58125bc6309be6fca0b856c3d0eb093d9c","ok":true}} +17:24:07.578 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-28", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 1, + "prevBlockHash": "c77bc6b44e27b73bd4117dcd374baa58125bc6309be6fca0b856c3d0eb093d9c", + "blockBytesB64": "AADHe8a0Tie3O9QRfc03S6pYElvGMJvm/KC4VsPQ6wk9nAAAAIEAAAABAAAAAGmoQIcAAQAKAAEAAAAAAAAAAMd7xrROJ7c71BF9zTdLqlgSW8Ywm+b8oLhWw9DrCT2cAAAAAAAbVTE6IHN0b3J5L3Bvc3QgaW4gY2hhbm5lbCAwAQARi/Jfr0LK3WI3UerRrtpyQjAlouS5S7HsHXVQQcIGJwsNy1MTS9QbWdsAx0JdHSEewLeCQWbvlJ+uBTianHMJ" + } +} + +17:24:07.583 [ws-worker-12] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=0 prevLineNumber=0 thisLineNumber=0 prevLineHashLen=32 +17:24:07.587 [ws-worker-12] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=1, newHash=3ef821c36f340e3092cabd494418f9c0fe90a7d8ed3e830cb36834fe7601cf2a +17:24:07.587 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-28","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"3ef821c36f340e3092cabd494418f9c0fe90a7d8ed3e830cb36834fe7601cf2a","ok":true}} +17:24:07.589 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-29", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 2, + "prevBlockHash": "3ef821c36f340e3092cabd494418f9c0fe90a7d8ed3e830cb36834fe7601cf2a", + "blockBytesB64": "AAA++CHDbzQOMJLKvUlEGPnA/pCn2O0+gwyzaDT+dgHPKgAAAGkAAAACAAAAAGmoQIcAAAABAAEAAAAAAAAAAMd7xrROJ7c71BF9zTdLqlgSW8Ywm+b8oLhWw9DrCT2cAAAAAQROZXdzAQBMSQMEJbnkgN9Tfrbhqq7fQtJwjPmbmopdigKSp3ZFgQO0AiNNLJRl7hFzBt36+JqUCc/MFiyGqKUpNhGOj1UO" + } +} + +17:24:07.593 [ws-worker-13] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=1 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +17:24:07.597 [ws-worker-13] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=2, newHash=accb63727c5f12e2927c35141213474247bacd4de9b0a4fe1978de2afbeee673 +17:24:07.598 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-29","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"accb63727c5f12e2927c35141213474247bacd4de9b0a4fe1978de2afbeee673","ok":true}} +17:24:07.600 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-30", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 3, + "prevBlockHash": "accb63727c5f12e2927c35141213474247bacd4de9b0a4fe1978de2afbeee673", + "blockBytesB64": "AACsy2NyfF8S4pJ8NRQSE0dCR7rNTemwpP4ZeN4q++7mcwAAAHYAAAADAAAAAGmoQIcAAQAKAAEAAAACAAAAAqzLY3J8XxLiknw1FBITR0JHus1N6bCk/hl43ir77uZzAAAAAAAQVTE6IE5ld3MgcG9zdCAjMAEAsRNNstW+8BBv6CdQU/h+tbaIaLWFn8oykstLKe31GCiag16VYNS1uORCMBk1j6KQ0lXO2+Z7VlUMwonAHM93DQ==" + } +} + +17:24:07.607 [ws-worker-14] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=2 thisLineNumber=0 prevLineHashLen=32 +17:24:07.613 [ws-worker-14] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=3, newHash=af7df5040d5cadb450487a54f4f6a1c01988e0b5baa980a1afa61639c60c0399 +17:24:07.613 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-30","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"af7df5040d5cadb450487a54f4f6a1c01988e0b5baa980a1afa61639c60c0399","ok":true}} +17:24:07.616 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-31", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 4, + "prevBlockHash": "af7df5040d5cadb450487a54f4f6a1c01988e0b5baa980a1afa61639c60c0399", + "blockBytesB64": "AACvffUEDVyttFBIelT09qHAGYjgtbqpgKGvphY5xgwDmQAAAHYAAAAEAAAAAGmoQIcAAQAKAAEAAAACAAAAA6999QQNXK20UEh6VPT2ocAZiOC1uqmAoa+mFjnGDAOZAAAAAQAQVTE6IE5ld3MgcG9zdCAjMQEArNg4VSIg9SVO3HgOi+HixXB9aKuhAlYeTtr6L33JVg3qATh0R1bqQhyn5opkKBS3Q0Cm08YEncQsC6fxSK+tDQ==" + } +} + +17:24:07.619 [ws-worker-15] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=3 thisLineNumber=1 prevLineHashLen=32 +17:24:07.624 [ws-worker-15] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=4, newHash=edfa47ff99352e90f84778c41ace7a13d77dad405414ff7b40c503d43a8d52a9 +17:24:07.624 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-31","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"edfa47ff99352e90f84778c41ace7a13d77dad405414ff7b40c503d43a8d52a9","ok":true}} +17:24:07.626 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-32", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 5, + "prevBlockHash": "edfa47ff99352e90f84778c41ace7a13d77dad405414ff7b40c503d43a8d52a9", + "blockBytesB64": "AADt+kf/mTUukPhHeMQaznoT132tQFQU/3tAxQPUOo1SqQAAAKEAAAAFAAAAAGmoQIcAAQALAAEAAAACAAAABO36R/+ZNS6Q+Ed4xBrOehPXfa1AVBT/e0DFA9Q6jVKpAAAAAgAAAAOvffUEDVyttFBIelT09qHAGYjgtbqpgKGvphY5xgwDmQAXVTE6IE5ld3MgcG9zdCAjMCAoRURJVCkBAGN86Cx3mO94QQHMun8ZY74VGFCUcQWZt0O+LY48D3srAsNLJPBTYQXX5aneRpGufworGxnkehieeOYj3Qk1Jwc=" + } +} + +17:24:07.629 [ws-worker-16] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=11 lineCode=2 prevLineNumber=4 thisLineNumber=2 prevLineHashLen=32 +17:24:07.633 [ws-worker-16] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=5, newHash=6793912d9d9b542375c1e3efcdda97b17a8947b88724e651a641c61d57b2181d +17:24:07.634 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-32","status":200,"payload":{"serverLastGlobalNumber":5,"serverLastGlobalHash":"6793912d9d9b542375c1e3efcdda97b17a8947b88724e651a641c61d57b2181d","ok":true}} +17:24:07.636 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-33", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGmoQIcAAAAAAAFTSGlOZQlUZXN0VXNlcjIBAG3UOKOD19W+10PxHYz8TuWhDSJRbH4dcQYp9Ip8cpqG2W0bDUtZCZ80H2rhkR3tJHLJf8uun7Hv5GpPXP7KwAQ=" + } +} + +17:24:07.641 [ws-worker-1] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +17:24:07.645 [ws-worker-1] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=0, newHash=df27486aa71bfb2e2b179999458de5257fe5218b0bba0f468680964f5a7ca9c5 +17:24:07.646 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-33","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"df27486aa71bfb2e2b179999458de5257fe5218b0bba0f468680964f5a7ca9c5","ok":true}} +17:24:07.651 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-34", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 6, + "prevBlockHash": "6793912d9d9b542375c1e3efcdda97b17a8947b88724e651a641c61d57b2181d", + "blockBytesB64": "AABnk5EtnZtUI3XB4+/N2pexeolHuIck5lGmQcYdV7IYHQAAAJYAAAAGAAAAAGmoQIcAAwAeAAEAAAAAAAAAAMd7xrROJ7c71BF9zTdLqlgSW8Ywm+b8oLhWw9DrCT2cAAAAAQ1UZXN0VXNlcjItMDAxAAAAAN8nSGqnG/suKxeZmUWN5SV/5SGLC7oPRoaAlk9afKnFAQDv21FZ8ryiea56rA9pputRKHN076SDIZKl+VycBHFzN/TZ+ZMkHF7BtVQNm86LvG9ddsrJFAytc2iPXzYhrhMP" + } +} + +17:24:07.656 [ws-worker-2] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +17:24:07.662 [ws-worker-2] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=6, newHash=121b4bab9aebdf80e159b0721452c42b8fb1a59855796c86fbce83e9fe25a046 +17:24:07.662 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-34","status":200,"payload":{"serverLastGlobalNumber":6,"serverLastGlobalHash":"121b4bab9aebdf80e159b0721452c42b8fb1a59855796c86fbce83e9fe25a046","ok":true}} +17:24:07.665 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-35", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 1, + "prevBlockHash": "df27486aa71bfb2e2b179999458de5257fe5218b0bba0f468680964f5a7ca9c5", + "blockBytesB64": "AADfJ0hqpxv7LisXmZlFjeUlf+Uhiwu6D0aGgJZPWnypxQAAAJYAAAABAAAAAGmoQIcAAwAeAAEAAAAAAAAAAN8nSGqnG/suKxeZmUWN5SV/5SGLC7oPRoaAlk9afKnFAAAAAQ1UZXN0VXNlcjEtMDAxAAAAAqzLY3J8XxLiknw1FBITR0JHus1N6bCk/hl43ir77uZzAQBOFjAeh0R9rsjgfAcHVvwONTotL9KJj9/N4EZ0EXsFFrYeYZhEQ08r+wyo8l4PsJkv9DhrSk4RZdBNi4J+jdwI" + } +} + +17:24:07.669 [ws-worker-3] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +17:24:07.674 [ws-worker-3] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=1, newHash=76ee8f1f01d8641825ea3de0862240c99b40278dd5a382dc4743e23091ce2088 +17:24:07.675 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-35","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"76ee8f1f01d8641825ea3de0862240c99b40278dd5a382dc4743e23091ce2088","ok":true}} +17:24:07.677 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-36", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 7, + "prevBlockHash": "121b4bab9aebdf80e159b0721452c42b8fb1a59855796c86fbce83e9fe25a046", + "blockBytesB64": "AAASG0urmuvfgOFZsHIUUsQrj7GlmFV5bIb7zoPp/iWgRgAAAJYAAAAHAAAAAGmoQIcAAwAKAAEAAAAAAAAABhIbS6ua69+A4VmwchRSxCuPsaWYVXlshvvOg+n+JaBGAAAAAg1UZXN0VXNlcjItMDAxAAAAAN8nSGqnG/suKxeZmUWN5SV/5SGLC7oPRoaAlk9afKnFAQDYYrrhw9vy9HzRSKe92i0FWiEUYWYxdODwL+B3lo2xmvQP470oNc65KRy7Sl+rjPZP8JEYmTMjMMhpgqGTKNcL" + } +} + +17:24:07.680 [ws-worker-4] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=6 thisLineNumber=2 prevLineHashLen=32 +17:24:07.686 [ws-worker-4] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=7, newHash=62d926f8b2b2e70f08f5888806fb53692cb40d4e237b9a8d843e6add28ba7019 +17:24:07.686 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-36","status":200,"payload":{"serverLastGlobalNumber":7,"serverLastGlobalHash":"62d926f8b2b2e70f08f5888806fb53692cb40d4e237b9a8d843e6add28ba7019","ok":true}} +17:24:07.688 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-37", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 2, + "prevBlockHash": "76ee8f1f01d8641825ea3de0862240c99b40278dd5a382dc4743e23091ce2088", + "blockBytesB64": "AAB27o8fAdhkGCXqPeCGIkDJm0AnjdWjgtxHQ+Iwkc4giAAAAJYAAAACAAAAAGmoQIcAAwAKAAEAAAAAAAAAAXbujx8B2GQYJeo94IYiQMmbQCeN1aOC3EdD4jCRziCIAAAAAg1UZXN0VXNlcjEtMDAxAAAAAMd7xrROJ7c71BF9zTdLqlgSW8Ywm+b8oLhWw9DrCT2cAQDXySeqiGupCMIEd8/9xc7UzGzZk3EUZCzlKV5k6dWmQmaaBkqaQUdCvUnAVcnHiEtDVh5iiJ6pLHXmHBLEqj8O" + } +} + +17:24:07.695 [ws-worker-5] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=1 thisLineNumber=2 prevLineHashLen=32 +17:24:07.701 [ws-worker-5] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=2, newHash=ec26ae2df1d64cffa435134279ce42ba5f3e472dc22b97cf2c25be2ebb00dc7e +17:24:07.703 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-37","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"ec26ae2df1d64cffa435134279ce42ba5f3e472dc22b97cf2c25be2ebb00dc7e","ok":true}} +17:24:07.709 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-38", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 8, + "prevBlockHash": "62d926f8b2b2e70f08f5888806fb53692cb40d4e237b9a8d843e6add28ba7019", + "blockBytesB64": "AABi2Sb4srLnDwj1iIgG+1NpLLQNTiN7mo2EPmrdKLpwGQAAAJYAAAAIAAAAAGmoQIcAAwAUAAEAAAAAAAAAB2LZJviysucPCPWIiAb7U2kstA1OI3uajYQ+at0ounAZAAAAAw1UZXN0VXNlcjItMDAxAAAAAN8nSGqnG/suKxeZmUWN5SV/5SGLC7oPRoaAlk9afKnFAQDE71HG1bIQD/APIMF4RRITC6zcu23ESnTyTjcLnxaCcllRvio5+aT1Twvn4ZpIFTLuGUtUhYFP/DY59RnBPXMH" + } +} + +17:24:07.721 [ws-worker-6] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=7 thisLineNumber=3 prevLineHashLen=32 +17:24:07.729 [ws-worker-6] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=8, newHash=e069c806ebde5f884e81cff416420724b3bc2430a8f8b83c087aa4831a249d49 +17:24:07.730 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-38","status":200,"payload":{"serverLastGlobalNumber":8,"serverLastGlobalHash":"e069c806ebde5f884e81cff416420724b3bc2430a8f8b83c087aa4831a249d49","ok":true}} +17:24:07.734 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-39", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 3, + "prevBlockHash": "ec26ae2df1d64cffa435134279ce42ba5f3e472dc22b97cf2c25be2ebb00dc7e", + "blockBytesB64": "AADsJq4t8dZM/6Q1E0J5zkK6Xz5HLcIrl88sJb4uuwDcfgAAAJYAAAADAAAAAGmoQIcAAwAUAAEAAAAAAAAAAuwmri3x1kz/pDUTQnnOQrpfPkctwiuXzywlvi67ANx+AAAAAw1UZXN0VXNlcjEtMDAxAAAAAMd7xrROJ7c71BF9zTdLqlgSW8Ywm+b8oLhWw9DrCT2cAQBriBMYB4C/ZaXtOoAvhlSqAmcTFIxEgmEDJqZ7LKOJeSkdepEtnOEasT3kPYbg1a24SJzMGBtjWqbwz9q9ZYgP" + } +} + +17:24:07.739 [ws-worker-7] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=2 thisLineNumber=3 prevLineHashLen=32 +17:24:07.745 [ws-worker-7] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=3, newHash=94aeabbe47c279bde37d631041982e1c4eaf974a84869e9dc9b55d296e6e55a1 +17:24:07.746 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-39","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"94aeabbe47c279bde37d631041982e1c4eaf974a84869e9dc9b55d296e6e55a1","ok":true}} +17:24:07.751 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-40", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 4, + "prevBlockHash": "94aeabbe47c279bde37d631041982e1c4eaf974a84869e9dc9b55d296e6e55a1", + "blockBytesB64": "AACUrqu+R8J5veN9YxBBmC4cTq+XSoSGnp3JtV0pbm5VoQAAAJYAAAAEAAAAAGmoQIcAAQAUAAENVGVzdFVzZXIxLTAwMQAAAAOvffUEDVyttFBIelT09qHAGYjgtbqpgKGvphY5xgwDmQAqVTI6IHJlcGx5IHRvIFUxIE5ld3MgcG9zdCAjMCAoY3Jvc3MtY2hhaW4pAQBaYTCSUVcoV5rnBPxvpJp6VbtEYfrZRAcERcF5MY1qx4v7spJZNbRxT2L9YsV3XJN/oESXVs3B32euDdF89mcA" + } +} + +17:24:07.756 [ws-worker-8] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=20 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +17:24:07.763 [ws-worker-8] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=4, newHash=39845e5986926d5a2307954f76e5c4262bd7901ac74e6a737db4fb8f6f4382a7 +17:24:07.763 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-40","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"39845e5986926d5a2307954f76e5c4262bd7901ac74e6a737db4fb8f6f4382a7","ok":true}} +17:24:07.766 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-41", + "payload": { + "blockchainName": "TestUser3-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGmoQIcAAAAAAAFTSGlOZQlUZXN0VXNlcjMBAB3mkpUVc76/m8qxrTf4e8Im9BsbMTUFujRFzMxrjbyIosQJWr3wBFwmgEJl2o6hY+YILleIh16M8Ub7mRDvbQk=" + } +} + +17:24:07.770 [ws-worker-9] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +17:24:07.776 [ws-worker-9] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser3, blockchainName=TestUser3-001, blockNumber=0, newHash=adb67a13ab6ec25e6ed169eaa72139fc633b125f7b3a4c353041594f6bfd8691 +17:24:07.776 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-41","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"adb67a13ab6ec25e6ed169eaa72139fc633b125f7b3a4c353041594f6bfd8691","ok":true}} +17:24:07.779 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-42", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 9, + "prevBlockHash": "e069c806ebde5f884e81cff416420724b3bc2430a8f8b83c087aa4831a249d49", + "blockBytesB64": "AADgacgG695fiE6Bz/QWQgcks7wkMKj4uDwIeqSDGiSdSQAAAJYAAAAJAAAAAGmoQIcAAwAUAAEAAAAAAAAACOBpyAbr3l+IToHP9BZCBySzvCQwqPi4PAh6pIMaJJ1JAAAABA1UZXN0VXNlcjMtMDAxAAAAAK22ehOrbsJebtFp6qchOfxjOxJfezpMNTBBWU9r/YaRAQCDT/sI1whMau/8z56w/SkjVFGqER5Phh6ew7jWNxvWCKPecs8Uc/c8c5xsxoPqOOaB7OdmHrwn0oJSNk/UjCsC" + } +} + +17:24:07.784 [ws-worker-10] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=8 thisLineNumber=4 prevLineHashLen=32 +17:24:07.790 [ws-worker-10] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=9, newHash=aa2208f919ea78d9975e025d78b039682c332ffa72f23219ae20c98991454b61 +17:24:07.792 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-42","status":200,"payload":{"serverLastGlobalNumber":9,"serverLastGlobalHash":"aa2208f919ea78d9975e025d78b039682c332ffa72f23219ae20c98991454b61","ok":true}} +17:24:07.794 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-43", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 10, + "prevBlockHash": "aa2208f919ea78d9975e025d78b039682c332ffa72f23219ae20c98991454b61", + "blockBytesB64": "AACqIgj5Gep42ZdeAl14sDloLDMv+nLyMhmuIMmJkUVLYQAAAJYAAAAKAAAAAGmoQIcAAwAVAAEAAAAAAAAACaoiCPkZ6njZl14CXXiwOWgsMy/6cvIyGa4gyYmRRUthAAAABQ1UZXN0VXNlcjItMDAxAAAAAN8nSGqnG/suKxeZmUWN5SV/5SGLC7oPRoaAlk9afKnFAQBezbaHxsC5/SPS8Xw7pNvtIqFlVgzUeKKo+w3+3u8MbG6uckCGVu/9HyHoxZ211RV8JsVfnsrLbIZI/CcSBAII" + } +} + +17:24:07.798 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=21 lineCode=0 prevLineNumber=9 thisLineNumber=5 prevLineHashLen=32 +17:24:07.803 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=10, newHash=d213abb238e0e1aacca30db5939743f5e8a95baa9c4ffa09e33465db4eb1cde8 +17:24:07.804 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-43","status":200,"payload":{"serverLastGlobalNumber":10,"serverLastGlobalHash":"d213abb238e0e1aacca30db5939743f5e8a95baa9c4ffa09e33465db4eb1cde8","ok":true}} +17:24:07.806 [qtp587747978-43] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.815 [qtp587747978-38] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38786 +17:24:07.817 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-44", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1772634247807, + "value": "Anna", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "hn4cw/Xzk0bWcXvM3Lx1RIWwU+TVw/fbtstJHjkv9Blyi+kofNFpnte4ddjMVKxIxmwZrp5ck9dqRUS5A+oWBw==" + } +} + +17:24:07.824 [ws-worker-12] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1772634247807 +17:24:07.828 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-44","status":200,"payload":{"ok":true}} +17:24:07.829 [qtp587747978-38] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.833 [qtp587747978-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38802 +17:24:07.834 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-45", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +17:24:07.840 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-45","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1772634247807,"value":"Anna","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"hn4cw/Xzk0bWcXvM3Lx1RIWwU+TVw/fbtstJHjkv9Blyi+kofNFpnte4ddjMVKxIxmwZrp5ck9dqRUS5A+oWBw==","ok":true}} +17:24:07.843 [qtp587747978-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.848 [qtp587747978-58] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38814 +17:24:07.849 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-46", + "payload": { + "login": "TestUser1", + "param": "profile:city", + "time_ms": 1772634247817, + "value": "Amsterdam", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "ZhclC4leJeuhFXZ0RyH/bP/l8Tza4GWVWztgS1hwYXKKxm92brxj8VmhnjlMDsLuD2iSROffCQZ99ebeCaIMAg==" + } +} + +17:24:07.853 [ws-worker-14] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:city, time_ms=1772634247817 +17:24:07.855 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-46","status":200,"payload":{"ok":true}} +17:24:07.856 [qtp587747978-58] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.861 [qtp587747978-42] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38820 +17:24:07.862 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-47", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1772634247827, + "value": "Anna Updated", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "BbYNYQ3fIGAit1e4RNxVRKf9VI7bFyMd2zhL8gYYAIHh792hKEtW6zyH/2O147koZNXVwb6fGvKeiBIILmqvCw==" + } +} + +17:24:07.865 [ws-worker-15] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1772634247827 +17:24:07.867 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-47","status":200,"payload":{"ok":true}} +17:24:07.869 [qtp587747978-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.875 [qtp587747978-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38822 +17:24:07.876 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-48", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +17:24:07.879 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-48","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1772634247827,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"BbYNYQ3fIGAit1e4RNxVRKf9VI7bFyMd2zhL8gYYAIHh792hKEtW6zyH/2O147koZNXVwb6fGvKeiBIILmqvCw==","ok":true}} +17:24:07.880 [qtp587747978-28] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.884 [qtp587747978-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38828 +17:24:07.885 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListUserParams", + "requestId": "it-listparams-49", + "payload": { "login": "TestUser1" } +} + +17:24:07.890 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListUserParams","requestId":"it-listparams-49","status":200,"payload":{"login":"TestUser1","params":[{"login":"TestUser1","param":"profile:name","time_ms":1772634247827,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"BbYNYQ3fIGAit1e4RNxVRKf9VI7bFyMd2zhL8gYYAIHh792hKEtW6zyH/2O147koZNXVwb6fGvKeiBIILmqvCw=="},{"login":"TestUser1","param":"profile:city","time_ms":1772634247817,"value":"Amsterdam","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"ZhclC4leJeuhFXZ0RyH/bP/l8Tza4GWVWztgS1hwYXKKxm92brxj8VmhnjlMDsLuD2iSROffCQZ99ebeCaIMAg=="}],"ok":true}} +17:24:07.892 [qtp587747978-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:24:07.897 [qtp587747978-30] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:38838 +17:24:07.898 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-50", + "payload": { + "login": "TestUser1" + } +} + +17:24:07.902 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-50","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +17:24:07.906 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-51", + "payload": { + "login": "Testuser1" + } +} + +17:24:07.908 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-51","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +17:24:07.910 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-52", + "payload": { + "login": "TestUser2" + } +} + +17:24:07.911 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-52","status":200,"payload":{"login":"TestUser2","out_friends":["TestUser1"],"in_friends":["TestUser1"],"ok":true}} +17:24:07.912 [qtp587747978-30] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:46.815 [wsServer-thread] INFO s.ws.BlockchainTmpRecoveryOnStartup - 🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется. +17:35:46.888 [wsServer-thread] INFO org.eclipse.jetty.server.Server - jetty-11.0.20; built: 2024-01-29T21:04:22.394Z; git: 922f8dc188f7011e60d0361de585fd4ac4d63064; jvm 17.0.18+8-Ubuntu-124.04.1 +17:35:46.974 [wsServer-thread] INFO o.e.j.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@4778f203{/,null,AVAILABLE} +17:35:46.984 [wsServer-thread] INFO o.e.jetty.server.AbstractConnector - Started ServerConnector@59721949{HTTP/1.1, (http/1.1)}{0.0.0.0:7070} +17:35:46.992 [wsServer-thread] INFO org.eclipse.jetty.server.Server - Started Server@71bdbbb3{STARTING}[11.0.20,sto=0] @1000ms +17:35:46.992 [wsServer-thread] INFO server.ws.WsServer - ✅ WS сервер запущен на ws://localhost:7070/ws +17:35:47.668 [qtp1534512182-41] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55366 +17:35:47.702 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-1", + "payload": { + "login": "TestUser1", + "blockchainName": "TestUser1-001", + "solanaKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "blockchainKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "deviceKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "bchLimit": 50000000 + } +} + +17:35:47.766 [ws-worker-1] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser1, blockchainName=TestUser1-001, limit=50000000 +17:35:47.781 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-1","status":200,"payload":{"ok":true}} +17:35:47.786 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-2", + "payload": { + "login": "TestUser1" + } +} + +17:35:47.791 [ws-worker-2] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +17:35:47.792 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-2","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +17:35:47.795 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-3", + "payload": { + "login": "TestUser2", + "blockchainName": "TestUser2-001", + "solanaKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "blockchainKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "deviceKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "bchLimit": 50000000 + } +} + +17:35:47.801 [ws-worker-3] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser2, blockchainName=TestUser2-001, limit=50000000 +17:35:47.801 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-3","status":200,"payload":{"ok":true}} +17:35:47.803 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-4", + "payload": { + "login": "TestUser2" + } +} + +17:35:47.806 [ws-worker-4] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser2, blockchainName=TestUser2-001 +17:35:47.806 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-4","status":200,"payload":{"exists":true,"login":"TestUser2","blockchainName":"TestUser2-001","solanaKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","blockchainKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","deviceKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","ok":true}} +17:35:47.809 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-5", + "payload": { + "login": "TestUser3", + "blockchainName": "TestUser3-001", + "solanaKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "blockchainKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "deviceKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "bchLimit": 50000000 + } +} + +17:35:47.814 [ws-worker-5] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser3, blockchainName=TestUser3-001, limit=50000000 +17:35:47.814 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-5","status":200,"payload":{"ok":true}} +17:35:47.816 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-6", + "payload": { + "login": "TestUser3" + } +} + +17:35:47.818 [ws-worker-6] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser3, blockchainName=TestUser3-001 +17:35:47.818 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-6","status":200,"payload":{"exists":true,"login":"TestUser3","blockchainName":"TestUser3-001","solanaKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","blockchainKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","deviceKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","ok":true}} +17:35:47.822 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-7", + "payload": { + "login": "Testuser1" + } +} + +17:35:47.824 [ws-worker-7] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +17:35:47.825 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-7","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +17:35:47.827 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-8", + "payload": { + "login": "NoSuchUser_987654321" + } +} + +17:35:47.829 [ws-worker-8] INFO s.l.w.J.h.t.Net_GetUser_Handler - ℹ️ GetUser: not found for login=NoSuchUser_987654321 +17:35:47.829 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-8","status":200,"payload":{"exists":false,"ok":true}} +17:35:47.832 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SearchUsers", + "requestId": "it-searchusers-9", + "payload": { + "prefix": "Tes" + } +} + +17:35:47.836 [ws-worker-9] INFO s.l.w.J.h.t.Net_SearchUsers_Handler - ✅ SearchUsers ok: prefix='Tes' -> 3 +17:35:47.839 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SearchUsers","requestId":"it-searchusers-9","status":200,"payload":{"logins":["TestUser1","TestUser2","TestUser3"],"ok":true}} +17:35:47.847 [qtp1534512182-37] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:47.851 [qtp1534512182-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55370 +17:35:47.854 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-10", + "payload": { "login": "TestUser1" } +} + +17:35:47.858 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-10","status":200,"payload":{"authNonce":"4MroIbv26AJzk81cjSB0kdxNKje0le2CBVbI9CLMpCw","ok":true}} +17:35:47.871 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-11", + "payload": { + "storagePwd": "pwd-704141649191027", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1772634947862, + "signatureB64": "MxpLJvSun43nt38sagsCE9/+cbmS9SK/osnGBUWZ4KjIbTnkFuJNEhnBvioJ7C7kMZIGi4mHtp2K/yRd35XUDA==", + "clientInfo": "it-tests" + } +} + +17:35:47.883 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-11","status":200,"payload":{"sessionId":"M9dSe+50tvuZw+jNTlU9NjDmqHKa5mylhEz66wV5Qs0","ok":true}} +17:35:47.885 [qtp1534512182-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:47.889 [qtp1534512182-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55376 +17:35:47.890 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-12", + "payload": { "login": "TestUser1" } +} + +17:35:47.893 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-12","status":200,"payload":{"authNonce":"3slrhytuXoja+ilgy5UjDtuYmcwe7RwEk2MrdoXQbzQ","ok":true}} +17:35:47.896 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-13", + "payload": { + "storagePwd": "pwd-704141680946131", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1772634947894, + "signatureB64": "FLVVKX/t0a2wxWHhqmMK/wGEFRnhaElLvm6hn6dwx8e6zsnbkPvCHBvxGAnrOmv9DwJ11y+qTLBFSjhTAKNaAQ==", + "clientInfo": "it-tests" + } +} + +17:35:47.902 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-13","status":200,"payload":{"sessionId":"/SqVa2wCAMHw03PHKhxoOP3oCDnY8UPS+aS1B9NWuj4","ok":true}} +17:35:47.904 [qtp1534512182-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:47.908 [qtp1534512182-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55388 +17:35:47.909 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-14", + "payload": { "login": "TestUser1" } +} + +17:35:47.912 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-14","status":200,"payload":{"authNonce":"x/O2M6PgyNNIkE18UXR0BWKvrq6FB7/G0C6/ZO1C4dU","ok":true}} +17:35:47.914 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-15", + "payload": { + "storagePwd": "pwd-704141699608691", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1772634947912, + "signatureB64": "GZiroGxDii78HA9wEAR1VWCJPMfu0z8l5AAhphtqo3Uetw0x5LAl3DBQWdGopXtYgKRTUXzTP8uhrCb70OI9Dw==", + "clientInfo": "it-tests" + } +} + +17:35:47.921 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-15","status":200,"payload":{"sessionId":"HItCoeIf2Kebt6pidZyOADa3u2Fo3/sKJFngfeY1nGU","ok":true}} +17:35:47.923 [qtp1534512182-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:47.926 [qtp1534512182-30] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55392 +17:35:47.928 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-16", + "payload": { + "sessionId": "M9dSe+50tvuZw+jNTlU9NjDmqHKa5mylhEz66wV5Qs0" + } +} + +17:35:47.934 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-16","status":200,"payload":{"nonce":"zk9xt3tAwauZSVpQ9VJkI8fnWcHElwATTjwf8a6atII","ok":true}} +17:35:47.937 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-17", + "payload": { + "sessionId": "M9dSe+50tvuZw+jNTlU9NjDmqHKa5mylhEz66wV5Qs0", + "timeMs": 1772634947936, + "signatureB64": "hoFbnAnR4kqX6aIrD01BCA1vbW3SXNnjdEeOeRGnOrQNGpk74y5jqZEWdDDzSH5pDNms92oc9YWHx6/uc/1bCg==", + "clientInfo": "it-tests" + } +} + +17:35:47.948 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-17","status":200,"payload":{"storagePwd":"pwd-704141649191027","ok":true}} +17:35:47.951 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-18", + "payload": { + } +} + +17:35:47.958 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-18","status":200,"payload":{"sessions":[{"sessionId":"M9dSe+50tvuZw+jNTlU9NjDmqHKa5mylhEz66wV5Qs0","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634947942},{"sessionId":"/SqVa2wCAMHw03PHKhxoOP3oCDnY8UPS+aS1B9NWuj4","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634947897},{"sessionId":"HItCoeIf2Kebt6pidZyOADa3u2Fo3/sKJFngfeY1nGU","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634947916}],"ok":true}} +17:35:47.960 [qtp1534512182-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:47.964 [qtp1534512182-31] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55394 +17:35:47.965 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-19", + "payload": { "login": "TestUser1" } +} + +17:35:47.967 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-19","status":200,"payload":{"authNonce":"ehysOegzPXd+m0Nzxid4r77sNF6AlBlo4eA4EavP2jw","ok":true}} +17:35:47.969 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-20", + "payload": { + "storagePwd": "pwd-704141754913052", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1772634947968, + "signatureB64": "tQxyT2/BbPm/Nc4PCmjfXTWUUMIgUOFArEqOdrnszIX80cmTP/L9HZiKV+fyJOHRoLkxu0St6+yicAZ6T583BQ==", + "clientInfo": "it-tests" + } +} + +17:35:47.978 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-20","status":200,"payload":{"sessionId":"BtO81W6Lx9aEb/R6pkeO9qnRGsHCh0NXIhrGXTGgQLI","ok":true}} +17:35:47.979 [qtp1534512182-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:47.984 [qtp1534512182-32] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55410 +17:35:47.985 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-21", + "payload": { + "sessionId": "M9dSe+50tvuZw+jNTlU9NjDmqHKa5mylhEz66wV5Qs0" + } +} + +17:35:47.987 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-21","status":200,"payload":{"nonce":"2aeBQJHsTw9eENnyncLifgdvuRlVryJOG50JelHNcrA","ok":true}} +17:35:47.989 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-22", + "payload": { + "sessionId": "M9dSe+50tvuZw+jNTlU9NjDmqHKa5mylhEz66wV5Qs0", + "timeMs": 1772634947988, + "signatureB64": "W9GN0zn3tYBbVPzjOKOvSpG1ML32vpDOEjHzQArVqlOW6vla5Mr+7rH1qTy6KQ8HQUNpbUUOTdLI1bnUuxIKCA==", + "clientInfo": "it-tests" + } +} + +17:35:47.997 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-22","status":200,"payload":{"storagePwd":"pwd-704141649191027","ok":true}} +17:35:47.999 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CloseActiveSession", + "requestId": "it-close-23", + "payload": { + "sessionId": "BtO81W6Lx9aEb/R6pkeO9qnRGsHCh0NXIhrGXTGgQLI" + } +} + +17:35:48.005 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CloseActiveSession","requestId":"it-close-23","status":200,"payload":{"ok":true}} +17:35:48.007 [qtp1534512182-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:48.009 [qtp1534512182-25] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55412 +17:35:48.011 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-24", + "payload": { + "sessionId": "M9dSe+50tvuZw+jNTlU9NjDmqHKa5mylhEz66wV5Qs0" + } +} + +17:35:48.012 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-24","status":200,"payload":{"nonce":"MPK3Yj1OX20OxSx79YlGh6ygUn/B7E2B5yxg5IB0tU0","ok":true}} +17:35:48.014 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-25", + "payload": { + "sessionId": "M9dSe+50tvuZw+jNTlU9NjDmqHKa5mylhEz66wV5Qs0", + "timeMs": 1772634948013, + "signatureB64": "hmie95asxbxcL5wjS4xOEWd8b6cZNlNnCZwjeGVCx49icWyYJp/QLk7JsZ4hxGuVbFZfOLv+E/gaMTiuaa/iDQ==", + "clientInfo": "it-tests" + } +} + +17:35:48.021 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-25","status":200,"payload":{"storagePwd":"pwd-704141649191027","ok":true}} +17:35:48.022 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-26", + "payload": { + } +} + +17:35:48.027 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-26","status":200,"payload":{"sessions":[{"sessionId":"M9dSe+50tvuZw+jNTlU9NjDmqHKa5mylhEz66wV5Qs0","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634948016},{"sessionId":"/SqVa2wCAMHw03PHKhxoOP3oCDnY8UPS+aS1B9NWuj4","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634947897},{"sessionId":"HItCoeIf2Kebt6pidZyOADa3u2Fo3/sKJFngfeY1nGU","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1772634947916}],"ok":true}} +17:35:48.029 [qtp1534512182-30] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:48.036 [qtp1534512182-42] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55416 +17:35:48.065 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-27", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGmoQ0QAAAAAAAFTSGlOZQlUZXN0VXNlcjEBAN9wpLJH6yZeIJYTY2q+E/xUI2nKF7Eqz1zSVBGL7d02ilvi8aLCF64bFnxhuDNctWJe8Co/X+xNA7UPfLHdGww=" + } +} + +17:35:48.071 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +17:35:48.076 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=0, newHash=d25af52611a37f97616c9514c52bd68bfaa7936ef376669408061d082c773a26 +17:35:48.078 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-27","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"d25af52611a37f97616c9514c52bd68bfaa7936ef376669408061d082c773a26","ok":true}} +17:35:48.083 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-28", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 1, + "prevBlockHash": "d25af52611a37f97616c9514c52bd68bfaa7936ef376669408061d082c773a26", + "blockBytesB64": "AADSWvUmEaN/l2FslRTFK9aL+qeTbvN2ZpQIBh0ILHc6JgAAAIEAAAABAAAAAGmoQ0QAAQAKAAEAAAAAAAAAANJa9SYRo3+XYWyVFMUr1ov6p5Nu83ZmlAgGHQgsdzomAAAAAAAbVTE6IHN0b3J5L3Bvc3QgaW4gY2hhbm5lbCAwAQAMz4VXh5ADCSXQXJkAI9hxnsYHQUqH1mNKcv+21jm+KwqIhMikP7LRLbH1aEkp3JajN5lhGA0A1j9KtGk8wzIB" + } +} + +17:35:48.086 [ws-worker-12] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=0 prevLineNumber=0 thisLineNumber=0 prevLineHashLen=32 +17:35:48.090 [ws-worker-12] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=1, newHash=c5c0f2eef6fca62c053cc74d4140f3f1b625c39c78ea477fcb983e4455ac63a0 +17:35:48.091 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-28","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"c5c0f2eef6fca62c053cc74d4140f3f1b625c39c78ea477fcb983e4455ac63a0","ok":true}} +17:35:48.093 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-29", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 2, + "prevBlockHash": "c5c0f2eef6fca62c053cc74d4140f3f1b625c39c78ea477fcb983e4455ac63a0", + "blockBytesB64": "AADFwPLu9vymLAU8x01BQPPxtiXDnHjqR3/LmD5EVaxjoAAAAGkAAAACAAAAAGmoQ0QAAAABAAEAAAAAAAAAANJa9SYRo3+XYWyVFMUr1ov6p5Nu83ZmlAgGHQgsdzomAAAAAQROZXdzAQCFTpv0v7FSTvGrQosQV6jFyu3PYPUy64mKpFB0rIJMZleuVhPJsyfZrJJjAceLfX/GKy2nl674sXNrSEWC43wN" + } +} + +17:35:48.097 [ws-worker-13] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=1 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +17:35:48.102 [ws-worker-13] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=2, newHash=88f2f027ba158d3bce519fb28c7f3f5847e4d47089e30ac7f8f6d8494c76862f +17:35:48.102 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-29","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"88f2f027ba158d3bce519fb28c7f3f5847e4d47089e30ac7f8f6d8494c76862f","ok":true}} +17:35:48.105 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-30", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 3, + "prevBlockHash": "88f2f027ba158d3bce519fb28c7f3f5847e4d47089e30ac7f8f6d8494c76862f", + "blockBytesB64": "AACI8vAnuhWNO85Rn7KMfz9YR+TUcInjCsf49thJTHaGLwAAAHYAAAADAAAAAGmoQ0QAAQAKAAEAAAACAAAAAojy8Ce6FY07zlGfsox/P1hH5NRwieMKx/j22ElMdoYvAAAAAAAQVTE6IE5ld3MgcG9zdCAjMAEAqWuGtTrkJVg2f7UvO9Nn5a9zl6EPdCnpRseIa5MlKiFMa5OK5HHQ8eGydO6tOyXfrNpvfk0fszublsjkXLIaAA==" + } +} + +17:35:48.109 [ws-worker-14] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=2 thisLineNumber=0 prevLineHashLen=32 +17:35:48.113 [ws-worker-14] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=3, newHash=dc22bd31596c102731f72748358ba0cb45178bb5e9c28587751d5ea109702ccb +17:35:48.114 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-30","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"dc22bd31596c102731f72748358ba0cb45178bb5e9c28587751d5ea109702ccb","ok":true}} +17:35:48.116 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-31", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 4, + "prevBlockHash": "dc22bd31596c102731f72748358ba0cb45178bb5e9c28587751d5ea109702ccb", + "blockBytesB64": "AADcIr0xWWwQJzH3J0g1i6DLRReLtenChYd1HV6hCXAsywAAAHYAAAAEAAAAAGmoQ0QAAQAKAAEAAAACAAAAA9wivTFZbBAnMfcnSDWLoMtFF4u16cKFh3UdXqEJcCzLAAAAAQAQVTE6IE5ld3MgcG9zdCAjMQEAIA3+vEjBY98WJsEe1pNNbJG8QvR2QYAgTg51RodeCH70Rg5F+9KRMg7fqcEXhoQkWlltLTi+qFx2cHNnK35mCA==" + } +} + +17:35:48.119 [ws-worker-15] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=3 thisLineNumber=1 prevLineHashLen=32 +17:35:48.123 [ws-worker-15] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=4, newHash=6707766490ddf182f8d9fc4a0277521bc412e75b36e2cdd2991190c0150df83b +17:35:48.124 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-31","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"6707766490ddf182f8d9fc4a0277521bc412e75b36e2cdd2991190c0150df83b","ok":true}} +17:35:48.126 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-32", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 5, + "prevBlockHash": "6707766490ddf182f8d9fc4a0277521bc412e75b36e2cdd2991190c0150df83b", + "blockBytesB64": "AABnB3ZkkN3xgvjZ/EoCd1IbxBLnWzbizdKZEZDAFQ34OwAAAKEAAAAFAAAAAGmoQ0QAAQALAAEAAAACAAAABGcHdmSQ3fGC+Nn8SgJ3UhvEEudbNuLN0pkRkMAVDfg7AAAAAgAAAAPcIr0xWWwQJzH3J0g1i6DLRReLtenChYd1HV6hCXAsywAXVTE6IE5ld3MgcG9zdCAjMCAoRURJVCkBACrlA5VlFrji8YgM/drBn3jseDHPO/FgnSLLoycUreUHxJAuz76t+hRdVwXE/Qkc/nN7fbMk2382f7thsrr0zww=" + } +} + +17:35:48.132 [ws-worker-16] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=11 lineCode=2 prevLineNumber=4 thisLineNumber=2 prevLineHashLen=32 +17:35:48.136 [ws-worker-16] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=5, newHash=ff4b21b995b5c437153043f3568e3d5b357bb33db28378da8a3dabb8bffa81ae +17:35:48.137 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-32","status":200,"payload":{"serverLastGlobalNumber":5,"serverLastGlobalHash":"ff4b21b995b5c437153043f3568e3d5b357bb33db28378da8a3dabb8bffa81ae","ok":true}} +17:35:48.139 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-33", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGmoQ0QAAAAAAAFTSGlOZQlUZXN0VXNlcjIBAF6tAFvD+svMBllV9BrJwNq1724+HoswAZ0wUTwSJNdkM71DM+qGuhugnxtQH/97l8M/2mo/8BwEpXlr+slNewU=" + } +} + +17:35:48.142 [ws-worker-1] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +17:35:48.146 [ws-worker-1] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=0, newHash=c16c8d0bee0f5a36736fd9c557c940f9c0a262d23adc99d5aa842795f9908622 +17:35:48.146 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-33","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"c16c8d0bee0f5a36736fd9c557c940f9c0a262d23adc99d5aa842795f9908622","ok":true}} +17:35:48.152 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-34", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 6, + "prevBlockHash": "ff4b21b995b5c437153043f3568e3d5b357bb33db28378da8a3dabb8bffa81ae", + "blockBytesB64": "AAD/SyG5lbXENxUwQ/NWjj1bNXuzPbKDeNqKPau4v/qBrgAAAJYAAAAGAAAAAGmoQ0QAAwAeAAEAAAAAAAAAANJa9SYRo3+XYWyVFMUr1ov6p5Nu83ZmlAgGHQgsdzomAAAAAQ1UZXN0VXNlcjItMDAxAAAAAMFsjQvuD1o2c2/ZxVfJQPnAomLSOtyZ1aqEJ5X5kIYiAQCRv/4Y0pRXiPXv/74s+/stBgLyN5Lm1wvhhOO/cEu38eXZeextm6LdAVQAr2aUMD7RjU4HFLTTOHLwmBMWANEP" + } +} + +17:35:48.155 [ws-worker-2] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +17:35:48.160 [ws-worker-2] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=6, newHash=6260e1ddd548d91db006f7a68651ce9601002c0d46f58ebfe8d339e7e86f7eb9 +17:35:48.161 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-34","status":200,"payload":{"serverLastGlobalNumber":6,"serverLastGlobalHash":"6260e1ddd548d91db006f7a68651ce9601002c0d46f58ebfe8d339e7e86f7eb9","ok":true}} +17:35:48.163 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-35", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 1, + "prevBlockHash": "c16c8d0bee0f5a36736fd9c557c940f9c0a262d23adc99d5aa842795f9908622", + "blockBytesB64": "AADBbI0L7g9aNnNv2cVXyUD5wKJi0jrcmdWqhCeV+ZCGIgAAAJYAAAABAAAAAGmoQ0QAAwAeAAEAAAAAAAAAAMFsjQvuD1o2c2/ZxVfJQPnAomLSOtyZ1aqEJ5X5kIYiAAAAAQ1UZXN0VXNlcjEtMDAxAAAAAojy8Ce6FY07zlGfsox/P1hH5NRwieMKx/j22ElMdoYvAQC8yaM1oCr7p+dzEy6afkQVra/2azh5Do7SdeJoz5RcwrulKawEZ8KLLf/TZe6MrMhntLID5cUJEH/K1ziBYhoJ" + } +} + +17:35:48.167 [ws-worker-3] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +17:35:48.172 [ws-worker-3] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=1, newHash=888c2ce7202f0aed36794f99ce2edea6289dc53363e4add74bc4ff99ef9b15e0 +17:35:48.173 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-35","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"888c2ce7202f0aed36794f99ce2edea6289dc53363e4add74bc4ff99ef9b15e0","ok":true}} +17:35:48.175 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-36", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 7, + "prevBlockHash": "6260e1ddd548d91db006f7a68651ce9601002c0d46f58ebfe8d339e7e86f7eb9", + "blockBytesB64": "AABiYOHd1UjZHbAG96aGUc6WAQAsDUb1jr/o0znn6G9+uQAAAJYAAAAHAAAAAGmoQ0QAAwAKAAEAAAAAAAAABmJg4d3VSNkdsAb3poZRzpYBACwNRvWOv+jTOefob365AAAAAg1UZXN0VXNlcjItMDAxAAAAAMFsjQvuD1o2c2/ZxVfJQPnAomLSOtyZ1aqEJ5X5kIYiAQBvQkqgI8ZviPQZYF0rc6APK6gEheU9yFq8KoV/ayGtv57UEu6v4QO4GiB3IRux8q2iSJVjF29tK14v8baMdhYD" + } +} + +17:35:48.178 [ws-worker-4] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=6 thisLineNumber=2 prevLineHashLen=32 +17:35:48.184 [ws-worker-4] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=7, newHash=dfd2dc801385bfb50e4663912f85a91e7ff5c70e9490779643a773f0c35a2383 +17:35:48.184 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-36","status":200,"payload":{"serverLastGlobalNumber":7,"serverLastGlobalHash":"dfd2dc801385bfb50e4663912f85a91e7ff5c70e9490779643a773f0c35a2383","ok":true}} +17:35:48.186 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-37", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 2, + "prevBlockHash": "888c2ce7202f0aed36794f99ce2edea6289dc53363e4add74bc4ff99ef9b15e0", + "blockBytesB64": "AACIjCznIC8K7TZ5T5nOLt6mKJ3FM2PkrddLxP+Z75sV4AAAAJYAAAACAAAAAGmoQ0QAAwAKAAEAAAAAAAAAAYiMLOcgLwrtNnlPmc4u3qYoncUzY+St10vE/5nvmxXgAAAAAg1UZXN0VXNlcjEtMDAxAAAAANJa9SYRo3+XYWyVFMUr1ov6p5Nu83ZmlAgGHQgsdzomAQDDRxWvgDiRGpQew7Kh9z8MYj/3prWEQIs01QhozwWWnzilshzygIGqJneDPY7ZLw6TDF1PV/vDoaKNQBvWuKcL" + } +} + +17:35:48.190 [ws-worker-5] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=1 thisLineNumber=2 prevLineHashLen=32 +17:35:48.195 [ws-worker-5] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=2, newHash=f07662b735330e05401c63a7ebed50e31656a3b792f388c41177ef627de41e00 +17:35:48.195 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-37","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"f07662b735330e05401c63a7ebed50e31656a3b792f388c41177ef627de41e00","ok":true}} +17:35:48.198 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-38", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 8, + "prevBlockHash": "dfd2dc801385bfb50e4663912f85a91e7ff5c70e9490779643a773f0c35a2383", + "blockBytesB64": "AADf0tyAE4W/tQ5GY5Evhakef/XHDpSQd5ZDp3Pww1ojgwAAAJYAAAAIAAAAAGmoQ0QAAwAUAAEAAAAAAAAAB9/S3IAThb+1DkZjkS+FqR5/9ccOlJB3lkOnc/DDWiODAAAAAw1UZXN0VXNlcjItMDAxAAAAAMFsjQvuD1o2c2/ZxVfJQPnAomLSOtyZ1aqEJ5X5kIYiAQA90jaPRKxqcJArNH8Ekcsx45wCKdXYgphwpCyq1+v9UdQ2K2XMZaKHBrMHcppm5PvIGs9qb4XJDVPABKeTjpEK" + } +} + +17:35:48.202 [ws-worker-6] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=7 thisLineNumber=3 prevLineHashLen=32 +17:35:48.207 [ws-worker-6] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=8, newHash=0c5cdf5cca8ae7cb51cd3163959bddd672f3f3dd608bd10ee0c3eeb7444f62c1 +17:35:48.207 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-38","status":200,"payload":{"serverLastGlobalNumber":8,"serverLastGlobalHash":"0c5cdf5cca8ae7cb51cd3163959bddd672f3f3dd608bd10ee0c3eeb7444f62c1","ok":true}} +17:35:48.210 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-39", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 3, + "prevBlockHash": "f07662b735330e05401c63a7ebed50e31656a3b792f388c41177ef627de41e00", + "blockBytesB64": "AADwdmK3NTMOBUAcY6fr7VDjFlajt5LziMQRd+9ifeQeAAAAAJYAAAADAAAAAGmoQ0QAAwAUAAEAAAAAAAAAAvB2Yrc1Mw4FQBxjp+vtUOMWVqO3kvOIxBF372J95B4AAAAAAw1UZXN0VXNlcjEtMDAxAAAAANJa9SYRo3+XYWyVFMUr1ov6p5Nu83ZmlAgGHQgsdzomAQDQUxJKz82MNXhMf6B3G1u3j6AutUlRu7u/CppbM/61phx6eMpbdlu+BvSYlNN/H0XnU4ni7/Mpwk2TxwGRingN" + } +} + +17:35:48.213 [ws-worker-7] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=2 thisLineNumber=3 prevLineHashLen=32 +17:35:48.218 [ws-worker-7] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=3, newHash=e8cf983dc0419f48417093465580080f6888f6b184c9be14469f337b04c7b6ef +17:35:48.218 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-39","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"e8cf983dc0419f48417093465580080f6888f6b184c9be14469f337b04c7b6ef","ok":true}} +17:35:48.221 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-40", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 4, + "prevBlockHash": "e8cf983dc0419f48417093465580080f6888f6b184c9be14469f337b04c7b6ef", + "blockBytesB64": "AADoz5g9wEGfSEFwk0ZVgAgPaIj2sYTJvhRGnzN7BMe27wAAAJYAAAAEAAAAAGmoQ0QAAQAUAAENVGVzdFVzZXIxLTAwMQAAAAPcIr0xWWwQJzH3J0g1i6DLRReLtenChYd1HV6hCXAsywAqVTI6IHJlcGx5IHRvIFUxIE5ld3MgcG9zdCAjMCAoY3Jvc3MtY2hhaW4pAQAOOK1wlQK1S+Bg4/IVespq2JuTR01MPPf8Z2UxRLWBaS8HiT3rlEXOQ2i4naxWEUZ+qd+OY9V0BgPQdtMTvPsD" + } +} + +17:35:48.223 [ws-worker-8] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=20 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +17:35:48.228 [ws-worker-8] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=4, newHash=51580ad4926307f946cb37100313ab41f88213e24d13fce160d38e3dfcf965a4 +17:35:48.228 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-40","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"51580ad4926307f946cb37100313ab41f88213e24d13fce160d38e3dfcf965a4","ok":true}} +17:35:48.231 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-41", + "payload": { + "blockchainName": "TestUser3-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGmoQ0QAAAAAAAFTSGlOZQlUZXN0VXNlcjMBAJLuVP4cQPDmksboKm2uXGXYsCO4c+Sx/vpP5PWjH5OshbYpaYfEn0GqimPqZECekOlCZRHuWDSnKkTzSIzazwQ=" + } +} + +17:35:48.235 [ws-worker-9] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +17:35:48.239 [ws-worker-9] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser3, blockchainName=TestUser3-001, blockNumber=0, newHash=202cc24f8483c0068f14d95e303079ae9925eb9657c04bbce020020fa6380a2e +17:35:48.239 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-41","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"202cc24f8483c0068f14d95e303079ae9925eb9657c04bbce020020fa6380a2e","ok":true}} +17:35:48.241 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-42", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 9, + "prevBlockHash": "0c5cdf5cca8ae7cb51cd3163959bddd672f3f3dd608bd10ee0c3eeb7444f62c1", + "blockBytesB64": "AAAMXN9cyorny1HNMWOVm93WcvPz3WCL0Q7gw+63RE9iwQAAAJYAAAAJAAAAAGmoQ0QAAwAUAAEAAAAAAAAACAxc31zKiufLUc0xY5Wb3dZy8/PdYIvRDuDD7rdET2LBAAAABA1UZXN0VXNlcjMtMDAxAAAAACAswk+Eg8AGjxTZXjAwea6ZJeuWV8BLvOAgAg+mOAouAQDfZKXZWXyYa1XD9rlnlgFnSqb/+g2ZlxpyX8SgcXmL5eT5TnpZofGjt+Y95Xpy1afW9HYLKofC6GS2nECy/l4G" + } +} + +17:35:48.246 [ws-worker-10] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=8 thisLineNumber=4 prevLineHashLen=32 +17:35:48.252 [ws-worker-10] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=9, newHash=14c72bf2b05e409e6017dbe451440530f938508a8d758e964c904de1adc20da1 +17:35:48.254 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-42","status":200,"payload":{"serverLastGlobalNumber":9,"serverLastGlobalHash":"14c72bf2b05e409e6017dbe451440530f938508a8d758e964c904de1adc20da1","ok":true}} +17:35:48.256 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-43", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 10, + "prevBlockHash": "14c72bf2b05e409e6017dbe451440530f938508a8d758e964c904de1adc20da1", + "blockBytesB64": "AAAUxyvysF5AnmAX2+RRRAUw+ThQio11jpZMkE3hrcINoQAAAJYAAAAKAAAAAGmoQ0QAAwAVAAEAAAAAAAAACRTHK/KwXkCeYBfb5FFEBTD5OFCKjXWOlkyQTeGtwg2hAAAABQ1UZXN0VXNlcjItMDAxAAAAAMFsjQvuD1o2c2/ZxVfJQPnAomLSOtyZ1aqEJ5X5kIYiAQAvuIDiuAg8x1DlwBauYGu8YVqDRAHd3rgvyGsUrfpjzvpCs/ver5kV/P2BMOS1NaxjJDBmuzArQ/q0pFoy47IC" + } +} + +17:35:48.261 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=21 lineCode=0 prevLineNumber=9 thisLineNumber=5 prevLineHashLen=32 +17:35:48.267 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=10, newHash=77c5d0ae5f1c9d9deadf3155839bcd04eab575256eff3199dfd2bc5bd601aee5 +17:35:48.267 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-43","status":200,"payload":{"serverLastGlobalNumber":10,"serverLastGlobalHash":"77c5d0ae5f1c9d9deadf3155839bcd04eab575256eff3199dfd2bc5bd601aee5","ok":true}} +17:35:48.269 [qtp1534512182-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:48.279 [qtp1534512182-37] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55428 +17:35:48.282 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-44", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1772634948270, + "value": "Anna", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "saMbpoHHk6X0uV9sWVbWFaJtbkPk3yNxoZ9BIQ4XzwpfKlsqJo0yU2rk8gmO0TGXybLD0v1lTUv8kBWoVJMfBQ==" + } +} + +17:35:48.290 [ws-worker-12] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1772634948270 +17:35:48.295 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-44","status":200,"payload":{"ok":true}} +17:35:48.297 [qtp1534512182-37] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:48.304 [qtp1534512182-26] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55432 +17:35:48.307 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-45", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +17:35:48.312 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-45","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1772634948270,"value":"Anna","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"saMbpoHHk6X0uV9sWVbWFaJtbkPk3yNxoZ9BIQ4XzwpfKlsqJo0yU2rk8gmO0TGXybLD0v1lTUv8kBWoVJMfBQ==","ok":true}} +17:35:48.315 [qtp1534512182-26] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:48.321 [qtp1534512182-57] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55436 +17:35:48.323 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-46", + "payload": { + "login": "TestUser1", + "param": "profile:city", + "time_ms": 1772634948280, + "value": "Amsterdam", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "VkmT3DHnpMHFMMHc7ZojjuiwRFji1x/ziWyFTVRHSG2lOXNNWJ8d9/NPPyDd+pVWGRFncsXfBY405Mjs5IVsBw==" + } +} + +17:35:48.327 [ws-worker-14] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:city, time_ms=1772634948280 +17:35:48.330 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-46","status":200,"payload":{"ok":true}} +17:35:48.332 [qtp1534512182-57] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:48.337 [qtp1534512182-41] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55440 +17:35:48.339 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-47", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1772634948290, + "value": "Anna Updated", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "nNueZz1I6Ztrf6GYsv07g6aUFnKIlid9pVOFkg/ccmgDin4zlUScU/nsmge81qtijpuMwtDeGxqkpbqhqWxSDQ==" + } +} + +17:35:48.342 [ws-worker-15] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1772634948290 +17:35:48.344 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-47","status":200,"payload":{"ok":true}} +17:35:48.346 [qtp1534512182-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:48.350 [qtp1534512182-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55448 +17:35:48.351 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-48", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +17:35:48.353 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-48","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1772634948290,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"nNueZz1I6Ztrf6GYsv07g6aUFnKIlid9pVOFkg/ccmgDin4zlUScU/nsmge81qtijpuMwtDeGxqkpbqhqWxSDQ==","ok":true}} +17:35:48.355 [qtp1534512182-28] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:48.359 [qtp1534512182-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55464 +17:35:48.360 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListUserParams", + "requestId": "it-listparams-49", + "payload": { "login": "TestUser1" } +} + +17:35:48.366 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListUserParams","requestId":"it-listparams-49","status":200,"payload":{"login":"TestUser1","params":[{"login":"TestUser1","param":"profile:name","time_ms":1772634948290,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"nNueZz1I6Ztrf6GYsv07g6aUFnKIlid9pVOFkg/ccmgDin4zlUScU/nsmge81qtijpuMwtDeGxqkpbqhqWxSDQ=="},{"login":"TestUser1","param":"profile:city","time_ms":1772634948280,"value":"Amsterdam","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"VkmT3DHnpMHFMMHc7ZojjuiwRFji1x/ziWyFTVRHSG2lOXNNWJ8d9/NPPyDd+pVWGRFncsXfBY405Mjs5IVsBw=="}],"ok":true}} +17:35:48.368 [qtp1534512182-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +17:35:48.374 [qtp1534512182-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:55468 +17:35:48.375 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-50", + "payload": { + "login": "TestUser1" + } +} + +17:35:48.381 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-50","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +17:35:48.384 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-51", + "payload": { + "login": "Testuser1" + } +} + +17:35:48.386 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-51","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +17:35:48.388 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-52", + "payload": { + "login": "TestUser2" + } +} + +17:35:48.390 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-52","status":200,"payload":{"login":"TestUser2","out_friends":["TestUser1"],"in_friends":["TestUser1"],"ok":true}} +17:35:48.392 [qtp1534512182-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye diff --git a/logs/app.log b/logs/app.log new file mode 100644 index 0000000..a2f17b7 --- /dev/null +++ b/logs/app.log @@ -0,0 +1,3240 @@ +19:50:43.993 [wsServer-thread] INFO s.ws.BlockchainTmpRecoveryOnStartup - 🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется. +19:50:44.067 [wsServer-thread] INFO org.eclipse.jetty.server.Server - jetty-11.0.20; built: 2024-01-29T21:04:22.394Z; git: 922f8dc188f7011e60d0361de585fd4ac4d63064; jvm 17.0.18+8-Ubuntu-124.04.1 +19:50:44.122 [wsServer-thread] INFO o.e.j.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@1343e032{/,null,AVAILABLE} +19:50:44.129 [wsServer-thread] INFO o.e.jetty.server.AbstractConnector - Started ServerConnector@742a3118{HTTP/1.1, (http/1.1)}{0.0.0.0:7070} +19:50:44.137 [wsServer-thread] INFO org.eclipse.jetty.server.Server - Started Server@7615bc30{STARTING}[11.0.20,sto=0] @904ms +19:50:44.137 [wsServer-thread] INFO server.ws.WsServer - ✅ WS сервер запущен на ws://localhost:7070/ws +19:50:44.883 [qtp341972345-42] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49556 +19:50:44.922 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-1", + "payload": { + "login": "TestUser1", + "blockchainName": "TestUser1-001", + "solanaKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "blockchainKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "deviceKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "bchLimit": 50000000 + } +} + +19:50:44.976 [ws-worker-1] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser1, blockchainName=TestUser1-001, limit=50000000 +19:50:44.989 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-1","status":200,"payload":{"ok":true}} +19:50:44.993 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-2", + "payload": { + "login": "TestUser1" + } +} + +19:50:44.998 [ws-worker-2] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +19:50:45.001 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-2","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +19:50:45.004 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-3", + "payload": { + "login": "TestUser2", + "blockchainName": "TestUser2-001", + "solanaKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "blockchainKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "deviceKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "bchLimit": 50000000 + } +} + +19:50:45.009 [ws-worker-3] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser2, blockchainName=TestUser2-001, limit=50000000 +19:50:45.010 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-3","status":200,"payload":{"ok":true}} +19:50:45.012 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-4", + "payload": { + "login": "TestUser2" + } +} + +19:50:45.014 [ws-worker-4] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser2, blockchainName=TestUser2-001 +19:50:45.014 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-4","status":200,"payload":{"exists":true,"login":"TestUser2","blockchainName":"TestUser2-001","solanaKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","blockchainKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","deviceKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","ok":true}} +19:50:45.017 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-5", + "payload": { + "login": "TestUser3", + "blockchainName": "TestUser3-001", + "solanaKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "blockchainKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "deviceKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "bchLimit": 50000000 + } +} + +19:50:45.021 [ws-worker-5] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser3, blockchainName=TestUser3-001, limit=50000000 +19:50:45.022 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-5","status":200,"payload":{"ok":true}} +19:50:45.025 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-6", + "payload": { + "login": "TestUser3" + } +} + +19:50:45.028 [ws-worker-6] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser3, blockchainName=TestUser3-001 +19:50:45.028 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-6","status":200,"payload":{"exists":true,"login":"TestUser3","blockchainName":"TestUser3-001","solanaKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","blockchainKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","deviceKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","ok":true}} +19:50:45.034 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-7", + "payload": { + "login": "Testuser1" + } +} + +19:50:45.036 [ws-worker-7] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +19:50:45.037 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-7","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +19:50:45.041 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-8", + "payload": { + "login": "NoSuchUser_987654321" + } +} + +19:50:45.042 [ws-worker-8] INFO s.l.w.J.h.t.Net_GetUser_Handler - ℹ️ GetUser: not found for login=NoSuchUser_987654321 +19:50:45.043 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-8","status":200,"payload":{"exists":false,"ok":true}} +19:50:45.044 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SearchUsers", + "requestId": "it-searchusers-9", + "payload": { + "prefix": "Tes" + } +} + +19:50:45.049 [ws-worker-9] INFO s.l.w.J.h.t.Net_SearchUsers_Handler - ✅ SearchUsers ok: prefix='Tes' -> 3 +19:50:45.051 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SearchUsers","requestId":"it-searchusers-9","status":200,"payload":{"logins":["TestUser1","TestUser2","TestUser3"],"ok":true}} +19:50:45.058 [qtp341972345-38] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.061 [qtp341972345-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49572 +19:50:45.064 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-10", + "payload": { "login": "TestUser1" } +} + +19:50:45.067 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-10","status":200,"payload":{"authNonce":"bNbzTc/Jfphi+0wFnf+rB3QKHxdveteAFx9560tb86s","ok":true}} +19:50:45.077 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-11", + "payload": { + "storagePwd": "pwd-1550596922725", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773852645071, + "signatureB64": "PTRvSD+Wdfg8Rjr1f/uyDwJHa0iq0QiB/C8Wdapdl2aTH6JniG/dakilA8tnZVtjDS8Jb1A0RbqyGBV8oJxqBQ==", + "clientInfo": "it-tests" + } +} + +19:50:45.091 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-11","status":200,"payload":{"sessionId":"rTsFoL3Ocac7TYT2BVTJ+xL0SjqX95n0F4JU6ut9N2I","ok":true}} +19:50:45.093 [qtp341972345-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.096 [qtp341972345-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49576 +19:50:45.098 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-12", + "payload": { "login": "TestUser1" } +} + +19:50:45.101 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-12","status":200,"payload":{"authNonce":"oLIXdFUnRV3onA+9jSv29SOPEwGcnY+NT3h1gGAHUV0","ok":true}} +19:50:45.104 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-13", + "payload": { + "storagePwd": "pwd-1550627512500", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773852645102, + "signatureB64": "m67e3tVvdhTRYevjnXyuJjXO4kbgAJwsuAP7vILx+PrSxrMM755oVdsfU0lw4jsKYGDgJqp+B1+twYJBnY8OBA==", + "clientInfo": "it-tests" + } +} + +19:50:45.111 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-13","status":200,"payload":{"sessionId":"1etEIK/rETleOyEPOXKPRqDCCN3T+yVJ1uSyfqjGAxM","ok":true}} +19:50:45.113 [qtp341972345-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.117 [qtp341972345-30] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49582 +19:50:45.118 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-14", + "payload": { "login": "TestUser1" } +} + +19:50:45.120 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-14","status":200,"payload":{"authNonce":"5vLyx8N0UGXj7V5npO39C+N+rjija1UwIDZQd4bPAa4","ok":true}} +19:50:45.123 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-15", + "payload": { + "storagePwd": "pwd-1550646744975", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773852645121, + "signatureB64": "36S8uCdu+ebLXCQ+023DgOORcFc/olbJctZusAFJevHlF3hBjs+nR+c4MGKQ2s5Yz2K++oel4fkwNp7nQL4GDQ==", + "clientInfo": "it-tests" + } +} + +19:50:45.129 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-15","status":200,"payload":{"sessionId":"mrE7FCfwwbV2QK4bs1+ITW5nQLLjEGnvgvNHRGWBPZw","ok":true}} +19:50:45.131 [qtp341972345-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.134 [qtp341972345-31] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49588 +19:50:45.136 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-16", + "payload": { + "sessionId": "rTsFoL3Ocac7TYT2BVTJ+xL0SjqX95n0F4JU6ut9N2I" + } +} + +19:50:45.144 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-16","status":200,"payload":{"nonce":"LH6Qccn1VVuQl7/9oxf6JVnaqrvWwvLfx9LhDPcNh6o","ok":true}} +19:50:45.146 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-17", + "payload": { + "sessionId": "rTsFoL3Ocac7TYT2BVTJ+xL0SjqX95n0F4JU6ut9N2I", + "timeMs": 1773852645145, + "signatureB64": "E+hOxX+7mFM1r7uMBDsCiHUmogNUqXBqSAHz9iL228WGF9iaTMXaCglUq60eZPoJiac5hZ+3eO1au0D/OkJuBg==", + "clientInfo": "it-tests" + } +} + +19:50:45.157 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-17","status":200,"payload":{"storagePwd":"pwd-1550596922725","ok":true}} +19:50:45.161 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-18", + "payload": { + } +} + +19:50:45.167 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-18","status":200,"payload":{"sessions":[{"sessionId":"rTsFoL3Ocac7TYT2BVTJ+xL0SjqX95n0F4JU6ut9N2I","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852645151},{"sessionId":"1etEIK/rETleOyEPOXKPRqDCCN3T+yVJ1uSyfqjGAxM","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852645105},{"sessionId":"mrE7FCfwwbV2QK4bs1+ITW5nQLLjEGnvgvNHRGWBPZw","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852645124}],"ok":true}} +19:50:45.168 [qtp341972345-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.172 [qtp341972345-32] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49592 +19:50:45.174 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-19", + "payload": { "login": "TestUser1" } +} + +19:50:45.176 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-19","status":200,"payload":{"authNonce":"L8HiwLlxqMwL4Mb1NuV7Kg/lKDsfn9O9VgJwK1UKjZ8","ok":true}} +19:50:45.178 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-20", + "payload": { + "storagePwd": "pwd-1550703151095", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773852645177, + "signatureB64": "eY17e/Zruj7bsKyw0wqwB4BUZWC2tMsACLR/I4vzdICUVgfvzz6SuYfQ1XRDI8dmpb3Oco7wLBavGbkHfUw8Bg==", + "clientInfo": "it-tests" + } +} + +19:50:45.184 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-20","status":200,"payload":{"sessionId":"YqNbSmG90Zu05zNPSvq45+zgcDjlwQBbyradW8skSew","ok":true}} +19:50:45.186 [qtp341972345-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.190 [qtp341972345-33] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49596 +19:50:45.192 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-21", + "payload": { + "sessionId": "rTsFoL3Ocac7TYT2BVTJ+xL0SjqX95n0F4JU6ut9N2I" + } +} + +19:50:45.193 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-21","status":200,"payload":{"nonce":"9s1xHZn3eGx7BSUtOkLYnroRX0Vtm5SoaoFDiaMD6NQ","ok":true}} +19:50:45.195 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-22", + "payload": { + "sessionId": "rTsFoL3Ocac7TYT2BVTJ+xL0SjqX95n0F4JU6ut9N2I", + "timeMs": 1773852645194, + "signatureB64": "XzUe7vqeMBUqa5m6eOuRRktcd99G13nQzeKrWw1hh8BIDgOhQrYzjIR2cUepbkrdYWuBRzHPtlbxLNVPN8eKAQ==", + "clientInfo": "it-tests" + } +} + +19:50:45.203 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-22","status":200,"payload":{"storagePwd":"pwd-1550596922725","ok":true}} +19:50:45.204 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CloseActiveSession", + "requestId": "it-close-23", + "payload": { + "sessionId": "YqNbSmG90Zu05zNPSvq45+zgcDjlwQBbyradW8skSew" + } +} + +19:50:45.212 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CloseActiveSession","requestId":"it-close-23","status":200,"payload":{"ok":true}} +19:50:45.213 [qtp341972345-30] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.216 [qtp341972345-26] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49608 +19:50:45.217 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-24", + "payload": { + "sessionId": "rTsFoL3Ocac7TYT2BVTJ+xL0SjqX95n0F4JU6ut9N2I" + } +} + +19:50:45.219 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-24","status":200,"payload":{"nonce":"AVEI1I5YguWbRQhsPi5pb+rBOc3+k5EU0nnCab76Hng","ok":true}} +19:50:45.220 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-25", + "payload": { + "sessionId": "rTsFoL3Ocac7TYT2BVTJ+xL0SjqX95n0F4JU6ut9N2I", + "timeMs": 1773852645219, + "signatureB64": "LLYvZL9AuWOMMU7IK8YklSh5qSTpOURJTuJS9SNVJgQ7utRBtKx7x3mbh6TbQ6RUt7LNiOCYZBUFbgdt72gpBA==", + "clientInfo": "it-tests" + } +} + +19:50:45.227 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-25","status":200,"payload":{"storagePwd":"pwd-1550596922725","ok":true}} +19:50:45.228 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-26", + "payload": { + } +} + +19:50:45.230 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-26","status":200,"payload":{"sessions":[{"sessionId":"rTsFoL3Ocac7TYT2BVTJ+xL0SjqX95n0F4JU6ut9N2I","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852645222},{"sessionId":"1etEIK/rETleOyEPOXKPRqDCCN3T+yVJ1uSyfqjGAxM","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852645105},{"sessionId":"mrE7FCfwwbV2QK4bs1+ITW5nQLLjEGnvgvNHRGWBPZw","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852645124}],"ok":true}} +19:50:45.232 [qtp341972345-31] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.238 [qtp341972345-43] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49612 +19:50:45.261 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-27", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm61+UAAAAAAAFTSGlOZQlUZXN0VXNlcjEBAKkzag/QBI97FU7F+yGoMc50tj0AVwFvZvEPwXAnYhkWXrDbWGbPZg6m1mhXPO/b8XPWobARNp3rOuJLDrgnVQY=" + } +} + +19:50:45.277 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +19:50:45.284 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=0, newHash=7f3edc6081d6b7655495f973667825310bc28fe0aeb4c62c47a29e6f136a9a24 +19:50:45.286 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-27","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"7f3edc6081d6b7655495f973667825310bc28fe0aeb4c62c47a29e6f136a9a24","ok":true}} +19:50:45.293 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-28", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 1, + "prevBlockHash": "7f3edc6081d6b7655495f973667825310bc28fe0aeb4c62c47a29e6f136a9a24", + "blockBytesB64": "AAB/Ptxggda3ZVSV+XNmeCUxC8KP4K60xixHop5vE2qaJAAAAIEAAAABAAAAAGm61+UAAQAKAAEAAAAAAAAAAH8+3GCB1rdlVJX5c2Z4JTELwo/grrTGLEeinm8TapokAAAAAAAbVTE6IHN0b3J5L3Bvc3QgaW4gY2hhbm5lbCAwAQAVJnAqJ2Wb7nAcX92w6JlX+8VAHp7aq1torwb4NHZnNKqGxtNo3xCyq574UJR4K3hCc3AE+703gNx3sekuUwwK" + } +} + +19:50:45.299 [ws-worker-12] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=0 prevLineNumber=0 thisLineNumber=0 prevLineHashLen=32 +19:50:45.305 [ws-worker-12] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=1, newHash=40d1f4f642b3fb91f2a513928917d8d834cdec92a0765ab6b595c666a459ed8b +19:50:45.306 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-28","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"40d1f4f642b3fb91f2a513928917d8d834cdec92a0765ab6b595c666a459ed8b","ok":true}} +19:50:45.309 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-29", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 2, + "prevBlockHash": "40d1f4f642b3fb91f2a513928917d8d834cdec92a0765ab6b595c666a459ed8b", + "blockBytesB64": "AABA0fT2QrP7kfKlE5KJF9jYNM3skqB2Wra1lcZmpFntiwAAAGkAAAACAAAAAGm61+UAAAABAAEAAAAAAAAAAH8+3GCB1rdlVJX5c2Z4JTELwo/grrTGLEeinm8TapokAAAAAQROZXdzAQBiw40zOwlJcKewyK4zyy0TQ3uMNhhR/llKnL0JcNa/1RUFxTP5VtnEPRXr2vDSv6mm2zT3TJHTr+eHnqph/WoO" + } +} + +19:50:45.314 [ws-worker-13] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=1 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +19:50:45.318 [ws-worker-13] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=2, newHash=081e50cb72251c9b8fd89d1f5922840d010f56b3ac902541e1ccb102580631c6 +19:50:45.319 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-29","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"081e50cb72251c9b8fd89d1f5922840d010f56b3ac902541e1ccb102580631c6","ok":true}} +19:50:45.321 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-30", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 3, + "prevBlockHash": "081e50cb72251c9b8fd89d1f5922840d010f56b3ac902541e1ccb102580631c6", + "blockBytesB64": "AAAIHlDLciUcm4/YnR9ZIoQNAQ9Ws6yQJUHhzLECWAYxxgAAAHYAAAADAAAAAGm61+UAAQAKAAEAAAACAAAAAggeUMtyJRybj9idH1kihA0BD1azrJAlQeHMsQJYBjHGAAAAAAAQVTE6IE5ld3MgcG9zdCAjMAEAjQdKW8XUk0jD3naQmkYsfXo2nwL2veEFnJMFIShOaf4QofmvDgVe/FFK6qla+siqbVFtulIwScUPr4LZvD1VCQ==" + } +} + +19:50:45.326 [ws-worker-14] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=2 thisLineNumber=0 prevLineHashLen=32 +19:50:45.331 [ws-worker-14] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=3, newHash=88b6ecbfc386f0efaf8fa481ce66c6840f3f6b4bcfcc849d5938198bbb3d71ef +19:50:45.331 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-30","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"88b6ecbfc386f0efaf8fa481ce66c6840f3f6b4bcfcc849d5938198bbb3d71ef","ok":true}} +19:50:45.333 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-31", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 4, + "prevBlockHash": "88b6ecbfc386f0efaf8fa481ce66c6840f3f6b4bcfcc849d5938198bbb3d71ef", + "blockBytesB64": "AACItuy/w4bw76+PpIHOZsaEDz9rS8/MhJ1ZOBmLuz1x7wAAAHYAAAAEAAAAAGm61+UAAQAKAAEAAAACAAAAA4i27L/DhvDvr4+kgc5mxoQPP2tLz8yEnVk4GYu7PXHvAAAAAQAQVTE6IE5ld3MgcG9zdCAjMQEASXl5ISSGMmZVdwCaL/lnGIZtW6KR3ST9tPzmPcVPxGtZjPOKeGJnq9A8umpj3eJpljVymVOINP8ouxQCvvqsDA==" + } +} + +19:50:45.338 [ws-worker-15] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=3 thisLineNumber=1 prevLineHashLen=32 +19:50:45.342 [ws-worker-15] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=4, newHash=ecde23195777f9dcd97dbf08b4801b4bc84f1f691f369c84ec63ee993bf19354 +19:50:45.343 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-31","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"ecde23195777f9dcd97dbf08b4801b4bc84f1f691f369c84ec63ee993bf19354","ok":true}} +19:50:45.345 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-32", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 5, + "prevBlockHash": "ecde23195777f9dcd97dbf08b4801b4bc84f1f691f369c84ec63ee993bf19354", + "blockBytesB64": "AADs3iMZV3f53Nl9vwi0gBtLyE8faR82nITsY+6ZO/GTVAAAAKEAAAAFAAAAAGm61+UAAQALAAEAAAACAAAABOzeIxlXd/nc2X2/CLSAG0vITx9pHzachOxj7pk78ZNUAAAAAgAAAAOItuy/w4bw76+PpIHOZsaEDz9rS8/MhJ1ZOBmLuz1x7wAXVTE6IE5ld3MgcG9zdCAjMCAoRURJVCkBAMWOdvgyG274PRwiRgzejLjomLflJ0m24j5Zaxx8gf835NnD0iAoPOa5JRJ7KqGSLc366kOOEiz2Gy16hzltYw8=" + } +} + +19:50:45.348 [ws-worker-16] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=11 lineCode=2 prevLineNumber=4 thisLineNumber=2 prevLineHashLen=32 +19:50:45.352 [ws-worker-16] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=5, newHash=fdcf9bd40f11da4d294bab4b56269e28ef95c8a4c3f100f44eb581150c9dbc84 +19:50:45.352 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-32","status":200,"payload":{"serverLastGlobalNumber":5,"serverLastGlobalHash":"fdcf9bd40f11da4d294bab4b56269e28ef95c8a4c3f100f44eb581150c9dbc84","ok":true}} +19:50:45.355 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-33", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm61+UAAAAAAAFTSGlOZQlUZXN0VXNlcjIBAG5vzH2pTasnxtSn8UqimbSHU1zcpCsv44b6jvAWJGBvySE5n9clVedgBFchzUI4c6NZgQ80996gsgXBzI7ZEQE=" + } +} + +19:50:45.360 [ws-worker-1] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +19:50:45.366 [ws-worker-1] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=0, newHash=3d719b9a231458859c388532165551969683e196c30dfc54b1deeeeb456604b6 +19:50:45.366 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-33","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"3d719b9a231458859c388532165551969683e196c30dfc54b1deeeeb456604b6","ok":true}} +19:50:45.372 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-34", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 6, + "prevBlockHash": "fdcf9bd40f11da4d294bab4b56269e28ef95c8a4c3f100f44eb581150c9dbc84", + "blockBytesB64": "AAD9z5vUDxHaTSlLq0tWJp4o75XIpMPxAPROtYEVDJ28hAAAAJYAAAAGAAAAAGm61+UAAwAeAAEAAAAAAAAAAH8+3GCB1rdlVJX5c2Z4JTELwo/grrTGLEeinm8TapokAAAAAQ1UZXN0VXNlcjItMDAxAAAAAD1xm5ojFFiFnDiFMhZVUZaWg+GWww38VLHe7utFZgS2AQA8eutQJEc/pzuFFw+n7P5EUrQ961LrBTYKfZBA0Gq2cbTGsdMwC9Jr6RV0ovbNXLd2jNfClahh3DgIaYM/CrIH" + } +} + +19:50:45.377 [ws-worker-2] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +19:50:45.382 [ws-worker-2] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=6, newHash=04c04bc1b77f119265dc23dd8177aa05be9a04610fc75c9d8bb65fa17efa79b1 +19:50:45.382 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-34","status":200,"payload":{"serverLastGlobalNumber":6,"serverLastGlobalHash":"04c04bc1b77f119265dc23dd8177aa05be9a04610fc75c9d8bb65fa17efa79b1","ok":true}} +19:50:45.384 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-35", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 1, + "prevBlockHash": "3d719b9a231458859c388532165551969683e196c30dfc54b1deeeeb456604b6", + "blockBytesB64": "AAA9cZuaIxRYhZw4hTIWVVGWloPhlsMN/FSx3u7rRWYEtgAAAJYAAAABAAAAAGm61+UAAwAeAAEAAAAAAAAAAD1xm5ojFFiFnDiFMhZVUZaWg+GWww38VLHe7utFZgS2AAAAAQ1UZXN0VXNlcjEtMDAxAAAAAggeUMtyJRybj9idH1kihA0BD1azrJAlQeHMsQJYBjHGAQD07JxLiXLXgoOtYdQgmx0GLkA+bD8vuPTiTx0XYY5F6abwZ2xRyX3o1fPvA56GzZ/KVlH33gLtleEKf3OWtQ4C" + } +} + +19:50:45.387 [ws-worker-3] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +19:50:45.392 [ws-worker-3] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=1, newHash=ee9bbcf630962956990a7457edb9738b20632dff7ff61964baaf8c0b9939bf5f +19:50:45.392 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-35","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"ee9bbcf630962956990a7457edb9738b20632dff7ff61964baaf8c0b9939bf5f","ok":true}} +19:50:45.394 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-36", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 7, + "prevBlockHash": "04c04bc1b77f119265dc23dd8177aa05be9a04610fc75c9d8bb65fa17efa79b1", + "blockBytesB64": "AAAEwEvBt38RkmXcI92Bd6oFvpoEYQ/HXJ2Ltl+hfvp5sQAAAJYAAAAHAAAAAGm61+UAAwAKAAEAAAAAAAAABgTAS8G3fxGSZdwj3YF3qgW+mgRhD8dcnYu2X6F++nmxAAAAAg1UZXN0VXNlcjItMDAxAAAAAD1xm5ojFFiFnDiFMhZVUZaWg+GWww38VLHe7utFZgS2AQAMiKuAnBBsaxsBS5LYNypci2TmaqPd0shshwGGECwrh+GYi/hPUsFeZ97S2GUMv2rh0ER6mArsRdz9FnRztlEG" + } +} + +19:50:45.399 [ws-worker-4] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=6 thisLineNumber=2 prevLineHashLen=32 +19:50:45.405 [ws-worker-4] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=7, newHash=e0a56e9edbb31b2b07b55ccd316db9d812edc07153060d0c15dd903044f5a7bd +19:50:45.405 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-36","status":200,"payload":{"serverLastGlobalNumber":7,"serverLastGlobalHash":"e0a56e9edbb31b2b07b55ccd316db9d812edc07153060d0c15dd903044f5a7bd","ok":true}} +19:50:45.407 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-37", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 2, + "prevBlockHash": "ee9bbcf630962956990a7457edb9738b20632dff7ff61964baaf8c0b9939bf5f", + "blockBytesB64": "AADum7z2MJYpVpkKdFftuXOLIGMt/3/2GWS6r4wLmTm/XwAAAJYAAAACAAAAAGm61+UAAwAKAAEAAAAAAAAAAe6bvPYwlilWmQp0V+25c4sgYy3/f/YZZLqvjAuZOb9fAAAAAg1UZXN0VXNlcjEtMDAxAAAAAH8+3GCB1rdlVJX5c2Z4JTELwo/grrTGLEeinm8TapokAQDZ6SjRexJ7JaJoyaBLL5CXXbmqX9HoT+7QjHaAXmtiebCzr6d5xBX/3sXwjET6fmroQLrb4yP5uyqmCSS4BsgD" + } +} + +19:50:45.412 [ws-worker-5] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=1 thisLineNumber=2 prevLineHashLen=32 +19:50:45.417 [ws-worker-5] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=2, newHash=e46ba9c82252f7ded81f73777e9d37ffb52139cb15c4bc6e2fc4311959a75ab7 +19:50:45.417 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-37","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"e46ba9c82252f7ded81f73777e9d37ffb52139cb15c4bc6e2fc4311959a75ab7","ok":true}} +19:50:45.419 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-38", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 8, + "prevBlockHash": "e0a56e9edbb31b2b07b55ccd316db9d812edc07153060d0c15dd903044f5a7bd", + "blockBytesB64": "AADgpW6e27MbKwe1XM0xbbnYEu3AcVMGDQwV3ZAwRPWnvQAAAJYAAAAIAAAAAGm61+UAAwAUAAEAAAAAAAAAB+Clbp7bsxsrB7VczTFtudgS7cBxUwYNDBXdkDBE9ae9AAAAAw1UZXN0VXNlcjItMDAxAAAAAD1xm5ojFFiFnDiFMhZVUZaWg+GWww38VLHe7utFZgS2AQCJ89QwcXBdefMbVVVLgqm0W2aXCJIpQUIiDueIR+CzCudKmNkjlwjSPMbr5zAWskJtZUUThaoMJc5BGk3BI0sJ" + } +} + +19:50:45.424 [ws-worker-6] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=7 thisLineNumber=3 prevLineHashLen=32 +19:50:45.429 [ws-worker-6] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=8, newHash=b55d33a568c225e3d340de66e7dd9aba62890cd1210d178117f10227964c0eb1 +19:50:45.430 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-38","status":200,"payload":{"serverLastGlobalNumber":8,"serverLastGlobalHash":"b55d33a568c225e3d340de66e7dd9aba62890cd1210d178117f10227964c0eb1","ok":true}} +19:50:45.432 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-39", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 3, + "prevBlockHash": "e46ba9c82252f7ded81f73777e9d37ffb52139cb15c4bc6e2fc4311959a75ab7", + "blockBytesB64": "AADka6nIIlL33tgfc3d+nTf/tSE5yxXEvG4vxDEZWadatwAAAJYAAAADAAAAAGm61+UAAwAUAAEAAAAAAAAAAuRrqcgiUvfe2B9zd36dN/+1ITnLFcS8bi/EMRlZp1q3AAAAAw1UZXN0VXNlcjEtMDAxAAAAAH8+3GCB1rdlVJX5c2Z4JTELwo/grrTGLEeinm8TapokAQBbe+ePvxRroc9UiAwoiuUcd8rxVwjfC7Vh3q1p5RrtrlWDJ6YI6ewJhqkCmAfuu1CfM1Qwx4aKoEtfzEgRAsUI" + } +} + +19:50:45.435 [ws-worker-7] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=2 thisLineNumber=3 prevLineHashLen=32 +19:50:45.440 [ws-worker-7] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=3, newHash=8cb8c973fbbf8efc4efe077da6114f1d73ebed663641d30d76bb360cb1dc36ad +19:50:45.441 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-39","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"8cb8c973fbbf8efc4efe077da6114f1d73ebed663641d30d76bb360cb1dc36ad","ok":true}} +19:50:45.443 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-40", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 4, + "prevBlockHash": "8cb8c973fbbf8efc4efe077da6114f1d73ebed663641d30d76bb360cb1dc36ad", + "blockBytesB64": "AACMuMlz+7+O/E7+B32mEU8dc+vtZjZB0w12uzYMsdw2rQAAAJYAAAAEAAAAAGm61+UAAQAUAAENVGVzdFVzZXIxLTAwMQAAAAOItuy/w4bw76+PpIHOZsaEDz9rS8/MhJ1ZOBmLuz1x7wAqVTI6IHJlcGx5IHRvIFUxIE5ld3MgcG9zdCAjMCAoY3Jvc3MtY2hhaW4pAQAc1YXEtD97qMRY2WAIimcBOHn44MLD84dPO1CPZiKuCtzAv4HT7lu9dtQaFWWpmx1jJndJFM+wNxJXPMxIW18H" + } +} + +19:50:45.446 [ws-worker-8] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=20 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +19:50:45.450 [ws-worker-8] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=4, newHash=89227edb9938ea148fa69dd2f8b8da06074e17b083d8f34cd5e136f02c781c58 +19:50:45.450 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-40","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"89227edb9938ea148fa69dd2f8b8da06074e17b083d8f34cd5e136f02c781c58","ok":true}} +19:50:45.452 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-41", + "payload": { + "blockchainName": "TestUser3-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm61+UAAAAAAAFTSGlOZQlUZXN0VXNlcjMBADNpNoHZaMvPb/KvQfakO5cfMSc8zVbYtS7O7Md2WinnVcdAY0+n67cTz0h4AOUHfRKC54jfbuCKR1KkwxTlqQI=" + } +} + +19:50:45.455 [ws-worker-9] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +19:50:45.459 [ws-worker-9] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser3, blockchainName=TestUser3-001, blockNumber=0, newHash=1b5b1a572b3d5c94319eef2e6916aa63b1a3357f4d648d8a7f8ad6d84b8b2168 +19:50:45.459 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-41","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"1b5b1a572b3d5c94319eef2e6916aa63b1a3357f4d648d8a7f8ad6d84b8b2168","ok":true}} +19:50:45.461 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-42", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 9, + "prevBlockHash": "b55d33a568c225e3d340de66e7dd9aba62890cd1210d178117f10227964c0eb1", + "blockBytesB64": "AAC1XTOlaMIl49NA3mbn3Zq6YokM0SENF4EX8QInlkwOsQAAAJYAAAAJAAAAAGm61+UAAwAUAAEAAAAAAAAACLVdM6VowiXj00DeZufdmrpiiQzRIQ0XgRfxAieWTA6xAAAABA1UZXN0VXNlcjMtMDAxAAAAABtbGlcrPVyUMZ7vLmkWqmOxozV/TWSNin+K1thLiyFoAQCiQSMDr+1YjBpf8gaYxW2ruMb9eN30e357u8uvQX+mw9u+4GbPwu+DLB06YdPX6TG4qkrdrKs5Pagvzepg/JMA" + } +} + +19:50:45.465 [ws-worker-10] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=8 thisLineNumber=4 prevLineHashLen=32 +19:50:45.470 [ws-worker-10] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=9, newHash=da3f018b5585a0ba093af8413e72330e676006137642bbc0887c16434e6ff986 +19:50:45.472 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-42","status":200,"payload":{"serverLastGlobalNumber":9,"serverLastGlobalHash":"da3f018b5585a0ba093af8413e72330e676006137642bbc0887c16434e6ff986","ok":true}} +19:50:45.474 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-43", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 10, + "prevBlockHash": "da3f018b5585a0ba093af8413e72330e676006137642bbc0887c16434e6ff986", + "blockBytesB64": "AADaPwGLVYWgugk6+EE+cjMOZ2AGE3ZCu8CIfBZDTm/5hgAAAJYAAAAKAAAAAGm61+UAAwAVAAEAAAAAAAAACdo/AYtVhaC6CTr4QT5yMw5nYAYTdkK7wIh8FkNOb/mGAAAABQ1UZXN0VXNlcjItMDAxAAAAAD1xm5ojFFiFnDiFMhZVUZaWg+GWww38VLHe7utFZgS2AQBbHDXPE4sB/kPUQLEA1Av8Ax9WC7GinQ4IJ+VYgbco3yTkqzv9N58F8UgGW/RZazI70KFWl/pHc8Btf3+Nl6cJ" + } +} + +19:50:45.480 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=21 lineCode=0 prevLineNumber=9 thisLineNumber=5 prevLineHashLen=32 +19:50:45.486 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=10, newHash=c99f38663a8f47eea9a8cb3d417a6e976b0390a5feff2d232c3aaf91a288cc9d +19:50:45.487 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-43","status":200,"payload":{"serverLastGlobalNumber":10,"serverLastGlobalHash":"c99f38663a8f47eea9a8cb3d417a6e976b0390a5feff2d232c3aaf91a288cc9d","ok":true}} +19:50:45.488 [qtp341972345-43] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.496 [qtp341972345-38] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49622 +19:50:45.498 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-44", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1773852645489, + "value": "Anna", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "OW6XmkXuWVv5G+woqEl+dBV9r1bLrogEX/xdoa1njWbCQwKnLp5DU1ttu7vqCUR26Nm7OBGyREn+A/aZfjeYDQ==" + } +} + +19:50:45.504 [ws-worker-12] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1773852645489 +19:50:45.509 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-44","status":200,"payload":{"ok":true}} +19:50:45.510 [qtp341972345-38] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.516 [qtp341972345-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49638 +19:50:45.517 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-45", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +19:50:45.522 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-45","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1773852645489,"value":"Anna","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"OW6XmkXuWVv5G+woqEl+dBV9r1bLrogEX/xdoa1njWbCQwKnLp5DU1ttu7vqCUR26Nm7OBGyREn+A/aZfjeYDQ==","ok":true}} +19:50:45.524 [qtp341972345-28] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.529 [qtp341972345-58] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49640 +19:50:45.531 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-46", + "payload": { + "login": "TestUser1", + "param": "profile:city", + "time_ms": 1773852645499, + "value": "Amsterdam", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "I+1K1lZsZaiSMO5ScflBlMZu42XYOeuAp8URsanjPaTfYbx8KlgVylGB5/OWZ4XlhJnYdpXM4PJRVrfPUmRIBg==" + } +} + +19:50:45.534 [ws-worker-14] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:city, time_ms=1773852645499 +19:50:45.537 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-46","status":200,"payload":{"ok":true}} +19:50:45.538 [qtp341972345-58] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.542 [qtp341972345-42] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49642 +19:50:45.543 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-47", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1773852645509, + "value": "Anna Updated", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "r1bb8R8Z8ZqLw005GkqRK6qrhfqPcZIqNzxzGML07fS2qkHQOmJu8kqymXYMNDRpam9xqA3CF6/iPkSPmAOYAw==" + } +} + +19:50:45.547 [ws-worker-15] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1773852645509 +19:50:45.550 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-47","status":200,"payload":{"ok":true}} +19:50:45.551 [qtp341972345-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.556 [qtp341972345-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49644 +19:50:45.558 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-48", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +19:50:45.560 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-48","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1773852645509,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"r1bb8R8Z8ZqLw005GkqRK6qrhfqPcZIqNzxzGML07fS2qkHQOmJu8kqymXYMNDRpam9xqA3CF6/iPkSPmAOYAw==","ok":true}} +19:50:45.561 [qtp341972345-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.567 [qtp341972345-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49652 +19:50:45.568 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListUserParams", + "requestId": "it-listparams-49", + "payload": { "login": "TestUser1" } +} + +19:50:45.574 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListUserParams","requestId":"it-listparams-49","status":200,"payload":{"login":"TestUser1","params":[{"login":"TestUser1","param":"profile:name","time_ms":1773852645509,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"r1bb8R8Z8ZqLw005GkqRK6qrhfqPcZIqNzxzGML07fS2qkHQOmJu8kqymXYMNDRpam9xqA3CF6/iPkSPmAOYAw=="},{"login":"TestUser1","param":"profile:city","time_ms":1773852645499,"value":"Amsterdam","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"I+1K1lZsZaiSMO5ScflBlMZu42XYOeuAp8URsanjPaTfYbx8KlgVylGB5/OWZ4XlhJnYdpXM4PJRVrfPUmRIBg=="}],"ok":true}} +19:50:45.576 [qtp341972345-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:45.581 [qtp341972345-30] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:49664 +19:50:45.583 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-50", + "payload": { + "login": "TestUser1" + } +} + +19:50:45.587 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-50","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +19:50:45.592 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-51", + "payload": { + "login": "Testuser1" + } +} + +19:50:45.593 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-51","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +19:50:45.595 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-52", + "payload": { + "login": "TestUser2" + } +} + +19:50:45.597 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-52","status":200,"payload":{"login":"TestUser2","out_friends":["TestUser1"],"in_friends":["TestUser1"],"ok":true}} +19:50:45.598 [qtp341972345-30] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:54.938 [wsServer-thread] INFO s.ws.BlockchainTmpRecoveryOnStartup - 🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется. +19:50:54.996 [wsServer-thread] INFO org.eclipse.jetty.server.Server - jetty-11.0.20; built: 2024-01-29T21:04:22.394Z; git: 922f8dc188f7011e60d0361de585fd4ac4d63064; jvm 17.0.18+8-Ubuntu-124.04.1 +19:50:55.044 [wsServer-thread] INFO o.e.j.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@2b7f2622{/,null,AVAILABLE} +19:50:55.050 [wsServer-thread] INFO o.e.jetty.server.AbstractConnector - Started ServerConnector@56ee516b{HTTP/1.1, (http/1.1)}{0.0.0.0:7070} +19:50:55.054 [wsServer-thread] INFO org.eclipse.jetty.server.Server - Started Server@3171580c{STARTING}[11.0.20,sto=0] @766ms +19:50:55.054 [wsServer-thread] INFO server.ws.WsServer - ✅ WS сервер запущен на ws://localhost:7070/ws +19:50:55.806 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56402 +19:50:55.834 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-1", + "payload": { + "login": "TestUser1", + "blockchainName": "TestUser1-001", + "solanaKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "blockchainKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "deviceKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "bchLimit": 50000000 + } +} + +19:50:55.884 [ws-worker-1] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser1, blockchainName=TestUser1-001, limit=50000000 +19:50:55.895 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-1","status":200,"payload":{"ok":true}} +19:50:55.899 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-2", + "payload": { + "login": "TestUser1" + } +} + +19:50:55.904 [ws-worker-2] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +19:50:55.906 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-2","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +19:50:55.909 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-3", + "payload": { + "login": "TestUser2", + "blockchainName": "TestUser2-001", + "solanaKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "blockchainKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "deviceKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "bchLimit": 50000000 + } +} + +19:50:55.914 [ws-worker-3] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser2, blockchainName=TestUser2-001, limit=50000000 +19:50:55.915 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-3","status":200,"payload":{"ok":true}} +19:50:55.916 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-4", + "payload": { + "login": "TestUser2" + } +} + +19:50:55.917 [ws-worker-4] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser2, blockchainName=TestUser2-001 +19:50:55.917 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-4","status":200,"payload":{"exists":true,"login":"TestUser2","blockchainName":"TestUser2-001","solanaKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","blockchainKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","deviceKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","ok":true}} +19:50:55.920 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-5", + "payload": { + "login": "TestUser3", + "blockchainName": "TestUser3-001", + "solanaKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "blockchainKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "deviceKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "bchLimit": 50000000 + } +} + +19:50:55.924 [ws-worker-5] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser3, blockchainName=TestUser3-001, limit=50000000 +19:50:55.924 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-5","status":200,"payload":{"ok":true}} +19:50:55.926 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-6", + "payload": { + "login": "TestUser3" + } +} + +19:50:55.927 [ws-worker-6] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser3, blockchainName=TestUser3-001 +19:50:55.928 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-6","status":200,"payload":{"exists":true,"login":"TestUser3","blockchainName":"TestUser3-001","solanaKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","blockchainKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","deviceKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","ok":true}} +19:50:55.931 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-7", + "payload": { + "login": "Testuser1" + } +} + +19:50:55.932 [ws-worker-7] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +19:50:55.933 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-7","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +19:50:55.934 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-8", + "payload": { + "login": "NoSuchUser_987654321" + } +} + +19:50:55.938 [ws-worker-8] INFO s.l.w.J.h.t.Net_GetUser_Handler - ℹ️ GetUser: not found for login=NoSuchUser_987654321 +19:50:55.938 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-8","status":200,"payload":{"exists":false,"ok":true}} +19:50:55.941 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SearchUsers", + "requestId": "it-searchusers-9", + "payload": { + "prefix": "Tes" + } +} + +19:50:55.946 [ws-worker-9] INFO s.l.w.J.h.t.Net_SearchUsers_Handler - ✅ SearchUsers ok: prefix='Tes' -> 3 +19:50:55.949 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SearchUsers","requestId":"it-searchusers-9","status":200,"payload":{"logins":["TestUser1","TestUser2","TestUser3"],"ok":true}} +19:50:55.954 [qtp1582446160-37] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:55.960 [qtp1582446160-26] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56410 +19:50:55.963 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-10", + "payload": { "login": "TestUser1" } +} + +19:50:55.967 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-10","status":200,"payload":{"authNonce":"1VKkHEQ8n+32/bIfNDnPlsHFwnob+0SxaQlfIB87F54","ok":true}} +19:50:55.975 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-11", + "payload": { + "storagePwd": "pwd-1561496456144", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773852655971, + "signatureB64": "SIW6hT/PoOMi1WbM+pkC4AHbzNFBOGzcyV4tME1ipEZiJ+GbjomGgp9beyUdBB9pX7bLFHGaVFxV+N0k0b2zDg==", + "clientInfo": "it-tests" + } +} + +19:50:55.988 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-11","status":200,"payload":{"sessionId":"TdKMlRxK6sZQdTmk/Zej+oLUZAag3D/DsF6ky+xjvKM","ok":true}} +19:50:55.990 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:55.993 [qtp1582446160-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56424 +19:50:55.995 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-12", + "payload": { "login": "TestUser1" } +} + +19:50:55.998 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-12","status":200,"payload":{"authNonce":"SKxkARLTgIbNSWnDiLCQnPvmevGdeY6Tnr1Gx1W4bv0","ok":true}} +19:50:56.000 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-13", + "payload": { + "storagePwd": "pwd-1561524531814", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773852655999, + "signatureB64": "JK3vVvPMtZCl3jRIOMN82lyT2MjPZxu44tS8fBQhMkjLSP9RrmfxXzihnAyae4kvf2tj4tM9fn/IcNfn5ASNAQ==", + "clientInfo": "it-tests" + } +} + +19:50:56.007 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-13","status":200,"payload":{"sessionId":"+E+LA1lX18sqoNHhOHbKdmypSUyhUwRJPF3IY2D1Hb8","ok":true}} +19:50:56.008 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.012 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56436 +19:50:56.014 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-14", + "payload": { "login": "TestUser1" } +} + +19:50:56.016 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-14","status":200,"payload":{"authNonce":"XGm4shpOLP0hkO9o0BTQSmn55Lwm7IiIKKfl2UeLWdc","ok":true}} +19:50:56.018 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-15", + "payload": { + "storagePwd": "pwd-1561542374783", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773852656016, + "signatureB64": "YTzj6ppdEwxJcK4c9s3S526+ubYGl+tlfIEXwmTu1DKyjW5FOASEVyU++BN9JB/EDIDNCdYjoyHyXF8iEK/GCw==", + "clientInfo": "it-tests" + } +} + +19:50:56.025 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-15","status":200,"payload":{"sessionId":"aSOHVAjq9tzp6nJJHKn8323jNpCKb/BsU//XXpY+Lak","ok":true}} +19:50:56.027 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.031 [qtp1582446160-30] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56442 +19:50:56.032 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-16", + "payload": { + "sessionId": "TdKMlRxK6sZQdTmk/Zej+oLUZAag3D/DsF6ky+xjvKM" + } +} + +19:50:56.038 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-16","status":200,"payload":{"nonce":"tig894FQos+GpFoRi+TlVSLFUkJf4DxKnrRESS6/s9c","ok":true}} +19:50:56.041 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-17", + "payload": { + "sessionId": "TdKMlRxK6sZQdTmk/Zej+oLUZAag3D/DsF6ky+xjvKM", + "timeMs": 1773852656040, + "signatureB64": "7LvQOB2ynnAJvKhuW3rPdfu6mZvUqjNhSzl6VU80vOrhQmAEvA5PnH8bsurKohnSoT9hxfics7Op6K5Rf8tKCQ==", + "clientInfo": "it-tests" + } +} + +19:50:56.050 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-17","status":200,"payload":{"storagePwd":"pwd-1561496456144","ok":true}} +19:50:56.052 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-18", + "payload": { + } +} + +19:50:56.059 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-18","status":200,"payload":{"sessions":[{"sessionId":"TdKMlRxK6sZQdTmk/Zej+oLUZAag3D/DsF6ky+xjvKM","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852656045},{"sessionId":"+E+LA1lX18sqoNHhOHbKdmypSUyhUwRJPF3IY2D1Hb8","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852656001},{"sessionId":"aSOHVAjq9tzp6nJJHKn8323jNpCKb/BsU//XXpY+Lak","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852656020}],"ok":true}} +19:50:56.061 [qtp1582446160-26] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.064 [qtp1582446160-31] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56446 +19:50:56.065 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-19", + "payload": { "login": "TestUser1" } +} + +19:50:56.067 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-19","status":200,"payload":{"authNonce":"HEqcPqZhwnpz/2s02N3OMdFH/AflLM2u6PbQHQ0NYPo","ok":true}} +19:50:56.069 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-20", + "payload": { + "storagePwd": "pwd-1561593617147", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773852656068, + "signatureB64": "CrjDINsQdwSwWK4FhxCg4cB99LslI8WbeXPkC4jowiz/+TN3PjJgekyCRbHgs/Dd+Yo29EFZXsKvciO4hW6EAg==", + "clientInfo": "it-tests" + } +} + +19:50:56.075 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-20","status":200,"payload":{"sessionId":"fRMWJdCvl+SAcNpbvYLJniSvfVdUnI5zwjNslSTI8Ng","ok":true}} +19:50:56.076 [qtp1582446160-26] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.080 [qtp1582446160-32] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56458 +19:50:56.081 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-21", + "payload": { + "sessionId": "TdKMlRxK6sZQdTmk/Zej+oLUZAag3D/DsF6ky+xjvKM" + } +} + +19:50:56.083 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-21","status":200,"payload":{"nonce":"DY3L7NYkqaxQqGf3S0psG38X2P6uxTbKEskvN4YhyPM","ok":true}} +19:50:56.085 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-22", + "payload": { + "sessionId": "TdKMlRxK6sZQdTmk/Zej+oLUZAag3D/DsF6ky+xjvKM", + "timeMs": 1773852656084, + "signatureB64": "f1SYf5Sk/w/DwYDR3hPSmmNngZCKal7rVuU6CXHm77H6m01loElJeQ9q0/DNxImKxAlaILggpb8qmmoxHV1tBw==", + "clientInfo": "it-tests" + } +} + +19:50:56.091 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-22","status":200,"payload":{"storagePwd":"pwd-1561496456144","ok":true}} +19:50:56.092 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CloseActiveSession", + "requestId": "it-close-23", + "payload": { + "sessionId": "fRMWJdCvl+SAcNpbvYLJniSvfVdUnI5zwjNslSTI8Ng" + } +} + +19:50:56.099 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CloseActiveSession","requestId":"it-close-23","status":200,"payload":{"ok":true}} +19:50:56.100 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.103 [qtp1582446160-25] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56470 +19:50:56.104 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-24", + "payload": { + "sessionId": "TdKMlRxK6sZQdTmk/Zej+oLUZAag3D/DsF6ky+xjvKM" + } +} + +19:50:56.105 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-24","status":200,"payload":{"nonce":"HDTPQYiObr9ky+OFbaIKrjjzBiapuxzMegq5ZtgsKGU","ok":true}} +19:50:56.106 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-25", + "payload": { + "sessionId": "TdKMlRxK6sZQdTmk/Zej+oLUZAag3D/DsF6ky+xjvKM", + "timeMs": 1773852656106, + "signatureB64": "S+Xt+VVSnj2ZX5rlPX2NObE8shj7c8d7vgGq+X26fEyHC4vEWtz4bVER5/xONMqy9XNI0BT9jZ3uXPeFBelOAg==", + "clientInfo": "it-tests" + } +} + +19:50:56.112 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-25","status":200,"payload":{"storagePwd":"pwd-1561496456144","ok":true}} +19:50:56.113 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-26", + "payload": { + } +} + +19:50:56.115 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-26","status":200,"payload":{"sessions":[{"sessionId":"TdKMlRxK6sZQdTmk/Zej+oLUZAag3D/DsF6ky+xjvKM","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852656108},{"sessionId":"+E+LA1lX18sqoNHhOHbKdmypSUyhUwRJPF3IY2D1Hb8","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852656001},{"sessionId":"aSOHVAjq9tzp6nJJHKn8323jNpCKb/BsU//XXpY+Lak","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773852656020}],"ok":true}} +19:50:56.116 [qtp1582446160-30] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.119 [qtp1582446160-42] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56476 +19:50:56.130 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-27", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm61/AAAAAAAAFTSGlOZQlUZXN0VXNlcjEBAPBQW1/Oq5s3AhVwxaNPkLuPn1hS1VVDODEI6P+rIIgR/zdD3p3aImYKQjxhbbDxkixyMMYbx2pvVvc2GpM5tww=" + } +} + +19:50:56.134 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +19:50:56.138 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=0, newHash=59a60815cef1b2ae3430b58c4c94625c6763ae795360eb8acca0763448974f7d +19:50:56.139 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-27","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"59a60815cef1b2ae3430b58c4c94625c6763ae795360eb8acca0763448974f7d","ok":true}} +19:50:56.143 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-28", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 1, + "prevBlockHash": "59a60815cef1b2ae3430b58c4c94625c6763ae795360eb8acca0763448974f7d", + "blockBytesB64": "AABZpggVzvGyrjQwtYxMlGJcZ2OueVNg64rMoHY0SJdPfQAAAIEAAAABAAAAAGm61/AAAQAKAAEAAAAAAAAAAFmmCBXO8bKuNDC1jEyUYlxnY655U2DrisygdjRIl099AAAAAAAbVTE6IHN0b3J5L3Bvc3QgaW4gY2hhbm5lbCAwAQBrNmwubu2vyvxdV9aFH041qn9kbdnt5e8UA1s42R7Jl8IrgKJgsYkKM4xc21CxJSNYaht8GNzKKTkt8VMXu1QG" + } +} + +19:50:56.146 [ws-worker-12] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=0 prevLineNumber=0 thisLineNumber=0 prevLineHashLen=32 +19:50:56.150 [ws-worker-12] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=1, newHash=30e09879c810de976bbaa44f487172bf2f6de2e1ea9c2b7bd498efe386ac3b59 +19:50:56.150 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-28","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"30e09879c810de976bbaa44f487172bf2f6de2e1ea9c2b7bd498efe386ac3b59","ok":true}} +19:50:56.152 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-29", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 2, + "prevBlockHash": "30e09879c810de976bbaa44f487172bf2f6de2e1ea9c2b7bd498efe386ac3b59", + "blockBytesB64": "AAAw4Jh5yBDel2u6pE9IcXK/L23i4eqcK3vUmO/jhqw7WQAAAGkAAAACAAAAAGm61/AAAAABAAEAAAAAAAAAAFmmCBXO8bKuNDC1jEyUYlxnY655U2DrisygdjRIl099AAAAAQROZXdzAQABoj0RWksPQPwsstm/QneWEzL9Fv9m0u6OqZ8hFiE/zKXQWlSLuPvDsKBR8QXa7xQReNOHIPgFHZy+brry+7AC" + } +} + +19:50:56.156 [ws-worker-13] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=1 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +19:50:56.160 [ws-worker-13] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=2, newHash=426d50df0a32b8f893ab6c8163873f5bd8783fa58eb988ea32e90fc1d8602579 +19:50:56.160 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-29","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"426d50df0a32b8f893ab6c8163873f5bd8783fa58eb988ea32e90fc1d8602579","ok":true}} +19:50:56.162 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-30", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 3, + "prevBlockHash": "426d50df0a32b8f893ab6c8163873f5bd8783fa58eb988ea32e90fc1d8602579", + "blockBytesB64": "AABCbVDfCjK4+JOrbIFjhz9b2Hg/pY65iOoy6Q/B2GAleQAAAHYAAAADAAAAAGm61/AAAQAKAAEAAAACAAAAAkJtUN8KMrj4k6tsgWOHP1vYeD+ljrmI6jLpD8HYYCV5AAAAAAAQVTE6IE5ld3MgcG9zdCAjMAEAfDYm9CT4EGKhYj81dnxVOozrJapkZLr6OySBtgtNlYhchWuWyAf/ndnesWp5w5Gsw+ZGxxqg8JQhtKaf1qC5DQ==" + } +} + +19:50:56.167 [ws-worker-14] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=2 thisLineNumber=0 prevLineHashLen=32 +19:50:56.171 [ws-worker-14] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=3, newHash=3a6cb60e2bfeb001d544a68564c62ee480bf2c62e08d62926e0ce89fca4957b0 +19:50:56.171 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-30","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"3a6cb60e2bfeb001d544a68564c62ee480bf2c62e08d62926e0ce89fca4957b0","ok":true}} +19:50:56.173 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-31", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 4, + "prevBlockHash": "3a6cb60e2bfeb001d544a68564c62ee480bf2c62e08d62926e0ce89fca4957b0", + "blockBytesB64": "AAA6bLYOK/6wAdVEpoVkxi7kgL8sYuCNYpJuDOifyklXsAAAAHYAAAAEAAAAAGm61/AAAQAKAAEAAAACAAAAAzpstg4r/rAB1USmhWTGLuSAvyxi4I1ikm4M6J/KSVewAAAAAQAQVTE6IE5ld3MgcG9zdCAjMQEAa2I9XJAnMu6DsAw8jtabte/yWY1/gxkoZyGQyrnaXZIc2HKyKZctzmpJmf1j0a+MhyOmLVKDh+o+2zhc0+ZoAQ==" + } +} + +19:50:56.177 [ws-worker-15] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=3 thisLineNumber=1 prevLineHashLen=32 +19:50:56.181 [ws-worker-15] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=4, newHash=19880937218627be0e363b2777e9806a11f88207e075cef525c94f5fafec9dd8 +19:50:56.181 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-31","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"19880937218627be0e363b2777e9806a11f88207e075cef525c94f5fafec9dd8","ok":true}} +19:50:56.183 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-32", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 5, + "prevBlockHash": "19880937218627be0e363b2777e9806a11f88207e075cef525c94f5fafec9dd8", + "blockBytesB64": "AAAZiAk3IYYnvg42Oyd36YBqEfiCB+B1zvUlyU9fr+yd2AAAAKEAAAAFAAAAAGm61/AAAQALAAEAAAACAAAABBmICTchhie+DjY7J3fpgGoR+IIH4HXO9SXJT1+v7J3YAAAAAgAAAAM6bLYOK/6wAdVEpoVkxi7kgL8sYuCNYpJuDOifyklXsAAXVTE6IE5ld3MgcG9zdCAjMCAoRURJVCkBAAvnk2gD+CI2Bf/S5T1tXv/aN9MxlY0SWQUgnsEqiiXc2OwIh1AyaJ16lVJ0vb5Rot2tJcfY5dnAxnKXAQNa+Qk=" + } +} + +19:50:56.185 [ws-worker-16] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=11 lineCode=2 prevLineNumber=4 thisLineNumber=2 prevLineHashLen=32 +19:50:56.189 [ws-worker-16] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=5, newHash=af0e59f3867349bc9bb0d88eb5b2b81c69625ae3776eec165fa751cbef134f00 +19:50:56.189 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-32","status":200,"payload":{"serverLastGlobalNumber":5,"serverLastGlobalHash":"af0e59f3867349bc9bb0d88eb5b2b81c69625ae3776eec165fa751cbef134f00","ok":true}} +19:50:56.191 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-33", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm61/AAAAAAAAFTSGlOZQlUZXN0VXNlcjIBANhRCWAKugrXoQwcWU821VDAg2U96Si9r3hx5irscfyN/N70vJJhkigC955nD9cWwoWSkEHvK2BZHzr9GpiNuAY=" + } +} + +19:50:56.193 [ws-worker-1] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +19:50:56.196 [ws-worker-1] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=0, newHash=1f9c4b8ae39fa0b21fe15187a0a5655ddbbda995f187a7fd704e3caf4ceb74d6 +19:50:56.197 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-33","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"1f9c4b8ae39fa0b21fe15187a0a5655ddbbda995f187a7fd704e3caf4ceb74d6","ok":true}} +19:50:56.201 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-34", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 6, + "prevBlockHash": "af0e59f3867349bc9bb0d88eb5b2b81c69625ae3776eec165fa751cbef134f00", + "blockBytesB64": "AACvDlnzhnNJvJuw2I61srgcaWJa43du7BZfp1HL7xNPAAAAAJYAAAAGAAAAAGm61/AAAwAeAAEAAAAAAAAAAFmmCBXO8bKuNDC1jEyUYlxnY655U2DrisygdjRIl099AAAAAQ1UZXN0VXNlcjItMDAxAAAAAB+cS4rjn6CyH+FRh6ClZV3bvamV8Yen/XBOPK9M63TWAQD8tAwtHMnZeLy3SRlkvch66YGTpCJbJpIkjazVTdup//T1VmwTJuLmXO08g7Mod1CrlA4oDKv5CXps86tGnqcI" + } +} + +19:50:56.205 [ws-worker-2] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +19:50:56.210 [ws-worker-2] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=6, newHash=401ad9dc2b51f8c1977bdc9d4591b6d49d6c1d9fe33267c248db6ce57c6d2d15 +19:50:56.210 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-34","status":200,"payload":{"serverLastGlobalNumber":6,"serverLastGlobalHash":"401ad9dc2b51f8c1977bdc9d4591b6d49d6c1d9fe33267c248db6ce57c6d2d15","ok":true}} +19:50:56.212 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-35", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 1, + "prevBlockHash": "1f9c4b8ae39fa0b21fe15187a0a5655ddbbda995f187a7fd704e3caf4ceb74d6", + "blockBytesB64": "AAAfnEuK45+gsh/hUYegpWVd272plfGHp/1wTjyvTOt01gAAAJYAAAABAAAAAGm61/AAAwAeAAEAAAAAAAAAAB+cS4rjn6CyH+FRh6ClZV3bvamV8Yen/XBOPK9M63TWAAAAAQ1UZXN0VXNlcjEtMDAxAAAAAkJtUN8KMrj4k6tsgWOHP1vYeD+ljrmI6jLpD8HYYCV5AQDIJqgT1fT4+j3ZxgFaigE7ABtClx3/tJEh68nOE8D4fua7J2ox4qg1zEb/11gtJYYDkii6Az2qm2EYBVnUgwoB" + } +} + +19:50:56.214 [ws-worker-3] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +19:50:56.219 [ws-worker-3] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=1, newHash=4cbc32616dc1760746990e9f5a82fef9609c758d4469f6bd59dccbec8d1703c3 +19:50:56.219 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-35","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"4cbc32616dc1760746990e9f5a82fef9609c758d4469f6bd59dccbec8d1703c3","ok":true}} +19:50:56.220 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-36", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 7, + "prevBlockHash": "401ad9dc2b51f8c1977bdc9d4591b6d49d6c1d9fe33267c248db6ce57c6d2d15", + "blockBytesB64": "AABAGtncK1H4wZd73J1FkbbUnWwdn+MyZ8JI22zlfG0tFQAAAJYAAAAHAAAAAGm61/AAAwAKAAEAAAAAAAAABkAa2dwrUfjBl3vcnUWRttSdbB2f4zJnwkjbbOV8bS0VAAAAAg1UZXN0VXNlcjItMDAxAAAAAB+cS4rjn6CyH+FRh6ClZV3bvamV8Yen/XBOPK9M63TWAQDd4/dCQKjbT1K0bwf3dpVrxqJ1gcZPPBv1jXDYI46ou2E9u9JYb3+PyBmbd07NeTVBywrfBCp1bslxJhh6yisG" + } +} + +19:50:56.225 [ws-worker-4] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=6 thisLineNumber=2 prevLineHashLen=32 +19:50:56.230 [ws-worker-4] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=7, newHash=3643c4e7602cbdd1883896562fb19d3c78153c6966f39cc243b8cbe743307b75 +19:50:56.231 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-36","status":200,"payload":{"serverLastGlobalNumber":7,"serverLastGlobalHash":"3643c4e7602cbdd1883896562fb19d3c78153c6966f39cc243b8cbe743307b75","ok":true}} +19:50:56.232 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-37", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 2, + "prevBlockHash": "4cbc32616dc1760746990e9f5a82fef9609c758d4469f6bd59dccbec8d1703c3", + "blockBytesB64": "AABMvDJhbcF2B0aZDp9agv75YJx1jURp9r1Z3MvsjRcDwwAAAJYAAAACAAAAAGm61/AAAwAKAAEAAAAAAAAAAUy8MmFtwXYHRpkOn1qC/vlgnHWNRGn2vVncy+yNFwPDAAAAAg1UZXN0VXNlcjEtMDAxAAAAAFmmCBXO8bKuNDC1jEyUYlxnY655U2DrisygdjRIl099AQDI5NhZqKj0KKq7s33Oc/GOUPuEVO1cNS5HvrfGwFFIZQfNQUlcFDwo3CDVBandL0RHiVO9KfQkZKtMLv7omU4E" + } +} + +19:50:56.235 [ws-worker-5] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=1 thisLineNumber=2 prevLineHashLen=32 +19:50:56.240 [ws-worker-5] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=2, newHash=e67b3e4744f422c6c6d2a3bca793ec252eb8178ba2ed6bce502e74a4c0526d3c +19:50:56.240 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-37","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"e67b3e4744f422c6c6d2a3bca793ec252eb8178ba2ed6bce502e74a4c0526d3c","ok":true}} +19:50:56.241 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-38", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 8, + "prevBlockHash": "3643c4e7602cbdd1883896562fb19d3c78153c6966f39cc243b8cbe743307b75", + "blockBytesB64": "AAA2Q8TnYCy90Yg4llYvsZ08eBU8aWbznMJDuMvnQzB7dQAAAJYAAAAIAAAAAGm61/AAAwAUAAEAAAAAAAAABzZDxOdgLL3RiDiWVi+xnTx4FTxpZvOcwkO4y+dDMHt1AAAAAw1UZXN0VXNlcjItMDAxAAAAAB+cS4rjn6CyH+FRh6ClZV3bvamV8Yen/XBOPK9M63TWAQAjACwOB1sNz4Li/YRA9eVzZlIhK1yXBTW+HloMfP/fq6VxGLUcSNcNpFVQfRDNxqjavr78u7hzoWtET/k3c90L" + } +} + +19:50:56.244 [ws-worker-6] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=7 thisLineNumber=3 prevLineHashLen=32 +19:50:56.249 [ws-worker-6] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=8, newHash=4697759870b65758d327a88c5abb276d6070c4ff6b4f9e32ece019aeb7e111f2 +19:50:56.249 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-38","status":200,"payload":{"serverLastGlobalNumber":8,"serverLastGlobalHash":"4697759870b65758d327a88c5abb276d6070c4ff6b4f9e32ece019aeb7e111f2","ok":true}} +19:50:56.251 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-39", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 3, + "prevBlockHash": "e67b3e4744f422c6c6d2a3bca793ec252eb8178ba2ed6bce502e74a4c0526d3c", + "blockBytesB64": "AADmez5HRPQixsbSo7ynk+wlLrgXi6Lta85QLnSkwFJtPAAAAJYAAAADAAAAAGm61/AAAwAUAAEAAAAAAAAAAuZ7PkdE9CLGxtKjvKeT7CUuuBeLou1rzlAudKTAUm08AAAAAw1UZXN0VXNlcjEtMDAxAAAAAFmmCBXO8bKuNDC1jEyUYlxnY655U2DrisygdjRIl099AQCdSKrQxe0sq/H/uEoMsd94syx5mbYeuxg2OFaAy2YENnExx+D63/r0otn+Z+qiy3wfjEypipVm5LLrz/7z0q0C" + } +} + +19:50:56.254 [ws-worker-7] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=2 thisLineNumber=3 prevLineHashLen=32 +19:50:56.259 [ws-worker-7] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=3, newHash=0cf2cafaac646999f5812035e7facff61a31daa0bfdb61ddd04a7af752a20965 +19:50:56.259 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-39","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"0cf2cafaac646999f5812035e7facff61a31daa0bfdb61ddd04a7af752a20965","ok":true}} +19:50:56.262 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-40", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 4, + "prevBlockHash": "0cf2cafaac646999f5812035e7facff61a31daa0bfdb61ddd04a7af752a20965", + "blockBytesB64": "AAAM8sr6rGRpmfWBIDXn+s/2GjHaoL/bYd3QSnr3UqIJZQAAAJYAAAAEAAAAAGm61/AAAQAUAAENVGVzdFVzZXIxLTAwMQAAAAM6bLYOK/6wAdVEpoVkxi7kgL8sYuCNYpJuDOifyklXsAAqVTI6IHJlcGx5IHRvIFUxIE5ld3MgcG9zdCAjMCAoY3Jvc3MtY2hhaW4pAQDW+riA6uBA4VqymW7NpnDwO8jG4RVlc7Asj6GgWVY0dD8NoTY8MxYEWuR6pgVpcbUVp6p+KxiIIdCkj1M/UmkJ" + } +} + +19:50:56.265 [ws-worker-8] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=20 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +19:50:56.268 [ws-worker-8] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=4, newHash=a85ebca5a4d1491704fcd09cd40ae34eba090b5c21a5f4db28480a1895ced0fe +19:50:56.269 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-40","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"a85ebca5a4d1491704fcd09cd40ae34eba090b5c21a5f4db28480a1895ced0fe","ok":true}} +19:50:56.270 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-41", + "payload": { + "blockchainName": "TestUser3-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm61/AAAAAAAAFTSGlOZQlUZXN0VXNlcjMBAHKyHu3y8867ERkfBKikcgE7hNiko/RPTLAetaDtAFdnr2iFiE5utbqxlPJQA1zG9qoYqSuEVNjOJ7eBDv87pQI=" + } +} + +19:50:56.272 [ws-worker-9] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +19:50:56.277 [ws-worker-9] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser3, blockchainName=TestUser3-001, blockNumber=0, newHash=c9309a758fd36eaeab81b59333e91a9a05741ad1529ae81e3a278bc8b8328a59 +19:50:56.278 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-41","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"c9309a758fd36eaeab81b59333e91a9a05741ad1529ae81e3a278bc8b8328a59","ok":true}} +19:50:56.280 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-42", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 9, + "prevBlockHash": "4697759870b65758d327a88c5abb276d6070c4ff6b4f9e32ece019aeb7e111f2", + "blockBytesB64": "AABGl3WYcLZXWNMnqIxauydtYHDE/2tPnjLs4Bmut+ER8gAAAJYAAAAJAAAAAGm61/AAAwAUAAEAAAAAAAAACEaXdZhwtldY0yeojFq7J21gcMT/a0+eMuzgGa634RHyAAAABA1UZXN0VXNlcjMtMDAxAAAAAMkwmnWP026uq4G1kzPpGpoFdBrRUproHjoni8i4MopZAQCuydaccgr+tCKgUtg7JdGATOfpp3jjWUHV78RKkwGM9aHUVX1xVE5GrGNw9ShtzVi9yolZrTZZxvM7xk/VElIK" + } +} + +19:50:56.285 [ws-worker-10] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=8 thisLineNumber=4 prevLineHashLen=32 +19:50:56.289 [ws-worker-10] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=9, newHash=2633e67844d24dc028944bea2191782976a7da354f817d8c2d8e53b65084e364 +19:50:56.290 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-42","status":200,"payload":{"serverLastGlobalNumber":9,"serverLastGlobalHash":"2633e67844d24dc028944bea2191782976a7da354f817d8c2d8e53b65084e364","ok":true}} +19:50:56.291 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-43", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 10, + "prevBlockHash": "2633e67844d24dc028944bea2191782976a7da354f817d8c2d8e53b65084e364", + "blockBytesB64": "AAAmM+Z4RNJNwCiUS+ohkXgpdqfaNU+BfYwtjlO2UITjZAAAAJYAAAAKAAAAAGm61/AAAwAVAAEAAAAAAAAACSYz5nhE0k3AKJRL6iGReCl2p9o1T4F9jC2OU7ZQhONkAAAABQ1UZXN0VXNlcjItMDAxAAAAAB+cS4rjn6CyH+FRh6ClZV3bvamV8Yen/XBOPK9M63TWAQAQXUCyi/p3y72IvGiA4Whjyn38C2bLdDmXv9IPeWHeA9N5GTPIxv+1FY7WswwOUQQHXQT1UN2F8fVjS0VdwgQP" + } +} + +19:50:56.295 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=21 lineCode=0 prevLineNumber=9 thisLineNumber=5 prevLineHashLen=32 +19:50:56.300 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=10, newHash=a185cbfed7d38bd93430792e35da84a06f5b3f490e5db626bf69962c9c94218a +19:50:56.301 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-43","status":200,"payload":{"serverLastGlobalNumber":10,"serverLastGlobalHash":"a185cbfed7d38bd93430792e35da84a06f5b3f490e5db626bf69962c9c94218a","ok":true}} +19:50:56.302 [qtp1582446160-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.308 [qtp1582446160-37] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56484 +19:50:56.310 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-44", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1773852656302, + "value": "Anna", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "YEwwP8LeJCRumLWz71hsSoIgy6DT0HbTIZVtnm6Fzyohmg2tuV06AHPaaUj4EEXgOLUBBoF+lmQ0fkBP2ggFDg==" + } +} + +19:50:56.315 [ws-worker-12] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1773852656302 +19:50:56.319 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-44","status":200,"payload":{"ok":true}} +19:50:56.321 [qtp1582446160-37] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.323 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56488 +19:50:56.324 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-45", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +19:50:56.329 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-45","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1773852656302,"value":"Anna","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"YEwwP8LeJCRumLWz71hsSoIgy6DT0HbTIZVtnm6Fzyohmg2tuV06AHPaaUj4EEXgOLUBBoF+lmQ0fkBP2ggFDg==","ok":true}} +19:50:56.331 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.334 [qtp1582446160-57] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56500 +19:50:56.335 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-46", + "payload": { + "login": "TestUser1", + "param": "profile:city", + "time_ms": 1773852656312, + "value": "Amsterdam", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "XuuU9rHiiDu7YQYDRoa0fBXeqjOiCpC0KEtb8Lnm3BCspzFgHoRKWRweDZNbHfI8s2SeQI6lA0QhquWE/CP4BA==" + } +} + +19:50:56.337 [ws-worker-14] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:city, time_ms=1773852656312 +19:50:56.339 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-46","status":200,"payload":{"ok":true}} +19:50:56.340 [qtp1582446160-57] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.343 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56512 +19:50:56.344 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-47", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1773852656322, + "value": "Anna Updated", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "mK60/dI/E6T1PoMgrIP8ekEwcCKSXr6QeAVwIca+rTniFu9MMirNMJixi7MFQ3del0lt6Ke87nt6rTMGCnBACg==" + } +} + +19:50:56.347 [ws-worker-15] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1773852656322 +19:50:56.350 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-47","status":200,"payload":{"ok":true}} +19:50:56.351 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.354 [qtp1582446160-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56526 +19:50:56.354 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-48", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +19:50:56.356 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-48","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1773852656322,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"mK60/dI/E6T1PoMgrIP8ekEwcCKSXr6QeAVwIca+rTniFu9MMirNMJixi7MFQ3del0lt6Ke87nt6rTMGCnBACg==","ok":true}} +19:50:56.357 [qtp1582446160-28] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.359 [qtp1582446160-26] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56532 +19:50:56.360 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListUserParams", + "requestId": "it-listparams-49", + "payload": { "login": "TestUser1" } +} + +19:50:56.363 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListUserParams","requestId":"it-listparams-49","status":200,"payload":{"login":"TestUser1","params":[{"login":"TestUser1","param":"profile:name","time_ms":1773852656322,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"mK60/dI/E6T1PoMgrIP8ekEwcCKSXr6QeAVwIca+rTniFu9MMirNMJixi7MFQ3del0lt6Ke87nt6rTMGCnBACg=="},{"login":"TestUser1","param":"profile:city","time_ms":1773852656312,"value":"Amsterdam","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"XuuU9rHiiDu7YQYDRoa0fBXeqjOiCpC0KEtb8Lnm3BCspzFgHoRKWRweDZNbHfI8s2SeQI6lA0QhquWE/CP4BA=="}],"ok":true}} +19:50:56.364 [qtp1582446160-26] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +19:50:56.367 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:56542 +19:50:56.368 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-50", + "payload": { + "login": "TestUser1" + } +} + +19:50:56.371 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-50","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +19:50:56.372 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-51", + "payload": { + "login": "Testuser1" + } +} + +19:50:56.374 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-51","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +19:50:56.375 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-52", + "payload": { + "login": "TestUser2" + } +} + +19:50:56.378 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-52","status":200,"payload":{"login":"TestUser2","out_friends":["TestUser1"],"in_friends":["TestUser1"],"ok":true}} +19:50:56.380 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:06.372 [wsServer-thread] INFO s.ws.BlockchainTmpRecoveryOnStartup - 🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется. +20:03:06.416 [wsServer-thread] INFO org.eclipse.jetty.server.Server - jetty-11.0.20; built: 2024-01-29T21:04:22.394Z; git: 922f8dc188f7011e60d0361de585fd4ac4d63064; jvm 17.0.18+8-Ubuntu-124.04.1 +20:03:06.463 [wsServer-thread] INFO o.e.j.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@2b7f2622{/,null,AVAILABLE} +20:03:06.469 [wsServer-thread] INFO o.e.jetty.server.AbstractConnector - Started ServerConnector@56ee516b{HTTP/1.1, (http/1.1)}{0.0.0.0:7070} +20:03:06.473 [wsServer-thread] INFO org.eclipse.jetty.server.Server - Started Server@3171580c{STARTING}[11.0.20,sto=0] @722ms +20:03:06.473 [wsServer-thread] INFO server.ws.WsServer - ✅ WS сервер запущен на ws://localhost:7070/ws +20:03:07.292 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:43900 +20:03:07.321 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-1", + "payload": { + "login": "TestUser1", + "blockchainName": "TestUser1-001", + "solanaKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "blockchainKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "deviceKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "bchLimit": 50000000 + } +} + +20:03:07.383 [ws-worker-1] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser1, blockchainName=TestUser1-001, limit=50000000 +20:03:07.397 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-1","status":200,"payload":{"ok":true}} +20:03:07.402 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-2", + "payload": { + "login": "TestUser1" + } +} + +20:03:07.406 [ws-worker-2] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +20:03:07.409 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-2","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +20:03:07.413 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-3", + "payload": { + "login": "TestUser2", + "blockchainName": "TestUser2-001", + "solanaKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "blockchainKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "deviceKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "bchLimit": 50000000 + } +} + +20:03:07.418 [ws-worker-3] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser2, blockchainName=TestUser2-001, limit=50000000 +20:03:07.419 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-3","status":200,"payload":{"ok":true}} +20:03:07.420 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-4", + "payload": { + "login": "TestUser2" + } +} + +20:03:07.422 [ws-worker-4] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser2, blockchainName=TestUser2-001 +20:03:07.422 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-4","status":200,"payload":{"exists":true,"login":"TestUser2","blockchainName":"TestUser2-001","solanaKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","blockchainKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","deviceKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","ok":true}} +20:03:07.426 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-5", + "payload": { + "login": "TestUser3", + "blockchainName": "TestUser3-001", + "solanaKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "blockchainKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "deviceKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "bchLimit": 50000000 + } +} + +20:03:07.432 [ws-worker-5] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser3, blockchainName=TestUser3-001, limit=50000000 +20:03:07.432 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-5","status":200,"payload":{"ok":true}} +20:03:07.434 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-6", + "payload": { + "login": "TestUser3" + } +} + +20:03:07.436 [ws-worker-6] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser3, blockchainName=TestUser3-001 +20:03:07.436 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-6","status":200,"payload":{"exists":true,"login":"TestUser3","blockchainName":"TestUser3-001","solanaKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","blockchainKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","deviceKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","ok":true}} +20:03:07.441 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-7", + "payload": { + "login": "Testuser1" + } +} + +20:03:07.442 [ws-worker-7] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +20:03:07.443 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-7","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +20:03:07.445 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-8", + "payload": { + "login": "NoSuchUser_987654321" + } +} + +20:03:07.448 [ws-worker-8] INFO s.l.w.J.h.t.Net_GetUser_Handler - ℹ️ GetUser: not found for login=NoSuchUser_987654321 +20:03:07.448 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-8","status":200,"payload":{"exists":false,"ok":true}} +20:03:07.451 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SearchUsers", + "requestId": "it-searchusers-9", + "payload": { + "prefix": "Tes" + } +} + +20:03:07.454 [ws-worker-9] INFO s.l.w.J.h.t.Net_SearchUsers_Handler - ✅ SearchUsers ok: prefix='Tes' -> 3 +20:03:07.457 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SearchUsers","requestId":"it-searchusers-9","status":200,"payload":{"logins":["TestUser1","TestUser2","TestUser3"],"ok":true}} +20:03:07.464 [qtp1582446160-37] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.467 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:43912 +20:03:07.469 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-10", + "payload": { "login": "TestUser1" } +} + +20:03:07.474 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-10","status":200,"payload":{"authNonce":"sSuymbw7nDzzKPjkrbvq9OIsPHTCzY2fVRrkhDqFW5c","ok":true}} +20:03:07.484 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-11", + "payload": { + "storagePwd": "pwd-2292340076703", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773853387479, + "signatureB64": "5vW5sYpz03DvfvjjybWGUDj7RfJRUiK910qaU+T0uIUXB0FQypj2rfrazTm8d4vuE7HnP3BDEDstyqKQaUoaAA==", + "clientInfo": "it-tests" + } +} + +20:03:07.499 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-11","status":200,"payload":{"sessionId":"m9AKXbbaru+SuoW1Y8XRBnLnMC8/wahapgrkJ9/B5uQ","ok":true}} +20:03:07.501 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.503 [qtp1582446160-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:43928 +20:03:07.504 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-12", + "payload": { "login": "TestUser1" } +} + +20:03:07.506 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-12","status":200,"payload":{"authNonce":"u20qOyJllqsoZW5+IWJ5Hl1QkSQN75l60od1sMrRmac","ok":true}} +20:03:07.508 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-13", + "payload": { + "storagePwd": "pwd-2292368793590", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773853387507, + "signatureB64": "1xnPh6e8C6zSB71Iy7kfPxJvcAQ4V6pV9++4gdKwUguMUHlmOi10vl3zNk9sJ/dXwTuX8jLxO7RTz+UoKvSuDQ==", + "clientInfo": "it-tests" + } +} + +20:03:07.513 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-13","status":200,"payload":{"sessionId":"/y+FLlx7puXqypi/ZG7X/Cw5jPJjQQW6IgnH8RLpxrY","ok":true}} +20:03:07.515 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.518 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:43940 +20:03:07.519 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-14", + "payload": { "login": "TestUser1" } +} + +20:03:07.522 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-14","status":200,"payload":{"authNonce":"VUifLSSNr7VzzgIEC6TmaB1vQwIw7j+JkqvAj196o5w","ok":true}} +20:03:07.523 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-15", + "payload": { + "storagePwd": "pwd-2292384075619", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773853387522, + "signatureB64": "U5yubgoD8aztttjB2MoKOgyXc0OiZRYd5CLPf+5VblKNMCFFGoZq8ptbrqMQQtK92cgU7V10/U/ZJ0EjwhNECg==", + "clientInfo": "it-tests" + } +} + +20:03:07.530 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-15","status":200,"payload":{"sessionId":"FMGRbmx9bFk4Hbt+lZmCqioGYqI6YWUFN/GCVKTTWDs","ok":true}} +20:03:07.531 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.534 [qtp1582446160-30] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:43946 +20:03:07.536 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-16", + "payload": { + "sessionId": "m9AKXbbaru+SuoW1Y8XRBnLnMC8/wahapgrkJ9/B5uQ" + } +} + +20:03:07.541 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-16","status":200,"payload":{"nonce":"spjb1JqHiEmT8rQ0tSsRKmU5wT6hgfvtcEt7B74CXZo","ok":true}} +20:03:07.545 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-17", + "payload": { + "sessionId": "m9AKXbbaru+SuoW1Y8XRBnLnMC8/wahapgrkJ9/B5uQ", + "timeMs": 1773853387543, + "signatureB64": "sRkFWbDfBYEjg81elF0REtPwFjNnSwdnKv7jxOGg4VgbGTj2jD9v1YaOMJUuKHwMHS270j/rRE550nCywFMMBw==", + "clientInfo": "it-tests" + } +} + +20:03:07.553 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-17","status":200,"payload":{"storagePwd":"pwd-2292340076703","ok":true}} +20:03:07.555 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-18", + "payload": { + } +} + +20:03:07.561 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-18","status":200,"payload":{"sessions":[{"sessionId":"m9AKXbbaru+SuoW1Y8XRBnLnMC8/wahapgrkJ9/B5uQ","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773853387549},{"sessionId":"/y+FLlx7puXqypi/ZG7X/Cw5jPJjQQW6IgnH8RLpxrY","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773853387509},{"sessionId":"FMGRbmx9bFk4Hbt+lZmCqioGYqI6YWUFN/GCVKTTWDs","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773853387525}],"ok":true}} +20:03:07.563 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.566 [qtp1582446160-31] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:43948 +20:03:07.567 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-19", + "payload": { "login": "TestUser1" } +} + +20:03:07.569 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-19","status":200,"payload":{"authNonce":"PiAdHZ2A009XEjNyh2vwa+1193seaS6VCCrjYgovBrE","ok":true}} +20:03:07.570 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-20", + "payload": { + "storagePwd": "pwd-2292431150330", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773853387569, + "signatureB64": "nGzeNeXv2hwq4AT10AbVlYaU2pkWZ6dAVd/xsBdRRm5pJDu2ayNEBcpFW0hsLgmC1YxDrNLi1Nx/dt1orooEDg==", + "clientInfo": "it-tests" + } +} + +20:03:07.576 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-20","status":200,"payload":{"sessionId":"kUDSYfGd80ox+TUezRJAMjJ+x3lL8itjN5wjLPGbYmM","ok":true}} +20:03:07.577 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.579 [qtp1582446160-32] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:43964 +20:03:07.580 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-21", + "payload": { + "sessionId": "m9AKXbbaru+SuoW1Y8XRBnLnMC8/wahapgrkJ9/B5uQ" + } +} + +20:03:07.583 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-21","status":200,"payload":{"nonce":"WZAxPWMlNVqx50PKR4VRAeaduoqxGZO1SWRsaZkcCdY","ok":true}} +20:03:07.584 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-22", + "payload": { + "sessionId": "m9AKXbbaru+SuoW1Y8XRBnLnMC8/wahapgrkJ9/B5uQ", + "timeMs": 1773853387583, + "signatureB64": "NCzekdhx3YUxCGzINi2trKjkSMxCDCGTMI0z58rqwraWy7oReFCcF21H9X96sdJos4tD/1hT2DG+/Qv32CecBA==", + "clientInfo": "it-tests" + } +} + +20:03:07.591 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-22","status":200,"payload":{"storagePwd":"pwd-2292340076703","ok":true}} +20:03:07.592 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CloseActiveSession", + "requestId": "it-close-23", + "payload": { + "sessionId": "kUDSYfGd80ox+TUezRJAMjJ+x3lL8itjN5wjLPGbYmM" + } +} + +20:03:07.599 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CloseActiveSession","requestId":"it-close-23","status":200,"payload":{"ok":true}} +20:03:07.600 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.602 [qtp1582446160-25] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:43972 +20:03:07.603 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-24", + "payload": { + "sessionId": "m9AKXbbaru+SuoW1Y8XRBnLnMC8/wahapgrkJ9/B5uQ" + } +} + +20:03:07.605 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-24","status":200,"payload":{"nonce":"wfnMsI4cjfDcCbUvbuHtcgtigQmEX+CP/7c3TgiI+lI","ok":true}} +20:03:07.606 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-25", + "payload": { + "sessionId": "m9AKXbbaru+SuoW1Y8XRBnLnMC8/wahapgrkJ9/B5uQ", + "timeMs": 1773853387605, + "signatureB64": "832omfc69LF/5UW3Ay+qtaKzau88gY+6g1nQvQjaAtewWmHgnSjcNzUxj+Qpm2pGCfKBliVUEco6LXSzgqTfDg==", + "clientInfo": "it-tests" + } +} + +20:03:07.613 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-25","status":200,"payload":{"storagePwd":"pwd-2292340076703","ok":true}} +20:03:07.614 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-26", + "payload": { + } +} + +20:03:07.617 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-26","status":200,"payload":{"sessions":[{"sessionId":"m9AKXbbaru+SuoW1Y8XRBnLnMC8/wahapgrkJ9/B5uQ","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773853387609},{"sessionId":"/y+FLlx7puXqypi/ZG7X/Cw5jPJjQQW6IgnH8RLpxrY","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773853387509},{"sessionId":"FMGRbmx9bFk4Hbt+lZmCqioGYqI6YWUFN/GCVKTTWDs","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773853387525}],"ok":true}} +20:03:07.619 [qtp1582446160-30] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.622 [qtp1582446160-42] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:43982 +20:03:07.634 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-27", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm62ssAAAAAAAFTSGlOZQlUZXN0VXNlcjEBAJ9Okxv/F54WE85v9jW8Cq+aBnIpzTLS8EEfIWlKJ1KWV85vq/K7MnDCAjpcxqiza9Km6mqsRO9VZDwnvjXYegU=" + } +} + +20:03:07.641 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:03:07.646 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=0, newHash=acda2f54ccf6b0cc02254c5e02541dfa12a47c2c125c95668df002a6a3f941a9 +20:03:07.649 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-27","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"acda2f54ccf6b0cc02254c5e02541dfa12a47c2c125c95668df002a6a3f941a9","ok":true}} +20:03:07.653 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-28", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 1, + "prevBlockHash": "acda2f54ccf6b0cc02254c5e02541dfa12a47c2c125c95668df002a6a3f941a9", + "blockBytesB64": "AACs2i9UzPawzAIlTF4CVB36EqR8LBJclWaN8AKmo/lBqQAAAIEAAAABAAAAAGm62ssAAQAKAAEAAAAAAAAAAKzaL1TM9rDMAiVMXgJUHfoSpHwsElyVZo3wAqaj+UGpAAAAAAAbVTE6IHN0b3J5L3Bvc3QgaW4gY2hhbm5lbCAwAQB2XMPmEhmbArjkZNa/QvlXQXhTYJwygItvflkbMwjKWt6/ooExeG+MYnOhGLafoLQNrXmyw5VyRwYOlk89DAwP" + } +} + +20:03:07.658 [ws-worker-12] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=0 prevLineNumber=0 thisLineNumber=0 prevLineHashLen=32 +20:03:07.663 [ws-worker-12] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=1, newHash=d4a1d4159f856eca79465add4578abf150394dbb58c05c6dfca07f120295f9e0 +20:03:07.663 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-28","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"d4a1d4159f856eca79465add4578abf150394dbb58c05c6dfca07f120295f9e0","ok":true}} +20:03:07.665 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-29", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 2, + "prevBlockHash": "d4a1d4159f856eca79465add4578abf150394dbb58c05c6dfca07f120295f9e0", + "blockBytesB64": "AADUodQVn4VuynlGWt1FeKvxUDlNu1jAXG38oH8SApX54AAAAGkAAAACAAAAAGm62ssAAAABAAEAAAAAAAAAAKzaL1TM9rDMAiVMXgJUHfoSpHwsElyVZo3wAqaj+UGpAAAAAQROZXdzAQA1hJQKmCFm0GqtgIFi/+M5BkrpQ53RMBcNgZdYyOuUm3mUoMves2YTVmzattXWC9hnQwVuMcL9eurzxPqta2QL" + } +} + +20:03:07.670 [ws-worker-13] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=1 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +20:03:07.675 [ws-worker-13] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=2, newHash=02776649caa3367594083f995f01a87b169a664969b01573401ab3d9be3ab4a2 +20:03:07.675 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-29","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"02776649caa3367594083f995f01a87b169a664969b01573401ab3d9be3ab4a2","ok":true}} +20:03:07.678 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-30", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 3, + "prevBlockHash": "02776649caa3367594083f995f01a87b169a664969b01573401ab3d9be3ab4a2", + "blockBytesB64": "AAACd2ZJyqM2dZQIP5lfAah7FppmSWmwFXNAGrPZvjq0ogAAAHYAAAADAAAAAGm62ssAAQAKAAEAAAACAAAAAgJ3ZknKozZ1lAg/mV8BqHsWmmZJabAVc0Aas9m+OrSiAAAAAAAQVTE6IE5ld3MgcG9zdCAjMAEADfWHKHYR9K9woIATsvkCeig2DbNGBmopJMxp/1FgsLB8HxR2jkeeZTqvX8Na5BfhhSTpFQMQFV4KsCtiqJYICQ==" + } +} + +20:03:07.683 [ws-worker-14] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=2 thisLineNumber=0 prevLineHashLen=32 +20:03:07.688 [ws-worker-14] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=3, newHash=dcbc2f265f7cbaf22bc70eb370c99057adc71adb6f63666725edf7b76cacd42d +20:03:07.688 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-30","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"dcbc2f265f7cbaf22bc70eb370c99057adc71adb6f63666725edf7b76cacd42d","ok":true}} +20:03:07.691 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-31", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 4, + "prevBlockHash": "dcbc2f265f7cbaf22bc70eb370c99057adc71adb6f63666725edf7b76cacd42d", + "blockBytesB64": "AADcvC8mX3y68ivHDrNwyZBXrcca229jZmcl7fe3bKzULQAAAHYAAAAEAAAAAGm62ssAAQAKAAEAAAACAAAAA9y8LyZffLryK8cOs3DJkFetxxrbb2NmZyXt97dsrNQtAAAAAQAQVTE6IE5ld3MgcG9zdCAjMQEAzpAcw0so8t6KPQhlCUsiH2hff3SXKG6j5YDrUynvoRoVJgTtZaB7vgZ+9VNYEHfvQjkOP1BA9DS+dfN4sbKZCg==" + } +} + +20:03:07.697 [ws-worker-15] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=3 thisLineNumber=1 prevLineHashLen=32 +20:03:07.702 [ws-worker-15] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=4, newHash=89cca68bf6cc44260666a122fd4c6dab71c8307f4877c789fe34c503c1d53239 +20:03:07.702 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-31","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"89cca68bf6cc44260666a122fd4c6dab71c8307f4877c789fe34c503c1d53239","ok":true}} +20:03:07.704 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-32", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 5, + "prevBlockHash": "89cca68bf6cc44260666a122fd4c6dab71c8307f4877c789fe34c503c1d53239", + "blockBytesB64": "AACJzKaL9sxEJgZmoSL9TG2rccgwf0h3x4n+NMUDwdUyOQAAAKEAAAAFAAAAAGm62ssAAQALAAEAAAACAAAABInMpov2zEQmBmahIv1MbatxyDB/SHfHif40xQPB1TI5AAAAAgAAAAPcvC8mX3y68ivHDrNwyZBXrcca229jZmcl7fe3bKzULQAXVTE6IE5ld3MgcG9zdCAjMCAoRURJVCkBANmyDxYZqiZqGQnB4ntuLXIdfQZSRAwH0nwXvJq8oy63uLyrpFH6Jvpslec+zdLFEnTYfCmxqeV4V8sR3qBhzQ8=" + } +} + +20:03:07.706 [ws-worker-16] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=11 lineCode=2 prevLineNumber=4 thisLineNumber=2 prevLineHashLen=32 +20:03:07.710 [ws-worker-16] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=5, newHash=09add1c64325dae730c19046ee3810d35132e060aec4ffba443ffaf3bd8da0ba +20:03:07.710 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-32","status":200,"payload":{"serverLastGlobalNumber":5,"serverLastGlobalHash":"09add1c64325dae730c19046ee3810d35132e060aec4ffba443ffaf3bd8da0ba","ok":true}} +20:03:07.712 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-33", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm62ssAAAAAAAFTSGlOZQlUZXN0VXNlcjIBAJzp6aNoXBfgvdhp3Q82Dn1btF+NuLhi/Setz7afB+zLRoM8N21weBl4d7smID1kDA0xEpJQalxqviXAT5k/QQ4=" + } +} + +20:03:07.714 [ws-worker-1] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:03:07.719 [ws-worker-1] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=0, newHash=518a5c00368b9cfa172d12af0c3929edaf0b1e4aa28917845e7f4667968080b7 +20:03:07.719 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-33","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"518a5c00368b9cfa172d12af0c3929edaf0b1e4aa28917845e7f4667968080b7","ok":true}} +20:03:07.725 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-34", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 6, + "prevBlockHash": "09add1c64325dae730c19046ee3810d35132e060aec4ffba443ffaf3bd8da0ba", + "blockBytesB64": "AAAJrdHGQyXa5zDBkEbuOBDTUTLgYK7E/7pEP/rzvY2gugAAAJYAAAAGAAAAAGm62ssAAwAeAAEAAAAAAAAAAKzaL1TM9rDMAiVMXgJUHfoSpHwsElyVZo3wAqaj+UGpAAAAAQ1UZXN0VXNlcjItMDAxAAAAAFGKXAA2i5z6Fy0Srww5Ke2vCx5KookXhF5/RmeWgIC3AQCblT45B+DhKBZ5ZTdTnK1TB74c8VPfccwFhT6Ugcac9MMsTAWfSPJ0/gfOu8islgUduOg4jvUsYiy7NwB8OGAE" + } +} + +20:03:07.731 [ws-worker-2] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +20:03:07.736 [ws-worker-2] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=6, newHash=74508c35a6f1140e327894851f8dc1b56a35ea6d9169aa8fd129d241628a9edd +20:03:07.737 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-34","status":200,"payload":{"serverLastGlobalNumber":6,"serverLastGlobalHash":"74508c35a6f1140e327894851f8dc1b56a35ea6d9169aa8fd129d241628a9edd","ok":true}} +20:03:07.738 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-35", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 1, + "prevBlockHash": "518a5c00368b9cfa172d12af0c3929edaf0b1e4aa28917845e7f4667968080b7", + "blockBytesB64": "AABRilwANouc+hctEq8MOSntrwseSqKJF4Ref0ZnloCAtwAAAJYAAAABAAAAAGm62ssAAwAeAAEAAAAAAAAAAFGKXAA2i5z6Fy0Srww5Ke2vCx5KookXhF5/RmeWgIC3AAAAAQ1UZXN0VXNlcjEtMDAxAAAAAgJ3ZknKozZ1lAg/mV8BqHsWmmZJabAVc0Aas9m+OrSiAQASX9no/OV+1G+dRtexhicmlhQn3Klz99gOOJRsqqcmTWGNua2ZnqkBTGPf5HAJ6MwEhSnaiI6Ha87gn72BKPsK" + } +} + +20:03:07.741 [ws-worker-3] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +20:03:07.746 [ws-worker-3] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=1, newHash=1bdf45271c7af830af5d7dc101ce51dc9b166ddabff1510d151da2ca23dcd1df +20:03:07.746 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-35","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"1bdf45271c7af830af5d7dc101ce51dc9b166ddabff1510d151da2ca23dcd1df","ok":true}} +20:03:07.747 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-36", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 7, + "prevBlockHash": "74508c35a6f1140e327894851f8dc1b56a35ea6d9169aa8fd129d241628a9edd", + "blockBytesB64": "AAB0UIw1pvEUDjJ4lIUfjcG1ajXqbZFpqo/RKdJBYoqe3QAAAJYAAAAHAAAAAGm62ssAAwAKAAEAAAAAAAAABnRQjDWm8RQOMniUhR+NwbVqNeptkWmqj9Ep0kFiip7dAAAAAg1UZXN0VXNlcjItMDAxAAAAAFGKXAA2i5z6Fy0Srww5Ke2vCx5KookXhF5/RmeWgIC3AQCxmYWpyGB/0drGHJ0wcqtOhE32yPetAbH5kwC65pifot9hhG35sg2PLCLyXgSZZWPc1cPn3eaYB0xrs/HIu3YK" + } +} + +20:03:07.751 [ws-worker-4] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=6 thisLineNumber=2 prevLineHashLen=32 +20:03:07.756 [ws-worker-4] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=7, newHash=ee7c2d5ed8df4a2caf75751bdeb6f10c5537f0c1288d6d567baec1dc86c708d2 +20:03:07.757 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-36","status":200,"payload":{"serverLastGlobalNumber":7,"serverLastGlobalHash":"ee7c2d5ed8df4a2caf75751bdeb6f10c5537f0c1288d6d567baec1dc86c708d2","ok":true}} +20:03:07.759 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-37", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 2, + "prevBlockHash": "1bdf45271c7af830af5d7dc101ce51dc9b166ddabff1510d151da2ca23dcd1df", + "blockBytesB64": "AAAb30UnHHr4MK9dfcEBzlHcmxZt2r/xUQ0VHaLKI9zR3wAAAJYAAAACAAAAAGm62ssAAwAKAAEAAAAAAAAAARvfRSccevgwr119wQHOUdybFm3av/FRDRUdosoj3NHfAAAAAg1UZXN0VXNlcjEtMDAxAAAAAKzaL1TM9rDMAiVMXgJUHfoSpHwsElyVZo3wAqaj+UGpAQC+3lFQhIy0Q/b9CSTEOPUlwr/oq4vwtaGW6Z+yUVO41IeXTgnB94uiiF8g0uJf2fjwSYoKe2WM7P6oaf5xrUID" + } +} + +20:03:07.764 [ws-worker-5] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=1 thisLineNumber=2 prevLineHashLen=32 +20:03:07.770 [ws-worker-5] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=2, newHash=e07154cea2df8177d23521d84d0df05bc6665cabb6fdcc2f357787973beda6a2 +20:03:07.770 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-37","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"e07154cea2df8177d23521d84d0df05bc6665cabb6fdcc2f357787973beda6a2","ok":true}} +20:03:07.772 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-38", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 8, + "prevBlockHash": "ee7c2d5ed8df4a2caf75751bdeb6f10c5537f0c1288d6d567baec1dc86c708d2", + "blockBytesB64": "AADufC1e2N9KLK91dRvetvEMVTfwwSiNbVZ7rsHchscI0gAAAJYAAAAIAAAAAGm62ssAAwAUAAEAAAAAAAAAB+58LV7Y30osr3V1G9628QxVN/DBKI1tVnuuwdyGxwjSAAAAAw1UZXN0VXNlcjItMDAxAAAAAFGKXAA2i5z6Fy0Srww5Ke2vCx5KookXhF5/RmeWgIC3AQCFGa/7bgaThcQ+7qcWuZOXmuJ/yInAJ6/HOHH+NnlP4eBmH5PGG5ohSPIzeFCs3COWtuh2YH02XVSxOM5NG+wH" + } +} + +20:03:07.777 [ws-worker-6] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=7 thisLineNumber=3 prevLineHashLen=32 +20:03:07.782 [ws-worker-6] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=8, newHash=450c1ea4c850b5e635ed3c37c73765eb0b384f48bb574bc9ae35b5757c9a6f40 +20:03:07.783 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-38","status":200,"payload":{"serverLastGlobalNumber":8,"serverLastGlobalHash":"450c1ea4c850b5e635ed3c37c73765eb0b384f48bb574bc9ae35b5757c9a6f40","ok":true}} +20:03:07.785 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-39", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 3, + "prevBlockHash": "e07154cea2df8177d23521d84d0df05bc6665cabb6fdcc2f357787973beda6a2", + "blockBytesB64": "AADgcVTOot+Bd9I1IdhNDfBbxmZcq7b9zC81d4eXO+2mogAAAJYAAAADAAAAAGm62ssAAwAUAAEAAAAAAAAAAuBxVM6i34F30jUh2E0N8FvGZlyrtv3MLzV3h5c77aaiAAAAAw1UZXN0VXNlcjEtMDAxAAAAAKzaL1TM9rDMAiVMXgJUHfoSpHwsElyVZo3wAqaj+UGpAQDjrjBdljtb8M51wqaj8v7MvNmCB8PD1vHhJ4dV8wkPMG8YSw2hLbf5op/KFYI3VJN3CBuSAar1F2jwkEYgJM8G" + } +} + +20:03:07.788 [ws-worker-7] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=2 thisLineNumber=3 prevLineHashLen=32 +20:03:07.793 [ws-worker-7] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=3, newHash=db482d9ea235a9bfb7df2dad6458853176cf5388d014a876d2082eaaeb303e65 +20:03:07.793 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-39","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"db482d9ea235a9bfb7df2dad6458853176cf5388d014a876d2082eaaeb303e65","ok":true}} +20:03:07.796 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-40", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 4, + "prevBlockHash": "db482d9ea235a9bfb7df2dad6458853176cf5388d014a876d2082eaaeb303e65", + "blockBytesB64": "AADbSC2eojWpv7ffLa1kWIUxds9TiNAUqHbSCC6q6zA+ZQAAAJYAAAAEAAAAAGm62ssAAQAUAAENVGVzdFVzZXIxLTAwMQAAAAPcvC8mX3y68ivHDrNwyZBXrcca229jZmcl7fe3bKzULQAqVTI6IHJlcGx5IHRvIFUxIE5ld3MgcG9zdCAjMCAoY3Jvc3MtY2hhaW4pAQCAHNVVYPjXv+GzMK3HJlYdxYJXJX3mC2NOa0zlpnxAU1ze0dBxg2T4hPCqFYGfW4oQ8AcXHxU69sn/1IFTiJ0G" + } +} + +20:03:07.798 [ws-worker-8] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=20 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:03:07.802 [ws-worker-8] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=4, newHash=94fddd34180ba45661a3bd8d6da96ed4b3df89f285367fcf9bb2c0757f7f41cf +20:03:07.803 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-40","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"94fddd34180ba45661a3bd8d6da96ed4b3df89f285367fcf9bb2c0757f7f41cf","ok":true}} +20:03:07.804 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-41", + "payload": { + "blockchainName": "TestUser3-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm62ssAAAAAAAFTSGlOZQlUZXN0VXNlcjMBANOfpG7EAU4AmiawUmIHJHuV+Czes+VVLeOZNcuGzrlBtMG2oJMVaRAKxqIJg5qtgTQqRovSJyONW8b8hS8j+w8=" + } +} + +20:03:07.807 [ws-worker-9] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:03:07.811 [ws-worker-9] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser3, blockchainName=TestUser3-001, blockNumber=0, newHash=f10c4b73671a89952f357e22e3675dff5485493082c3c36547c7a946f129bc6c +20:03:07.811 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-41","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"f10c4b73671a89952f357e22e3675dff5485493082c3c36547c7a946f129bc6c","ok":true}} +20:03:07.812 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-42", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 9, + "prevBlockHash": "450c1ea4c850b5e635ed3c37c73765eb0b384f48bb574bc9ae35b5757c9a6f40", + "blockBytesB64": "AABFDB6kyFC15jXtPDfHN2XrCzhPSLtXS8muNbV1fJpvQAAAAJYAAAAJAAAAAGm62ssAAwAUAAEAAAAAAAAACEUMHqTIULXmNe08N8c3ZesLOE9Iu1dLya41tXV8mm9AAAAABA1UZXN0VXNlcjMtMDAxAAAAAPEMS3NnGomVLzV+IuNnXf9UhUkwgsPDZUfHqUbxKbxsAQDgco2tnp0wpv9Puoixjo5A7sDF83fexCK7+Tv6KF9urQzZQNuDTlk2c0AK5DRFniRYd7kclE91B6vq3O8vvDsI" + } +} + +20:03:07.816 [ws-worker-10] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=8 thisLineNumber=4 prevLineHashLen=32 +20:03:07.820 [ws-worker-10] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=9, newHash=9bb3393a23e448e7dd94b8f2ffa9ec99159254c7d63bb233997ae6d43eee5cb7 +20:03:07.821 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-42","status":200,"payload":{"serverLastGlobalNumber":9,"serverLastGlobalHash":"9bb3393a23e448e7dd94b8f2ffa9ec99159254c7d63bb233997ae6d43eee5cb7","ok":true}} +20:03:07.822 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-43", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 10, + "prevBlockHash": "9bb3393a23e448e7dd94b8f2ffa9ec99159254c7d63bb233997ae6d43eee5cb7", + "blockBytesB64": "AACbszk6I+RI592UuPL/qeyZFZJUx9Y7sjOZeubUPu5ctwAAAJYAAAAKAAAAAGm62ssAAwAVAAEAAAAAAAAACZuzOToj5Ejn3ZS48v+p7JkVklTH1juyM5l65tQ+7ly3AAAABQ1UZXN0VXNlcjItMDAxAAAAAFGKXAA2i5z6Fy0Srww5Ke2vCx5KookXhF5/RmeWgIC3AQDCyzKB1j8koFOO1aqdtIhjk/4auUhFNgbXMOF5b3ZC1UZ8K+e1UIPhe0pnGOGdecXSKdWAC67s5aSyMuQP36EO" + } +} + +20:03:07.826 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=21 lineCode=0 prevLineNumber=9 thisLineNumber=5 prevLineHashLen=32 +20:03:07.831 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=10, newHash=b4ed134ef386f2cc8e86ebd9bacce8339fa5c707bdb0ade674205fd6137df522 +20:03:07.831 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-43","status":200,"payload":{"serverLastGlobalNumber":10,"serverLastGlobalHash":"b4ed134ef386f2cc8e86ebd9bacce8339fa5c707bdb0ade674205fd6137df522","ok":true}} +20:03:07.832 [qtp1582446160-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.837 [qtp1582446160-37] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:43986 +20:03:07.839 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-44", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1773853387832, + "value": "Anna", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "PXYSV7dgrpIicBrOb2lSVRNQBpn9BMCwBQqt/j2b/zJxK9+Glo3L2sWuTm3xOn1kaOaHqHewYQSvblwhC1IiBA==" + } +} + +20:03:07.844 [ws-worker-12] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1773853387832 +20:03:07.847 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-44","status":200,"payload":{"ok":true}} +20:03:07.849 [qtp1582446160-37] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.852 [qtp1582446160-26] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:43992 +20:03:07.854 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-45", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +20:03:07.859 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-45","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1773853387832,"value":"Anna","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"PXYSV7dgrpIicBrOb2lSVRNQBpn9BMCwBQqt/j2b/zJxK9+Glo3L2sWuTm3xOn1kaOaHqHewYQSvblwhC1IiBA==","ok":true}} +20:03:07.861 [qtp1582446160-26] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.865 [qtp1582446160-57] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44008 +20:03:07.866 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-46", + "payload": { + "login": "TestUser1", + "param": "profile:city", + "time_ms": 1773853387842, + "value": "Amsterdam", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "x1TGiWZYO/XCeXBkwOuq5UO5RvD5zFKtw1dJdmFmUfwFIN8Boc8ukfe8BfmA72tS4XTmfsqmCGcptR1RlJaVCw==" + } +} + +20:03:07.869 [ws-worker-14] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:city, time_ms=1773853387842 +20:03:07.872 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-46","status":200,"payload":{"ok":true}} +20:03:07.873 [qtp1582446160-57] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.876 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44020 +20:03:07.877 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-47", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1773853387852, + "value": "Anna Updated", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "MrUl5B8N88NSfF63QqOobd5F+Pq0WFT8E+sWSny8gs3VqIKQTM/6WcwaGJeIdw0HxBE48jvyOqgM1pPod58kBg==" + } +} + +20:03:07.879 [ws-worker-15] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1773853387852 +20:03:07.882 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-47","status":200,"payload":{"ok":true}} +20:03:07.883 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.886 [qtp1582446160-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44028 +20:03:07.887 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-48", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +20:03:07.888 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-48","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1773853387852,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"MrUl5B8N88NSfF63QqOobd5F+Pq0WFT8E+sWSny8gs3VqIKQTM/6WcwaGJeIdw0HxBE48jvyOqgM1pPod58kBg==","ok":true}} +20:03:07.889 [qtp1582446160-28] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.892 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44032 +20:03:07.893 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListUserParams", + "requestId": "it-listparams-49", + "payload": { "login": "TestUser1" } +} + +20:03:07.896 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListUserParams","requestId":"it-listparams-49","status":200,"payload":{"login":"TestUser1","params":[{"login":"TestUser1","param":"profile:name","time_ms":1773853387852,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"MrUl5B8N88NSfF63QqOobd5F+Pq0WFT8E+sWSny8gs3VqIKQTM/6WcwaGJeIdw0HxBE48jvyOqgM1pPod58kBg=="},{"login":"TestUser1","param":"profile:city","time_ms":1773853387842,"value":"Amsterdam","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"x1TGiWZYO/XCeXBkwOuq5UO5RvD5zFKtw1dJdmFmUfwFIN8Boc8ukfe8BfmA72tS4XTmfsqmCGcptR1RlJaVCw=="}],"ok":true}} +20:03:07.898 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:03:07.901 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44040 +20:03:07.902 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-50", + "payload": { + "login": "TestUser1" + } +} + +20:03:07.905 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-50","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +20:03:07.907 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-51", + "payload": { + "login": "Testuser1" + } +} + +20:03:07.908 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-51","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +20:03:07.909 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-52", + "payload": { + "login": "TestUser2" + } +} + +20:03:07.910 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-52","status":200,"payload":{"login":"TestUser2","out_friends":["TestUser1"],"in_friends":["TestUser1"],"ok":true}} +20:03:07.911 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:51.478 [wsServer-thread] INFO s.ws.BlockchainTmpRecoveryOnStartup - 🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется. +20:13:51.550 [wsServer-thread] INFO org.eclipse.jetty.server.Server - jetty-11.0.20; built: 2024-01-29T21:04:22.394Z; git: 922f8dc188f7011e60d0361de585fd4ac4d63064; jvm 17.0.18+8-Ubuntu-124.04.1 +20:13:51.600 [wsServer-thread] INFO o.e.j.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@244d5426{/,null,AVAILABLE} +20:13:51.608 [wsServer-thread] INFO o.e.jetty.server.AbstractConnector - Started ServerConnector@4663b900{HTTP/1.1, (http/1.1)}{0.0.0.0:7070} +20:13:51.612 [wsServer-thread] INFO org.eclipse.jetty.server.Server - Started Server@3c6d529{STARTING}[11.0.20,sto=0] @788ms +20:13:51.612 [wsServer-thread] INFO server.ws.WsServer - ✅ WS сервер запущен на ws://localhost:7070/ws +20:13:52.401 [qtp396378551-41] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46000 +20:13:52.430 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-1", + "payload": { + "login": "TestUser1", + "blockchainName": "TestUser1-001", + "solanaKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "blockchainKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "deviceKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "bchLimit": 50000000 + } +} + +20:13:52.488 [ws-worker-1] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser1, blockchainName=TestUser1-001, limit=50000000 +20:13:52.500 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-1","status":200,"payload":{"ok":true}} +20:13:52.504 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-2", + "payload": { + "login": "TestUser1" + } +} + +20:13:52.507 [ws-worker-2] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +20:13:52.509 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-2","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +20:13:52.511 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-3", + "payload": { + "login": "TestUser2", + "blockchainName": "TestUser2-001", + "solanaKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "blockchainKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "deviceKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "bchLimit": 50000000 + } +} + +20:13:52.519 [ws-worker-3] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser2, blockchainName=TestUser2-001, limit=50000000 +20:13:52.519 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-3","status":200,"payload":{"ok":true}} +20:13:52.521 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-4", + "payload": { + "login": "TestUser2" + } +} + +20:13:52.523 [ws-worker-4] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser2, blockchainName=TestUser2-001 +20:13:52.524 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-4","status":200,"payload":{"exists":true,"login":"TestUser2","blockchainName":"TestUser2-001","solanaKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","blockchainKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","deviceKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","ok":true}} +20:13:52.526 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-5", + "payload": { + "login": "TestUser3", + "blockchainName": "TestUser3-001", + "solanaKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "blockchainKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "deviceKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "bchLimit": 50000000 + } +} + +20:13:52.531 [ws-worker-5] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser3, blockchainName=TestUser3-001, limit=50000000 +20:13:52.531 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-5","status":200,"payload":{"ok":true}} +20:13:52.533 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-6", + "payload": { + "login": "TestUser3" + } +} + +20:13:52.535 [ws-worker-6] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser3, blockchainName=TestUser3-001 +20:13:52.535 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-6","status":200,"payload":{"exists":true,"login":"TestUser3","blockchainName":"TestUser3-001","solanaKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","blockchainKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","deviceKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","ok":true}} +20:13:52.538 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-7", + "payload": { + "login": "Testuser1" + } +} + +20:13:52.540 [ws-worker-7] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +20:13:52.540 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-7","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +20:13:52.542 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-8", + "payload": { + "login": "NoSuchUser_987654321" + } +} + +20:13:52.544 [ws-worker-8] INFO s.l.w.J.h.t.Net_GetUser_Handler - ℹ️ GetUser: not found for login=NoSuchUser_987654321 +20:13:52.545 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-8","status":200,"payload":{"exists":false,"ok":true}} +20:13:52.547 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SearchUsers", + "requestId": "it-searchusers-9", + "payload": { + "prefix": "Tes" + } +} + +20:13:52.551 [ws-worker-9] INFO s.l.w.J.h.t.Net_SearchUsers_Handler - ✅ SearchUsers ok: prefix='Tes' -> 3 +20:13:52.554 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SearchUsers","requestId":"it-searchusers-9","status":200,"payload":{"logins":["TestUser1","TestUser2","TestUser3"],"ok":true}} +20:13:52.559 [qtp396378551-37] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.564 [qtp396378551-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46002 +20:13:52.566 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-10", + "payload": { "login": "TestUser1" } +} + +20:13:52.570 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-10","status":200,"payload":{"authNonce":"HYS6uEiUOQA7+S+nKXBfLryhe0pQGfcah3PM/0H6pWY","ok":true}} +20:13:52.578 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-11", + "payload": { + "storagePwd": "pwd-2937434637364", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773854032573, + "signatureB64": "ZJRTtBOFdck/yz1Bl+T7i8ELbd6ffivsDGQI6EDUa5th/+NEom56nltvittgznYjG26/EJwk55hUQHNjOi96Cg==", + "clientInfo": "it-tests" + } +} + +20:13:52.604 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-11","status":200,"payload":{"sessionId":"mO5by5k9U6WcM9HCyQRcmY2TfwJjA8vILEBdO8X6wzE","ok":true}} +20:13:52.608 [qtp396378551-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.613 [qtp396378551-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46010 +20:13:52.614 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-12", + "payload": { "login": "TestUser1" } +} + +20:13:52.617 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-12","status":200,"payload":{"authNonce":"3NLAufnkmQ+MtvOVUMgPTij/w9UI/9Yrf2SHqTDHChA","ok":true}} +20:13:52.620 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-13", + "payload": { + "storagePwd": "pwd-2937479772566", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773854032618, + "signatureB64": "WIQEGXTlo7bVvL6h4tJfvJ6dVWlnxsPuDiLvSw5QLkkbszDDmIVwZuJfmuzB4HBMLaoW24gQQMREOyeZzwDaAw==", + "clientInfo": "it-tests" + } +} + +20:13:52.628 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-13","status":200,"payload":{"sessionId":"P7QBemTaXnHoBUZ0HprdIPho09ZwDH48GHFZXZJ5us8","ok":true}} +20:13:52.631 [qtp396378551-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.638 [qtp396378551-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46014 +20:13:52.641 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-14", + "payload": { "login": "TestUser1" } +} + +20:13:52.644 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-14","status":200,"payload":{"authNonce":"mBxwgINeuJfMhfh0HVE2lo2vizlMg0jYojHhx1Mu/Js","ok":true}} +20:13:52.650 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-15", + "payload": { + "storagePwd": "pwd-2937507580592", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773854032646, + "signatureB64": "uDOjx2azP8HDnh8sIyxErc97rNEynBxOQtD0QUITHzdt4U4JxnICYdOIN2jbe023xjc9q3jDgwY4v/JySLevAA==", + "clientInfo": "it-tests" + } +} + +20:13:52.658 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-15","status":200,"payload":{"sessionId":"UMaw4cHMfIku2PLXXBkUVml0iIcykwXdz8AcN9iZQz4","ok":true}} +20:13:52.659 [qtp396378551-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.663 [qtp396378551-30] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46030 +20:13:52.665 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-16", + "payload": { + "sessionId": "mO5by5k9U6WcM9HCyQRcmY2TfwJjA8vILEBdO8X6wzE" + } +} + +20:13:52.672 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-16","status":200,"payload":{"nonce":"J2XHKs9+3p0CPACbL5FF/DJdMi4HWlL4TyRL5p1HcSM","ok":true}} +20:13:52.675 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-17", + "payload": { + "sessionId": "mO5by5k9U6WcM9HCyQRcmY2TfwJjA8vILEBdO8X6wzE", + "timeMs": 1773854032673, + "signatureB64": "gVa7rvYjjooUijbcIpoOXD8hq9mSZQ4o0bRKr5wJ98BXOE/5EtjH5dhRoeicnOjNOqWyKurzn9wXxlOd8stxAA==", + "clientInfo": "it-tests" + } +} + +20:13:52.687 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-17","status":200,"payload":{"storagePwd":"pwd-2937434637364","ok":true}} +20:13:52.691 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-18", + "payload": { + } +} + +20:13:52.696 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-18","status":200,"payload":{"sessions":[{"sessionId":"mO5by5k9U6WcM9HCyQRcmY2TfwJjA8vILEBdO8X6wzE","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854032681},{"sessionId":"P7QBemTaXnHoBUZ0HprdIPho09ZwDH48GHFZXZJ5us8","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854032622},{"sessionId":"UMaw4cHMfIku2PLXXBkUVml0iIcykwXdz8AcN9iZQz4","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854032652}],"ok":true}} +20:13:52.699 [qtp396378551-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.702 [qtp396378551-31] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46046 +20:13:52.704 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-19", + "payload": { "login": "TestUser1" } +} + +20:13:52.705 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-19","status":200,"payload":{"authNonce":"Y8oPv6h3PICZ/fBg9PDM99Ga9A7mfkv9/48FMi8kW28","ok":true}} +20:13:52.707 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-20", + "payload": { + "storagePwd": "pwd-2937567970629", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773854032706, + "signatureB64": "PADNZEElnNPlx9HWSzWlA42uVPMgIeABNzVMgweo4FyccwOWqLqlCIifB5t04gHbtCyQkh/huzKj53JS0vjRAQ==", + "clientInfo": "it-tests" + } +} + +20:13:52.712 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-20","status":200,"payload":{"sessionId":"Wx6zNe7tUnpnXmrS+D0TUtMy0WkqQAxV+E/2VQG9C4c","ok":true}} +20:13:52.713 [qtp396378551-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.718 [qtp396378551-32] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46060 +20:13:52.719 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-21", + "payload": { + "sessionId": "mO5by5k9U6WcM9HCyQRcmY2TfwJjA8vILEBdO8X6wzE" + } +} + +20:13:52.720 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-21","status":200,"payload":{"nonce":"7tAFCqHBxQzA8SIALNcCbD6o3xEUHr2h6Pnll3PaYjo","ok":true}} +20:13:52.722 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-22", + "payload": { + "sessionId": "mO5by5k9U6WcM9HCyQRcmY2TfwJjA8vILEBdO8X6wzE", + "timeMs": 1773854032721, + "signatureB64": "8JJUgqTJm8q0TRhmRNbnDbEXoPWAuUaMRuaCNWA8HFpm+hdCliLFxzU5iGYipi6Tes05BOYfrgj7E6zP8rdUBw==", + "clientInfo": "it-tests" + } +} + +20:13:52.729 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-22","status":200,"payload":{"storagePwd":"pwd-2937434637364","ok":true}} +20:13:52.730 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CloseActiveSession", + "requestId": "it-close-23", + "payload": { + "sessionId": "Wx6zNe7tUnpnXmrS+D0TUtMy0WkqQAxV+E/2VQG9C4c" + } +} + +20:13:52.737 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CloseActiveSession","requestId":"it-close-23","status":200,"payload":{"ok":true}} +20:13:52.738 [qtp396378551-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.741 [qtp396378551-25] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46076 +20:13:52.743 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-24", + "payload": { + "sessionId": "mO5by5k9U6WcM9HCyQRcmY2TfwJjA8vILEBdO8X6wzE" + } +} + +20:13:52.745 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-24","status":200,"payload":{"nonce":"XlokMwM8O93Y+YXyNZZIr7L619SUs9hDtBFAIkWVgrU","ok":true}} +20:13:52.747 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-25", + "payload": { + "sessionId": "mO5by5k9U6WcM9HCyQRcmY2TfwJjA8vILEBdO8X6wzE", + "timeMs": 1773854032745, + "signatureB64": "nZ6IsEpL0Lne5gCEXvGkL13byCuFBKFmObYJp7n4VQrST9CvyTDu8IWvin0sBIT8wZ/HSgub0vmXc2rkpb39AQ==", + "clientInfo": "it-tests" + } +} + +20:13:52.754 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-25","status":200,"payload":{"storagePwd":"pwd-2937434637364","ok":true}} +20:13:52.755 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-26", + "payload": { + } +} + +20:13:52.757 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-26","status":200,"payload":{"sessions":[{"sessionId":"mO5by5k9U6WcM9HCyQRcmY2TfwJjA8vILEBdO8X6wzE","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854032749},{"sessionId":"P7QBemTaXnHoBUZ0HprdIPho09ZwDH48GHFZXZJ5us8","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854032622},{"sessionId":"UMaw4cHMfIku2PLXXBkUVml0iIcykwXdz8AcN9iZQz4","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854032652}],"ok":true}} +20:13:52.758 [qtp396378551-30] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.763 [qtp396378551-42] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46086 +20:13:52.772 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-27", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm63VAAAAAAAAFTSGlOZQlUZXN0VXNlcjEBAA1PTkvzEufVilbg/IWO1ODGSMbkwr20bJ4dDaQGtqd9RrvZXqN+DOvXJWI69rmoOPAPuJwqPtlxbBIPXtEuMgY=" + } +} + +20:13:52.778 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:13:52.782 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=0, newHash=d35122f07bafc47a0c8c3a791ce7bd991331196b56a31fd2f017731e2fae3593 +20:13:52.783 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-27","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"d35122f07bafc47a0c8c3a791ce7bd991331196b56a31fd2f017731e2fae3593","ok":true}} +20:13:52.787 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-28", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 1, + "prevBlockHash": "d35122f07bafc47a0c8c3a791ce7bd991331196b56a31fd2f017731e2fae3593", + "blockBytesB64": "AADTUSLwe6/EegyMOnkc572ZEzEZa1ajH9LwF3MeL641kwAAAIEAAAABAAAAAGm63VAAAQAKAAEAAAAAAAAAANNRIvB7r8R6DIw6eRznvZkTMRlrVqMf0vAXcx4vrjWTAAAAAAAbVTE6IHN0b3J5L3Bvc3QgaW4gY2hhbm5lbCAwAQBMJuxTyUUtQicD6mueDbWREV6Itvua9TpzqpeY/j7KJrRy99zHTP9otJCmgOvCDlhtQ3ekBIl2fwcrzGepvHQG" + } +} + +20:13:52.790 [ws-worker-12] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=0 prevLineNumber=0 thisLineNumber=0 prevLineHashLen=32 +20:13:52.794 [ws-worker-12] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=1, newHash=166f9d1b6a74b04b1e1ba2fd28910f0a90002447f4ad0c8da72d25dbef6d437f +20:13:52.795 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-28","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"166f9d1b6a74b04b1e1ba2fd28910f0a90002447f4ad0c8da72d25dbef6d437f","ok":true}} +20:13:52.797 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-29", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 2, + "prevBlockHash": "166f9d1b6a74b04b1e1ba2fd28910f0a90002447f4ad0c8da72d25dbef6d437f", + "blockBytesB64": "AAAWb50banSwSx4bov0okQ8KkAAkR/StDI2nLSXb721DfwAAAGkAAAACAAAAAGm63VAAAAABAAEAAAAAAAAAANNRIvB7r8R6DIw6eRznvZkTMRlrVqMf0vAXcx4vrjWTAAAAAQROZXdzAQDxPN9OTK5ulnhGNYQXsovp1/mnynBkwahHhq7Bm7CgXgAjEdF+kNQneJJPv3zSI2kyrbbeC9mbJVfSbtrTTzgJ" + } +} + +20:13:52.801 [ws-worker-13] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=1 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +20:13:52.806 [ws-worker-13] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=2, newHash=91613efa6b49914a4f55589e8bbf63a4e654f0529abc85df6654b98662340f98 +20:13:52.806 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-29","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"91613efa6b49914a4f55589e8bbf63a4e654f0529abc85df6654b98662340f98","ok":true}} +20:13:52.808 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-30", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 3, + "prevBlockHash": "91613efa6b49914a4f55589e8bbf63a4e654f0529abc85df6654b98662340f98", + "blockBytesB64": "AACRYT76a0mRSk9VWJ6Lv2Ok5lTwUpq8hd9mVLmGYjQPmAAAAHYAAAADAAAAAGm63VAAAQAKAAEAAAACAAAAApFhPvprSZFKT1VYnou/Y6TmVPBSmryF32ZUuYZiNA+YAAAAAAAQVTE6IE5ld3MgcG9zdCAjMAEARA8BnVwa4ZrxucPhYWi2hH2uJaF8G2sUJEE1mBb6GcniCecj1JgAHH6iIdv74GemepAHdRJ51/xo7SOIwDhIAA==" + } +} + +20:13:52.811 [ws-worker-14] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=2 thisLineNumber=0 prevLineHashLen=32 +20:13:52.815 [ws-worker-14] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=3, newHash=fbac7143ef7330ce75792621b0d0c9eb1e93c8b3fc3d13c135d2d0efb60399d5 +20:13:52.815 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-30","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"fbac7143ef7330ce75792621b0d0c9eb1e93c8b3fc3d13c135d2d0efb60399d5","ok":true}} +20:13:52.817 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-31", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 4, + "prevBlockHash": "fbac7143ef7330ce75792621b0d0c9eb1e93c8b3fc3d13c135d2d0efb60399d5", + "blockBytesB64": "AAD7rHFD73MwznV5JiGw0MnrHpPIs/w9E8E10tDvtgOZ1QAAAHYAAAAEAAAAAGm63VAAAQAKAAEAAAACAAAAA/uscUPvczDOdXkmIbDQyesek8iz/D0TwTXS0O+2A5nVAAAAAQAQVTE6IE5ld3MgcG9zdCAjMQEAuOaqU1r+LstpIeSiaEYExdYgdcisj0E+pxQga06VZp/vdWYrQ0EZ96stNs9V7YrbbTMk0iASO1EV/DOA3ltCBA==" + } +} + +20:13:52.821 [ws-worker-15] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=3 thisLineNumber=1 prevLineHashLen=32 +20:13:52.825 [ws-worker-15] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=4, newHash=d29369151352c2537ef3cc887b2ec7e19ef2e708ef507d178c7c7af18ff39ce5 +20:13:52.826 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-31","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"d29369151352c2537ef3cc887b2ec7e19ef2e708ef507d178c7c7af18ff39ce5","ok":true}} +20:13:52.827 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-32", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 5, + "prevBlockHash": "d29369151352c2537ef3cc887b2ec7e19ef2e708ef507d178c7c7af18ff39ce5", + "blockBytesB64": "AADSk2kVE1LCU37zzIh7LsfhnvLnCO9QfReMfHrxj/Oc5QAAAKEAAAAFAAAAAGm63VAAAQALAAEAAAACAAAABNKTaRUTUsJTfvPMiHsux+Ge8ucI71B9F4x8evGP85zlAAAAAgAAAAP7rHFD73MwznV5JiGw0MnrHpPIs/w9E8E10tDvtgOZ1QAXVTE6IE5ld3MgcG9zdCAjMCAoRURJVCkBAHIKLza7omJwWkW2cmyEo8pPBQPAaHN0jC+labpQaduIIfIue7/KmEujAt+G73cgSopqh1ULxZztKCLsZ9IVeAQ=" + } +} + +20:13:52.830 [ws-worker-16] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=11 lineCode=2 prevLineNumber=4 thisLineNumber=2 prevLineHashLen=32 +20:13:52.834 [ws-worker-16] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=5, newHash=7d93d254dc30dd103aae8cf860039ef61e00484fee3813b07be7cfd723db6643 +20:13:52.834 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-32","status":200,"payload":{"serverLastGlobalNumber":5,"serverLastGlobalHash":"7d93d254dc30dd103aae8cf860039ef61e00484fee3813b07be7cfd723db6643","ok":true}} +20:13:52.836 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-33", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm63VAAAAAAAAFTSGlOZQlUZXN0VXNlcjIBAHQM5L58tLDsEOhNoQO8vPyf0u1T3EOqAJvtefs+ZNPpTA1naHasyzO97SUR4u1oFaY//NNYLqzADnXwk3ELVwQ=" + } +} + +20:13:52.839 [ws-worker-1] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:13:52.843 [ws-worker-1] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=0, newHash=3e910ea2fc7ae53dcbf18beb878d4c8c92d973047e114d485bd44aeb31500852 +20:13:52.843 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-33","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"3e910ea2fc7ae53dcbf18beb878d4c8c92d973047e114d485bd44aeb31500852","ok":true}} +20:13:52.847 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-34", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 6, + "prevBlockHash": "7d93d254dc30dd103aae8cf860039ef61e00484fee3813b07be7cfd723db6643", + "blockBytesB64": "AAB9k9JU3DDdEDqujPhgA572HgBIT+44E7B758/XI9tmQwAAAJYAAAAGAAAAAGm63VAAAwAeAAEAAAAAAAAAANNRIvB7r8R6DIw6eRznvZkTMRlrVqMf0vAXcx4vrjWTAAAAAQ1UZXN0VXNlcjItMDAxAAAAAD6RDqL8euU9y/GL64eNTIyS2XMEfhFNSFvUSusxUAhSAQAuYmvogWmypS/a9C1/g1rRxwKsuBhRxyt7xmNv6JO+vvNKVMYxH3QtNGu83/UaUfqyxK1HT5Qwz8tRZ3eTXJkG" + } +} + +20:13:52.852 [ws-worker-2] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +20:13:52.857 [ws-worker-2] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=6, newHash=909499d7292a047f2aee933a4eab63fa8c98168d29d6c6d7b1164eb53e0aeaed +20:13:52.858 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-34","status":200,"payload":{"serverLastGlobalNumber":6,"serverLastGlobalHash":"909499d7292a047f2aee933a4eab63fa8c98168d29d6c6d7b1164eb53e0aeaed","ok":true}} +20:13:52.860 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-35", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 1, + "prevBlockHash": "3e910ea2fc7ae53dcbf18beb878d4c8c92d973047e114d485bd44aeb31500852", + "blockBytesB64": "AAA+kQ6i/HrlPcvxi+uHjUyMktlzBH4RTUhb1ErrMVAIUgAAAJYAAAABAAAAAGm63VAAAwAeAAEAAAAAAAAAAD6RDqL8euU9y/GL64eNTIyS2XMEfhFNSFvUSusxUAhSAAAAAQ1UZXN0VXNlcjEtMDAxAAAAApFhPvprSZFKT1VYnou/Y6TmVPBSmryF32ZUuYZiNA+YAQBXXiocqP4Wjiz/QxQ/FDeisocKkX8GADGqHsRuJ4j9cQZn//It0/YjomJuFhWGO6SfkatW66nx7bGv+es7mE8J" + } +} + +20:13:52.863 [ws-worker-3] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +20:13:52.868 [ws-worker-3] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=1, newHash=c4179b0c4d2a107e0a1305bc3470c09268c3576120363ea4c3201ddb6c1b2e9a +20:13:52.868 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-35","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"c4179b0c4d2a107e0a1305bc3470c09268c3576120363ea4c3201ddb6c1b2e9a","ok":true}} +20:13:52.870 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-36", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 7, + "prevBlockHash": "909499d7292a047f2aee933a4eab63fa8c98168d29d6c6d7b1164eb53e0aeaed", + "blockBytesB64": "AACQlJnXKSoEfyrukzpOq2P6jJgWjSnWxtexFk61Pgrq7QAAAJYAAAAHAAAAAGm63VAAAwAKAAEAAAAAAAAABpCUmdcpKgR/Ku6TOk6rY/qMmBaNKdbG17EWTrU+CurtAAAAAg1UZXN0VXNlcjItMDAxAAAAAD6RDqL8euU9y/GL64eNTIyS2XMEfhFNSFvUSusxUAhSAQC2Z/1hRprsw53r1c++nNCEBYGwNXKVnmPWmDdc7NusObvLoLArMR5E4yc3XNjuoOLYUPD+EfRz2R5qjv8aDm4O" + } +} + +20:13:52.873 [ws-worker-4] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=6 thisLineNumber=2 prevLineHashLen=32 +20:13:52.878 [ws-worker-4] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=7, newHash=1b9cf64ae436ca42922aa4aea51085e8dc756b65176359d6ae653b6d44cd33d2 +20:13:52.879 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-36","status":200,"payload":{"serverLastGlobalNumber":7,"serverLastGlobalHash":"1b9cf64ae436ca42922aa4aea51085e8dc756b65176359d6ae653b6d44cd33d2","ok":true}} +20:13:52.881 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-37", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 2, + "prevBlockHash": "c4179b0c4d2a107e0a1305bc3470c09268c3576120363ea4c3201ddb6c1b2e9a", + "blockBytesB64": "AADEF5sMTSoQfgoTBbw0cMCSaMNXYSA2PqTDIB3bbBsumgAAAJYAAAACAAAAAGm63VAAAwAKAAEAAAAAAAAAAcQXmwxNKhB+ChMFvDRwwJJow1dhIDY+pMMgHdtsGy6aAAAAAg1UZXN0VXNlcjEtMDAxAAAAANNRIvB7r8R6DIw6eRznvZkTMRlrVqMf0vAXcx4vrjWTAQB1P71Ocscjkf+08zZI9Mi1nfIyB/muFmLFuwTfr4fZzdA+g1y5oUNORclf5+wW27AK9JiUEb0SjmgUS6OVjRwB" + } +} + +20:13:52.885 [ws-worker-5] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=1 thisLineNumber=2 prevLineHashLen=32 +20:13:52.889 [ws-worker-5] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=2, newHash=8280b1eada06376cec933a9fa8d1290f6e18fcb86607fe4063f87eb52cd93bb5 +20:13:52.890 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-37","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"8280b1eada06376cec933a9fa8d1290f6e18fcb86607fe4063f87eb52cd93bb5","ok":true}} +20:13:52.892 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-38", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 8, + "prevBlockHash": "1b9cf64ae436ca42922aa4aea51085e8dc756b65176359d6ae653b6d44cd33d2", + "blockBytesB64": "AAAbnPZK5DbKQpIqpK6lEIXo3HVrZRdjWdauZTttRM0z0gAAAJYAAAAIAAAAAGm63VAAAwAUAAEAAAAAAAAABxuc9krkNspCkiqkrqUQhejcdWtlF2NZ1q5lO21EzTPSAAAAAw1UZXN0VXNlcjItMDAxAAAAAD6RDqL8euU9y/GL64eNTIyS2XMEfhFNSFvUSusxUAhSAQCY5XMBpJf99lbRe7bwFp0ZtX/0xXiq7N613jHu886RgarIjGQnUuEXJpQnJ+At4F0OLvd1omXJaibnAtosBuUC" + } +} + +20:13:52.896 [ws-worker-6] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=7 thisLineNumber=3 prevLineHashLen=32 +20:13:52.901 [ws-worker-6] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=8, newHash=2a963eb2c69ce261922ee1c58e64e9abaa60273bae8bacb0e11c01ebeb29c444 +20:13:52.901 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-38","status":200,"payload":{"serverLastGlobalNumber":8,"serverLastGlobalHash":"2a963eb2c69ce261922ee1c58e64e9abaa60273bae8bacb0e11c01ebeb29c444","ok":true}} +20:13:52.904 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-39", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 3, + "prevBlockHash": "8280b1eada06376cec933a9fa8d1290f6e18fcb86607fe4063f87eb52cd93bb5", + "blockBytesB64": "AACCgLHq2gY3bOyTOp+o0SkPbhj8uGYH/kBj+H61LNk7tQAAAJYAAAADAAAAAGm63VAAAwAUAAEAAAAAAAAAAoKAseraBjds7JM6n6jRKQ9uGPy4Zgf+QGP4frUs2Tu1AAAAAw1UZXN0VXNlcjEtMDAxAAAAANNRIvB7r8R6DIw6eRznvZkTMRlrVqMf0vAXcx4vrjWTAQBpeuzmjZPby+mO3pX+1T0t8Ul78IVmqTjcyl488ukzPfWNEVCOCmj0pddoc5b+mDcl+ZD99ykzwnnmCNMZ/RcO" + } +} + +20:13:52.908 [ws-worker-7] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=2 thisLineNumber=3 prevLineHashLen=32 +20:13:52.912 [ws-worker-7] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=3, newHash=148a305a3ce533b1fb3333e02965c9a9709dfbdae62ea3d46288085ba74b5d6c +20:13:52.912 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-39","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"148a305a3ce533b1fb3333e02965c9a9709dfbdae62ea3d46288085ba74b5d6c","ok":true}} +20:13:52.915 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-40", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 4, + "prevBlockHash": "148a305a3ce533b1fb3333e02965c9a9709dfbdae62ea3d46288085ba74b5d6c", + "blockBytesB64": "AAAUijBaPOUzsfszM+ApZcmpcJ372uYuo9RiiAhbp0tdbAAAAJYAAAAEAAAAAGm63VAAAQAUAAENVGVzdFVzZXIxLTAwMQAAAAP7rHFD73MwznV5JiGw0MnrHpPIs/w9E8E10tDvtgOZ1QAqVTI6IHJlcGx5IHRvIFUxIE5ld3MgcG9zdCAjMCAoY3Jvc3MtY2hhaW4pAQBQENvJesllao/j5O9+Vlig+ap1YKV/wPJ8wrQMEB1MupoJ/Jvptlg7KF8f9y7VFEEh7NTvFu8MHKidYFRkCs8D" + } +} + +20:13:52.918 [ws-worker-8] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=20 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:13:52.922 [ws-worker-8] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=4, newHash=9185d319d1a8390fcd6daa94c36e6c76544a6ceab8673ee7cd70a1b4f6645035 +20:13:52.922 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-40","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"9185d319d1a8390fcd6daa94c36e6c76544a6ceab8673ee7cd70a1b4f6645035","ok":true}} +20:13:52.924 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-41", + "payload": { + "blockchainName": "TestUser3-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm63VAAAAAAAAFTSGlOZQlUZXN0VXNlcjMBAPgcxpfAY8FzNUjoUKq82Fu3AncrBylD6yq5JUfGMhJabWde3RWFX72d9u79tYpQ8LOqhErXmyie5UUoBnHl2wo=" + } +} + +20:13:52.926 [ws-worker-9] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:13:52.929 [ws-worker-9] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser3, blockchainName=TestUser3-001, blockNumber=0, newHash=8bbe01d70008adcc1e14908000bdcc3a16acabb5c406ec374ee3e63792bf99d1 +20:13:52.929 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-41","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"8bbe01d70008adcc1e14908000bdcc3a16acabb5c406ec374ee3e63792bf99d1","ok":true}} +20:13:52.931 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-42", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 9, + "prevBlockHash": "2a963eb2c69ce261922ee1c58e64e9abaa60273bae8bacb0e11c01ebeb29c444", + "blockBytesB64": "AAAqlj6yxpziYZIu4cWOZOmrqmAnO66LrLDhHAHr6ynERAAAAJYAAAAJAAAAAGm63VAAAwAUAAEAAAAAAAAACCqWPrLGnOJhki7hxY5k6auqYCc7roussOEcAevrKcREAAAABA1UZXN0VXNlcjMtMDAxAAAAAIu+AdcACK3MHhSQgAC9zDoWrKu1xAbsN07j5jeSv5nRAQCgnbCP3XqXyQ6tNAz0OBDfHaMoJseaFw1PAfkioKvWnqdnD3Z0xGdntbosuz5LCSbIrx24D6gee1LYHF/nEroM" + } +} + +20:13:52.934 [ws-worker-10] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=8 thisLineNumber=4 prevLineHashLen=32 +20:13:52.939 [ws-worker-10] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=9, newHash=caad163ab2a1011f8e020bb805da8b58bb512cba016472c836161d2ee258a726 +20:13:52.939 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-42","status":200,"payload":{"serverLastGlobalNumber":9,"serverLastGlobalHash":"caad163ab2a1011f8e020bb805da8b58bb512cba016472c836161d2ee258a726","ok":true}} +20:13:52.940 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-43", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 10, + "prevBlockHash": "caad163ab2a1011f8e020bb805da8b58bb512cba016472c836161d2ee258a726", + "blockBytesB64": "AADKrRY6sqEBH44CC7gF2otYu1EsugFkcsg2Fh0u4linJgAAAJYAAAAKAAAAAGm63VAAAwAVAAEAAAAAAAAACcqtFjqyoQEfjgILuAXai1i7USy6AWRyyDYWHS7iWKcmAAAABQ1UZXN0VXNlcjItMDAxAAAAAD6RDqL8euU9y/GL64eNTIyS2XMEfhFNSFvUSusxUAhSAQD+GbTn0K+NUheBOYRERu5Zs05v/dzWZp2oGMExYXwV2sHOemFdevmvZBD/fcr7/G1QLHwRnie9aQsRWx3qPv4D" + } +} + +20:13:52.943 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=21 lineCode=0 prevLineNumber=9 thisLineNumber=5 prevLineHashLen=32 +20:13:52.948 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=10, newHash=c340da73b022592c1053bcb9ae0bedb60d1dc86ae75abd728f1cbb740cf612c7 +20:13:52.948 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-43","status":200,"payload":{"serverLastGlobalNumber":10,"serverLastGlobalHash":"c340da73b022592c1053bcb9ae0bedb60d1dc86ae75abd728f1cbb740cf612c7","ok":true}} +20:13:52.949 [qtp396378551-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.956 [qtp396378551-37] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46090 +20:13:52.957 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-44", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1773854032950, + "value": "Anna", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "2pSPMguvuNYp6znwqKHc51rxRSinicKMsiB6pzuNiBA/M2PTW1unkHr2+67+35sGnJO+u99avViOEuFZ8V0VBw==" + } +} + +20:13:52.962 [ws-worker-12] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1773854032950 +20:13:52.965 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-44","status":200,"payload":{"ok":true}} +20:13:52.966 [qtp396378551-37] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.968 [qtp396378551-26] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46098 +20:13:52.970 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-45", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +20:13:52.974 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-45","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1773854032950,"value":"Anna","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"2pSPMguvuNYp6znwqKHc51rxRSinicKMsiB6pzuNiBA/M2PTW1unkHr2+67+35sGnJO+u99avViOEuFZ8V0VBw==","ok":true}} +20:13:52.975 [qtp396378551-26] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.979 [qtp396378551-56] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46114 +20:13:52.980 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-46", + "payload": { + "login": "TestUser1", + "param": "profile:city", + "time_ms": 1773854032960, + "value": "Amsterdam", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "Z1Pw1LDBS1YVis4apT72AA5hsbaXFQIUIG1foYeNKaEvsPBsGvwiPz0VxCeZcOWQto0bhhHek0GnwT6E7BkUAw==" + } +} + +20:13:52.983 [ws-worker-14] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:city, time_ms=1773854032960 +20:13:52.985 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-46","status":200,"payload":{"ok":true}} +20:13:52.986 [qtp396378551-56] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.989 [qtp396378551-41] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46128 +20:13:52.989 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-47", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1773854032970, + "value": "Anna Updated", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "9zwzgoMAtKmEUXZQAtaZ0eVtbNYqM6DTivuQJitSVNaHV2ksx6XAxIPVNnpj6bHSw7mAnbC8fHjBSjGb6zu9DA==" + } +} + +20:13:52.992 [ws-worker-15] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1773854032970 +20:13:52.994 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-47","status":200,"payload":{"ok":true}} +20:13:52.995 [qtp396378551-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:52.998 [qtp396378551-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46130 +20:13:52.999 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-48", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +20:13:53.000 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-48","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1773854032970,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"9zwzgoMAtKmEUXZQAtaZ0eVtbNYqM6DTivuQJitSVNaHV2ksx6XAxIPVNnpj6bHSw7mAnbC8fHjBSjGb6zu9DA==","ok":true}} +20:13:53.001 [qtp396378551-28] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:53.003 [qtp396378551-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46142 +20:13:53.004 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListUserParams", + "requestId": "it-listparams-49", + "payload": { "login": "TestUser1" } +} + +20:13:53.007 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListUserParams","requestId":"it-listparams-49","status":200,"payload":{"login":"TestUser1","params":[{"login":"TestUser1","param":"profile:name","time_ms":1773854032970,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"9zwzgoMAtKmEUXZQAtaZ0eVtbNYqM6DTivuQJitSVNaHV2ksx6XAxIPVNnpj6bHSw7mAnbC8fHjBSjGb6zu9DA=="},{"login":"TestUser1","param":"profile:city","time_ms":1773854032960,"value":"Amsterdam","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"Z1Pw1LDBS1YVis4apT72AA5hsbaXFQIUIG1foYeNKaEvsPBsGvwiPz0VxCeZcOWQto0bhhHek0GnwT6E7BkUAw=="}],"ok":true}} +20:13:53.008 [qtp396378551-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:13:53.011 [qtp396378551-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:46150 +20:13:53.012 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-50", + "payload": { + "login": "TestUser1" + } +} + +20:13:53.016 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-50","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +20:13:53.018 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-51", + "payload": { + "login": "Testuser1" + } +} + +20:13:53.019 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-51","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +20:13:53.020 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-52", + "payload": { + "login": "TestUser2" + } +} + +20:13:53.021 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-52","status":200,"payload":{"login":"TestUser2","out_friends":["TestUser1"],"in_friends":["TestUser1"],"ok":true}} +20:13:53.021 [qtp396378551-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:18.823 [wsServer-thread] INFO s.ws.BlockchainTmpRecoveryOnStartup - 🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется. +20:14:18.892 [wsServer-thread] INFO org.eclipse.jetty.server.Server - jetty-11.0.20; built: 2024-01-29T21:04:22.394Z; git: 922f8dc188f7011e60d0361de585fd4ac4d63064; jvm 17.0.18+8-Ubuntu-124.04.1 +20:14:18.953 [wsServer-thread] INFO o.e.j.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@2b7f2622{/,null,AVAILABLE} +20:14:18.960 [wsServer-thread] INFO o.e.jetty.server.AbstractConnector - Started ServerConnector@56ee516b{HTTP/1.1, (http/1.1)}{0.0.0.0:7070} +20:14:18.965 [wsServer-thread] INFO org.eclipse.jetty.server.Server - Started Server@3171580c{STARTING}[11.0.20,sto=0] @842ms +20:14:18.965 [wsServer-thread] INFO server.ws.WsServer - ✅ WS сервер запущен на ws://localhost:7070/ws +20:14:19.705 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44482 +20:14:19.739 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-1", + "payload": { + "login": "TestUser1", + "blockchainName": "TestUser1-001", + "solanaKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "blockchainKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "deviceKey": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "bchLimit": 50000000 + } +} + +20:14:19.792 [ws-worker-1] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser1, blockchainName=TestUser1-001, limit=50000000 +20:14:19.804 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-1","status":200,"payload":{"ok":true}} +20:14:19.808 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-2", + "payload": { + "login": "TestUser1" + } +} + +20:14:19.813 [ws-worker-2] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +20:14:19.814 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-2","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +20:14:19.817 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-3", + "payload": { + "login": "TestUser2", + "blockchainName": "TestUser2-001", + "solanaKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "blockchainKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "deviceKey": "Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=", + "bchLimit": 50000000 + } +} + +20:14:19.823 [ws-worker-3] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser2, blockchainName=TestUser2-001, limit=50000000 +20:14:19.823 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-3","status":200,"payload":{"ok":true}} +20:14:19.825 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-4", + "payload": { + "login": "TestUser2" + } +} + +20:14:19.827 [ws-worker-4] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser2, blockchainName=TestUser2-001 +20:14:19.827 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-4","status":200,"payload":{"exists":true,"login":"TestUser2","blockchainName":"TestUser2-001","solanaKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","blockchainKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","deviceKey":"Sdfdd5XRWFzm66XDq850Xe+xZByUaEX4yhW39yJytxs=","ok":true}} +20:14:19.829 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddUser", + "requestId": "it-adduser-5", + "payload": { + "login": "TestUser3", + "blockchainName": "TestUser3-001", + "solanaKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "blockchainKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "deviceKey": "W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=", + "bchLimit": 50000000 + } +} + +20:14:19.834 [ws-worker-5] INFO s.l.w.J.h.t.Net_AddUser_Handler - ✅ AddUser ok: login=TestUser3, blockchainName=TestUser3-001, limit=50000000 +20:14:19.834 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddUser","requestId":"it-adduser-5","status":200,"payload":{"ok":true}} +20:14:19.836 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-6", + "payload": { + "login": "TestUser3" + } +} + +20:14:19.838 [ws-worker-6] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser3, blockchainName=TestUser3-001 +20:14:19.838 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-6","status":200,"payload":{"exists":true,"login":"TestUser3","blockchainName":"TestUser3-001","solanaKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","blockchainKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","deviceKey":"W/SdJOdxg2WWEMdG66o1hMbjg9ej0X+gWy4uJnzexe4=","ok":true}} +20:14:19.841 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-7", + "payload": { + "login": "Testuser1" + } +} + +20:14:19.843 [ws-worker-7] INFO s.l.w.J.h.t.Net_GetUser_Handler - ✅ GetUser: found login=TestUser1, blockchainName=TestUser1-001 +20:14:19.843 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-7","status":200,"payload":{"exists":true,"login":"TestUser1","blockchainName":"TestUser1-001","solanaKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","blockchainKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","deviceKey":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","ok":true}} +20:14:19.845 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUser", + "requestId": "it-getuser-8", + "payload": { + "login": "NoSuchUser_987654321" + } +} + +20:14:19.848 [ws-worker-8] INFO s.l.w.J.h.t.Net_GetUser_Handler - ℹ️ GetUser: not found for login=NoSuchUser_987654321 +20:14:19.848 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUser","requestId":"it-getuser-8","status":200,"payload":{"exists":false,"ok":true}} +20:14:19.850 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SearchUsers", + "requestId": "it-searchusers-9", + "payload": { + "prefix": "Tes" + } +} + +20:14:19.855 [ws-worker-9] INFO s.l.w.J.h.t.Net_SearchUsers_Handler - ✅ SearchUsers ok: prefix='Tes' -> 3 +20:14:19.858 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SearchUsers","requestId":"it-searchusers-9","status":200,"payload":{"logins":["TestUser1","TestUser2","TestUser3"],"ok":true}} +20:14:19.864 [qtp1582446160-37] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:19.868 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44496 +20:14:19.869 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-10", + "payload": { "login": "TestUser1" } +} + +20:14:19.873 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-10","status":200,"payload":{"authNonce":"udVgplshRvj1VqxFg+7r6FsWgl+rJ3LSeklO1KwLH3M","ok":true}} +20:14:19.881 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-11", + "payload": { + "storagePwd": "pwd-2964738635206", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773854059877, + "signatureB64": "7gmRJQFTcTzchbxMoZaMJq7Pmd7NymLziFrZrMhymGLEifMM+3E0MDd1hIq4dihSpVJg0AtgbHPZ5nvorLYSAw==", + "clientInfo": "it-tests" + } +} + +20:14:19.892 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-11","status":200,"payload":{"sessionId":"Schmy3cBlfEUrjaFyH1/vTcB1FFs4x1xVxb1+JIyzUY","ok":true}} +20:14:19.894 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:19.897 [qtp1582446160-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44500 +20:14:19.898 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-12", + "payload": { "login": "TestUser1" } +} + +20:14:19.901 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-12","status":200,"payload":{"authNonce":"LLlasP7CXxEZ6P7RwrX/fqfNGd+ZhtoKi71dnsS8CG4","ok":true}} +20:14:19.903 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-13", + "payload": { + "storagePwd": "pwd-2964763305319", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773854059902, + "signatureB64": "PwKgqonubAivoAeXTAkBcGe74nwGIw9JUw5FogdemKGn/zFW/yk9lPbi/5Fxp4K8TYgQVn6eo1RTNtX3xn7kAg==", + "clientInfo": "it-tests" + } +} + +20:14:19.910 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-13","status":200,"payload":{"sessionId":"CoJEE6cPFciGHvYGrF8+37yXJ4J/QY8rKYzPEyKUBCA","ok":true}} +20:14:19.911 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:19.915 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44508 +20:14:19.916 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-14", + "payload": { "login": "TestUser1" } +} + +20:14:19.919 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-14","status":200,"payload":{"authNonce":"MdxzlC8G4fS8ghvkR2sHXe34tT1zwHzKnudKsDDKsIs","ok":true}} +20:14:19.921 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-15", + "payload": { + "storagePwd": "pwd-2964781298382", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773854059920, + "signatureB64": "oI3f+ZA3X5lA7L6vWxlLNb1k3l8oWODx539YVBCpXptFeAnd1pfPH053Em6w6ODKakYI9yrN8HH/XVq71jfoAQ==", + "clientInfo": "it-tests" + } +} + +20:14:19.927 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-15","status":200,"payload":{"sessionId":"yOazK4el16Jxexx8ME/QDQRe/OajY+eeqi8CAmDmRkI","ok":true}} +20:14:19.928 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:19.930 [qtp1582446160-30] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44522 +20:14:19.932 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-16", + "payload": { + "sessionId": "Schmy3cBlfEUrjaFyH1/vTcB1FFs4x1xVxb1+JIyzUY" + } +} + +20:14:19.939 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-16","status":200,"payload":{"nonce":"6OWYB2jPIwOznY9b5t/OZjdaryR5a5Yhnn2RWvU8Bxw","ok":true}} +20:14:19.941 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-17", + "payload": { + "sessionId": "Schmy3cBlfEUrjaFyH1/vTcB1FFs4x1xVxb1+JIyzUY", + "timeMs": 1773854059940, + "signatureB64": "tpqLtbNmtQpBpvDD/XvXJz9NfbN14mRxN+rJhhZV8LHj4jL9VgYntPrhjY0gXrBoAxOYfLe3Q3/p5mfpw7LoBA==", + "clientInfo": "it-tests" + } +} + +20:14:19.949 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-17","status":200,"payload":{"storagePwd":"pwd-2964738635206","ok":true}} +20:14:19.952 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-18", + "payload": { + } +} + +20:14:19.958 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-18","status":200,"payload":{"sessions":[{"sessionId":"Schmy3cBlfEUrjaFyH1/vTcB1FFs4x1xVxb1+JIyzUY","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854059944},{"sessionId":"CoJEE6cPFciGHvYGrF8+37yXJ4J/QY8rKYzPEyKUBCA","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854059904},{"sessionId":"yOazK4el16Jxexx8ME/QDQRe/OajY+eeqi8CAmDmRkI","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854059922}],"ok":true}} +20:14:19.960 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:19.965 [qtp1582446160-31] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44530 +20:14:19.966 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AuthChallenge", + "requestId": "it-auth-19", + "payload": { "login": "TestUser1" } +} + +20:14:19.968 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AuthChallenge","requestId":"it-auth-19","status":200,"payload":{"authNonce":"Rkuk2PP2guUlZbYz3f/wh/rKr/0jG7XMAiKzkbcMvVA","ok":true}} +20:14:19.970 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CreateAuthSession", + "requestId": "it-create-20", + "payload": { + "storagePwd": "pwd-2964830581469", + "sessionPubKeyB64": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "timeMs": 1773854059969, + "signatureB64": "yfDmWzXmB+V7yf9v9R1cc5Ymvu7uv6AQq60eLDWKQieRRR+246Mykjnl7ZcfV9rXo6jfQU8c5S/VzuWwbvBdBA==", + "clientInfo": "it-tests" + } +} + +20:14:19.976 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CreateAuthSession","requestId":"it-create-20","status":200,"payload":{"sessionId":"/C8TQxnOYJl8S63mk9EqGBgHqGP7rmZ4IJsJengUIJU","ok":true}} +20:14:19.977 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:19.980 [qtp1582446160-32] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44534 +20:14:19.981 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-21", + "payload": { + "sessionId": "Schmy3cBlfEUrjaFyH1/vTcB1FFs4x1xVxb1+JIyzUY" + } +} + +20:14:19.982 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-21","status":200,"payload":{"nonce":"ToynkFqpQqv4CXHDWpKfPkjQUTS0/gUGagV0rm1/sAc","ok":true}} +20:14:19.984 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-22", + "payload": { + "sessionId": "Schmy3cBlfEUrjaFyH1/vTcB1FFs4x1xVxb1+JIyzUY", + "timeMs": 1773854059983, + "signatureB64": "b/pU0gXlaYzmZOSn7Y836klq5DKD+BTJO3wQPeqlaT9lVvSHCht3F2dNLZbXAifUirqyZrHHe/seWHRsW+K4Cg==", + "clientInfo": "it-tests" + } +} + +20:14:19.991 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-22","status":200,"payload":{"storagePwd":"pwd-2964738635206","ok":true}} +20:14:19.991 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "CloseActiveSession", + "requestId": "it-close-23", + "payload": { + "sessionId": "/C8TQxnOYJl8S63mk9EqGBgHqGP7rmZ4IJsJengUIJU" + } +} + +20:14:19.998 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"CloseActiveSession","requestId":"it-close-23","status":200,"payload":{"ok":true}} +20:14:19.999 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:20.001 [qtp1582446160-25] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44550 +20:14:20.003 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionChallenge", + "requestId": "it-sch-24", + "payload": { + "sessionId": "Schmy3cBlfEUrjaFyH1/vTcB1FFs4x1xVxb1+JIyzUY" + } +} + +20:14:20.005 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionChallenge","requestId":"it-sch-24","status":200,"payload":{"nonce":"I5TDATsWqTWUrvPefvqBblJtlCc0qWxz361ykVIexNc","ok":true}} +20:14:20.007 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "SessionLogin", + "requestId": "it-slogin-25", + "payload": { + "sessionId": "Schmy3cBlfEUrjaFyH1/vTcB1FFs4x1xVxb1+JIyzUY", + "timeMs": 1773854060006, + "signatureB64": "ZGoxEiCnt7ggggWLgSLdVVrR80ioAUHwZmt+M2NySJ1nEk5b32B9eTEgSoxbSYvx55biGJVB1PhzCkxiiBKGDg==", + "clientInfo": "it-tests" + } +} + +20:14:20.013 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"SessionLogin","requestId":"it-slogin-25","status":200,"payload":{"storagePwd":"pwd-2964738635206","ok":true}} +20:14:20.014 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListSessions", + "requestId": "it-list-26", + "payload": { + } +} + +20:14:20.015 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListSessions","requestId":"it-list-26","status":200,"payload":{"sessions":[{"sessionId":"Schmy3cBlfEUrjaFyH1/vTcB1FFs4x1xVxb1+JIyzUY","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854060009},{"sessionId":"CoJEE6cPFciGHvYGrF8+37yXJ4J/QY8rKYzPEyKUBCA","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854059904},{"sessionId":"yOazK4el16Jxexx8ME/QDQRe/OajY+eeqi8CAmDmRkI","clientInfoFromClient":"it-tests","clientInfoFromRequest":"UA=Java-http-client/17.0.18; remote=127.0.0.1","geo":"unknown","lastAuthirificatedAtMs":1773854059922}],"ok":true}} +20:14:20.017 [qtp1582446160-30] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:20.021 [qtp1582446160-42] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44566 +20:14:20.032 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-27", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm63WwAAAAAAAFTSGlOZQlUZXN0VXNlcjEBAOgYo10H+gAeEEO3gcmVs+7lUnM1OKmTkGF2Hcme5LgbD+RqVwJ6lxTZRdOf8WQr4Uc5d+BmXJx3BFyBHZIG7Qc=" + } +} + +20:14:20.037 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:14:20.041 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=0, newHash=9f3f24c98ec71aa8ace8ee2bae56152eed80b9c30d1a5635a68c46041df878ee +20:14:20.042 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-27","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"9f3f24c98ec71aa8ace8ee2bae56152eed80b9c30d1a5635a68c46041df878ee","ok":true}} +20:14:20.046 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-28", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 1, + "prevBlockHash": "9f3f24c98ec71aa8ace8ee2bae56152eed80b9c30d1a5635a68c46041df878ee", + "blockBytesB64": "AACfPyTJjscaqKzo7iuuVhUu7YC5ww0aVjWmjEYEHfh47gAAAIEAAAABAAAAAGm63WwAAQAKAAEAAAAAAAAAAJ8/JMmOxxqorOjuK65WFS7tgLnDDRpWNaaMRgQd+HjuAAAAAAAbVTE6IHN0b3J5L3Bvc3QgaW4gY2hhbm5lbCAwAQBq91cvMI+aw1Ieut3RXL5UOJINPDSqo6S59IuCgDIrpbZnpoeoWT3r7PBixnM2WoW+K0DpraFNEN+0zk0SmyEH" + } +} + +20:14:20.048 [ws-worker-12] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=0 prevLineNumber=0 thisLineNumber=0 prevLineHashLen=32 +20:14:20.052 [ws-worker-12] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=1, newHash=85ce7e077adbf432700c450b9dc4f8ad0797a0a8f245d852e1da7375e45d1835 +20:14:20.052 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-28","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"85ce7e077adbf432700c450b9dc4f8ad0797a0a8f245d852e1da7375e45d1835","ok":true}} +20:14:20.054 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-29", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 2, + "prevBlockHash": "85ce7e077adbf432700c450b9dc4f8ad0797a0a8f245d852e1da7375e45d1835", + "blockBytesB64": "AACFzn4Hetv0MnAMRQudxPitB5egqPJF2FLh2nN15F0YNQAAAGkAAAACAAAAAGm63WwAAAABAAEAAAAAAAAAAJ8/JMmOxxqorOjuK65WFS7tgLnDDRpWNaaMRgQd+HjuAAAAAQROZXdzAQAlx//FiPs1yE+OGXBMqUVgApsiUiCQ91j1l7qJXTh4zvPSNLd5NHxZ+4WWvb/nfWSJWgFd+Ia6bRQ5JVquIbgK" + } +} + +20:14:20.057 [ws-worker-13] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=1 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +20:14:20.062 [ws-worker-13] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=2, newHash=7f92da46d5569259e605747f9d630a702afc5061f5f505e0703fa02d25f74df0 +20:14:20.062 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-29","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"7f92da46d5569259e605747f9d630a702afc5061f5f505e0703fa02d25f74df0","ok":true}} +20:14:20.064 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-30", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 3, + "prevBlockHash": "7f92da46d5569259e605747f9d630a702afc5061f5f505e0703fa02d25f74df0", + "blockBytesB64": "AAB/ktpG1VaSWeYFdH+dYwpwKvxQYfX1BeBwP6AtJfdN8AAAAHYAAAADAAAAAGm63WwAAQAKAAEAAAACAAAAAn+S2kbVVpJZ5gV0f51jCnAq/FBh9fUF4HA/oC0l903wAAAAAAAQVTE6IE5ld3MgcG9zdCAjMAEA/TBKOM7SbZMlf2ILOIRRuzdh6G6ZUPNqG1aJRPU3PJuf5Z98l+38TxnLhjNeK48fbg0X1zxpUI2mraj5T3G/BQ==" + } +} + +20:14:20.068 [ws-worker-14] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=2 thisLineNumber=0 prevLineHashLen=32 +20:14:20.072 [ws-worker-14] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=3, newHash=72d43525982fa2a10b1866d238608c1b26360ea8647178de76381ee4ec2bc594 +20:14:20.072 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-30","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"72d43525982fa2a10b1866d238608c1b26360ea8647178de76381ee4ec2bc594","ok":true}} +20:14:20.074 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-31", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 4, + "prevBlockHash": "72d43525982fa2a10b1866d238608c1b26360ea8647178de76381ee4ec2bc594", + "blockBytesB64": "AABy1DUlmC+ioQsYZtI4YIwbJjYOqGRxeN52OB7k7CvFlAAAAHYAAAAEAAAAAGm63WwAAQAKAAEAAAACAAAAA3LUNSWYL6KhCxhm0jhgjBsmNg6oZHF43nY4HuTsK8WUAAAAAQAQVTE6IE5ld3MgcG9zdCAjMQEAfgxgfy0D8Sp1aHW+vT87c/pf+HMFLG+set1yLoiploMx55Ra2BKrhQt3JtuobGabuoJwi/dri0DNOjlfaN/OBQ==" + } +} + +20:14:20.076 [ws-worker-15] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=10 lineCode=2 prevLineNumber=3 thisLineNumber=1 prevLineHashLen=32 +20:14:20.083 [ws-worker-15] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=4, newHash=60f03f2436a1ff47d46f4e3cdda0ee1805e7f4a79e3da9172a033605bb8a7e9c +20:14:20.083 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-31","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"60f03f2436a1ff47d46f4e3cdda0ee1805e7f4a79e3da9172a033605bb8a7e9c","ok":true}} +20:14:20.085 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-32", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 5, + "prevBlockHash": "60f03f2436a1ff47d46f4e3cdda0ee1805e7f4a79e3da9172a033605bb8a7e9c", + "blockBytesB64": "AABg8D8kNqH/R9RvTjzdoO4YBef0p549qRcqAzYFu4p+nAAAAKEAAAAFAAAAAGm63WwAAQALAAEAAAACAAAABGDwPyQ2of9H1G9OPN2g7hgF5/Snnj2pFyoDNgW7in6cAAAAAgAAAANy1DUlmC+ioQsYZtI4YIwbJjYOqGRxeN52OB7k7CvFlAAXVTE6IE5ld3MgcG9zdCAjMCAoRURJVCkBALi7rciuLRaV8d6eNKYqsTsXacxG1z71CVLTidmAgCBwCrS6WZPjH7fKAdoux70dCdZgzBcS8agQ0T9HKJm2WgE=" + } +} + +20:14:20.088 [ws-worker-16] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=11 lineCode=2 prevLineNumber=4 thisLineNumber=2 prevLineHashLen=32 +20:14:20.092 [ws-worker-16] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=5, newHash=2f963c5f7214a1c109367b74bf07c659d4284e0f0acd84a85b24c3afbda47e69 +20:14:20.093 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-32","status":200,"payload":{"serverLastGlobalNumber":5,"serverLastGlobalHash":"2f963c5f7214a1c109367b74bf07c659d4284e0f0acd84a85b24c3afbda47e69","ok":true}} +20:14:20.095 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-33", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm63WwAAAAAAAFTSGlOZQlUZXN0VXNlcjIBAHUTXnceunvUBOTWwM8k9NBINmlpkBBcIWKSarGiYPAr6/uguh96M/2P4hRMOtBN0SjREnUqs4YZls10Vi3TkwY=" + } +} + +20:14:20.097 [ws-worker-1] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:14:20.101 [ws-worker-1] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=0, newHash=99f004e851986f85106171e27b19cc4fc4d65cfff8eb4253e90f632b4a4a01dd +20:14:20.102 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-33","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"99f004e851986f85106171e27b19cc4fc4d65cfff8eb4253e90f632b4a4a01dd","ok":true}} +20:14:20.107 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-34", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 6, + "prevBlockHash": "2f963c5f7214a1c109367b74bf07c659d4284e0f0acd84a85b24c3afbda47e69", + "blockBytesB64": "AAAvljxfchShwQk2e3S/B8ZZ1ChODwrNhKhbJMOvvaR+aQAAAJYAAAAGAAAAAGm63WwAAwAeAAEAAAAAAAAAAJ8/JMmOxxqorOjuK65WFS7tgLnDDRpWNaaMRgQd+HjuAAAAAQ1UZXN0VXNlcjItMDAxAAAAAJnwBOhRmG+FEGFx4nsZzE/E1lz/+OtCU+kPYytKSgHdAQAtsfbY87Q8a2gCrkivkOhiP3262jzPs04E0zxMLX4iuyVUbLKPBLTBszTha7831ueRoB0+vDDTo5tp+SzZAYEO" + } +} + +20:14:20.112 [ws-worker-2] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +20:14:20.117 [ws-worker-2] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=6, newHash=eac177c78ab2692ada550acbc7a44f225c2a337891ac2b306724d4dab64ee079 +20:14:20.118 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-34","status":200,"payload":{"serverLastGlobalNumber":6,"serverLastGlobalHash":"eac177c78ab2692ada550acbc7a44f225c2a337891ac2b306724d4dab64ee079","ok":true}} +20:14:20.121 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-35", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 1, + "prevBlockHash": "99f004e851986f85106171e27b19cc4fc4d65cfff8eb4253e90f632b4a4a01dd", + "blockBytesB64": "AACZ8AToUZhvhRBhceJ7GcxPxNZc//jrQlPpD2MrSkoB3QAAAJYAAAABAAAAAGm63WwAAwAeAAEAAAAAAAAAAJnwBOhRmG+FEGFx4nsZzE/E1lz/+OtCU+kPYytKSgHdAAAAAQ1UZXN0VXNlcjEtMDAxAAAAAn+S2kbVVpJZ5gV0f51jCnAq/FBh9fUF4HA/oC0l903wAQCheKCRjM7TSGy0lnOyrxwr80hfvzjwDllqedG1OXyEnI3szrS9tz6DvUC3E98PZ9vB7xX3SVqT5TdvwFZktXIA" + } +} + +20:14:20.124 [ws-worker-3] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=30 lineCode=0 prevLineNumber=0 thisLineNumber=1 prevLineHashLen=32 +20:14:20.129 [ws-worker-3] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=1, newHash=d2e00d5c07e9e5d1e631d8ff3aa81dd22c1f839ea926b682bc64ef3160451456 +20:14:20.130 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-35","status":200,"payload":{"serverLastGlobalNumber":1,"serverLastGlobalHash":"d2e00d5c07e9e5d1e631d8ff3aa81dd22c1f839ea926b682bc64ef3160451456","ok":true}} +20:14:20.132 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-36", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 7, + "prevBlockHash": "eac177c78ab2692ada550acbc7a44f225c2a337891ac2b306724d4dab64ee079", + "blockBytesB64": "AADqwXfHirJpKtpVCsvHpE8iXCozeJGsKzBnJNTatk7geQAAAJYAAAAHAAAAAGm63WwAAwAKAAEAAAAAAAAABurBd8eKsmkq2lUKy8ekTyJcKjN4kawrMGck1Nq2TuB5AAAAAg1UZXN0VXNlcjItMDAxAAAAAJnwBOhRmG+FEGFx4nsZzE/E1lz/+OtCU+kPYytKSgHdAQC2Vn5kGWZfdj44ddvLHMgwq0Rg3y7f/XKBvkpgXQ0FDod8WF8D0qkvi+RWS2jm9ivmg6EXCGOS6B50tWXLYE4A" + } +} + +20:14:20.136 [ws-worker-4] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=6 thisLineNumber=2 prevLineHashLen=32 +20:14:20.141 [ws-worker-4] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=7, newHash=cd1ce20ce4f9f03fe6abb0fd5863b3c599e63e892fb7e81b0a44f2618a061d16 +20:14:20.141 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-36","status":200,"payload":{"serverLastGlobalNumber":7,"serverLastGlobalHash":"cd1ce20ce4f9f03fe6abb0fd5863b3c599e63e892fb7e81b0a44f2618a061d16","ok":true}} +20:14:20.143 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-37", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 2, + "prevBlockHash": "d2e00d5c07e9e5d1e631d8ff3aa81dd22c1f839ea926b682bc64ef3160451456", + "blockBytesB64": "AADS4A1cB+nl0eYx2P86qB3SLB+DnqkmtoK8ZO8xYEUUVgAAAJYAAAACAAAAAGm63WwAAwAKAAEAAAAAAAAAAdLgDVwH6eXR5jHY/zqoHdIsH4OeqSa2grxk7zFgRRRWAAAAAg1UZXN0VXNlcjEtMDAxAAAAAJ8/JMmOxxqorOjuK65WFS7tgLnDDRpWNaaMRgQd+HjuAQBfhLCUsX+nbJKWpziTjIq8zmG6apkHNI449h7cqwBnoYA79p/CJCTb9idL4TqctIPoJlPGuY3C85rFpCDtGhYG" + } +} + +20:14:20.147 [ws-worker-5] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=10 lineCode=0 prevLineNumber=1 thisLineNumber=2 prevLineHashLen=32 +20:14:20.152 [ws-worker-5] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=2, newHash=df81e6259b1f88cb86ebb88405c30f317794a57defe4d12f3f964017c2d8c44e +20:14:20.153 [ws-worker-5] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-37","status":200,"payload":{"serverLastGlobalNumber":2,"serverLastGlobalHash":"df81e6259b1f88cb86ebb88405c30f317794a57defe4d12f3f964017c2d8c44e","ok":true}} +20:14:20.156 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-38", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 8, + "prevBlockHash": "cd1ce20ce4f9f03fe6abb0fd5863b3c599e63e892fb7e81b0a44f2618a061d16", + "blockBytesB64": "AADNHOIM5PnwP+arsP1YY7PFmeY+iS+36BsKRPJhigYdFgAAAJYAAAAIAAAAAGm63WwAAwAUAAEAAAAAAAAAB80c4gzk+fA/5quw/Vhjs8WZ5j6JL7foGwpE8mGKBh0WAAAAAw1UZXN0VXNlcjItMDAxAAAAAJnwBOhRmG+FEGFx4nsZzE/E1lz/+OtCU+kPYytKSgHdAQAzrJjpjr+5ETwtaxWFat7AM9EWHomqGJMYR28iNEizPo0EE9F2amcpu6hPvk2Lfax/qEoLn1TFeCTihsamokkO" + } +} + +20:14:20.161 [ws-worker-6] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=7 thisLineNumber=3 prevLineHashLen=32 +20:14:20.166 [ws-worker-6] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=8, newHash=1a72a290d211d3fd0148081c02cebb300cc8400d8b3a12d9a67e879d7eeddff3 +20:14:20.167 [ws-worker-6] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-38","status":200,"payload":{"serverLastGlobalNumber":8,"serverLastGlobalHash":"1a72a290d211d3fd0148081c02cebb300cc8400d8b3a12d9a67e879d7eeddff3","ok":true}} +20:14:20.170 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-39", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 3, + "prevBlockHash": "df81e6259b1f88cb86ebb88405c30f317794a57defe4d12f3f964017c2d8c44e", + "blockBytesB64": "AADfgeYlmx+Iy4bruIQFww8xd5Slfe/k0S8/lkAXwtjETgAAAJYAAAADAAAAAGm63WwAAwAUAAEAAAAAAAAAAt+B5iWbH4jLhuu4hAXDDzF3lKV97+TRLz+WQBfC2MROAAAAAw1UZXN0VXNlcjEtMDAxAAAAAJ8/JMmOxxqorOjuK65WFS7tgLnDDRpWNaaMRgQd+HjuAQAiZQTLZLIhuNo/1AFBDyTSxo+RQz+pzD6IQjdFCbtPMYqsNJrWWBIHhZre4uv1aHdP4b0CusA2TXv7V78PWgsA" + } +} + +20:14:20.174 [ws-worker-7] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=2 thisLineNumber=3 prevLineHashLen=32 +20:14:20.179 [ws-worker-7] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=3, newHash=8208fe7332f7324f1600ca5991f53c74e76e94dfeee44fc1b28b2364875c92a2 +20:14:20.179 [ws-worker-7] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-39","status":200,"payload":{"serverLastGlobalNumber":3,"serverLastGlobalHash":"8208fe7332f7324f1600ca5991f53c74e76e94dfeee44fc1b28b2364875c92a2","ok":true}} +20:14:20.183 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-40", + "payload": { + "blockchainName": "TestUser2-001", + "blockNumber": 4, + "prevBlockHash": "8208fe7332f7324f1600ca5991f53c74e76e94dfeee44fc1b28b2364875c92a2", + "blockBytesB64": "AACCCP5zMvcyTxYAylmR9Tx0526U3+7kT8GyiyNkh1ySogAAAJYAAAAEAAAAAGm63WwAAQAUAAENVGVzdFVzZXIxLTAwMQAAAANy1DUlmC+ioQsYZtI4YIwbJjYOqGRxeN52OB7k7CvFlAAqVTI6IHJlcGx5IHRvIFUxIE5ld3MgcG9zdCAjMCAoY3Jvc3MtY2hhaW4pAQA9SE6lJ4CD4Lk2L3xA2lALLvjZEEOmbJUsBeHRa7dJin3Z7JWvWTe1E85mnh7tqtPqAh1b1XK1xg5Pd14YqMkJ" + } +} + +20:14:20.186 [ws-worker-8] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=1 sub=20 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:14:20.192 [ws-worker-8] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser2, blockchainName=TestUser2-001, blockNumber=4, newHash=58ce17749a45f77119754a465c3d6991de648605a238e0539e4c27d8123196c0 +20:14:20.192 [ws-worker-8] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-40","status":200,"payload":{"serverLastGlobalNumber":4,"serverLastGlobalHash":"58ce17749a45f77119754a465c3d6991de648605a238e0539e4c27d8123196c0","ok":true}} +20:14:20.194 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-41", + "payload": { + "blockchainName": "TestUser3-001", + "blockNumber": 0, + "prevBlockHash": "0000000000000000000000000000000000000000000000000000000000000000", + "blockBytesB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcAAAAAAAAAAGm63WwAAAAAAAFTSGlOZQlUZXN0VXNlcjMBALHGkdu5edZTL/uI/jYJdoyEKCPPCMLdvzSMOxlGC08tyDr2tduBsRleFUHoG2UyWY+xTg6KGJXbuV/WNbw7WwQ=" + } +} + +20:14:20.198 [ws-worker-9] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=0 sub=0 lineCode=null prevLineNumber=null thisLineNumber=null prevLineHashLen=null +20:14:20.203 [ws-worker-9] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser3, blockchainName=TestUser3-001, blockNumber=0, newHash=2dbf6cb3b3ef73ae64374ebd641805d37ebd7ba66f09ffabe5a1be280902f25b +20:14:20.203 [ws-worker-9] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-41","status":200,"payload":{"serverLastGlobalNumber":0,"serverLastGlobalHash":"2dbf6cb3b3ef73ae64374ebd641805d37ebd7ba66f09ffabe5a1be280902f25b","ok":true}} +20:14:20.205 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-42", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 9, + "prevBlockHash": "1a72a290d211d3fd0148081c02cebb300cc8400d8b3a12d9a67e879d7eeddff3", + "blockBytesB64": "AAAacqKQ0hHT/QFICBwCzrswDMhADYs6Etmmfoedfu3f8wAAAJYAAAAJAAAAAGm63WwAAwAUAAEAAAAAAAAACBpyopDSEdP9AUgIHALOuzAMyEANizoS2aZ+h51+7d/zAAAABA1UZXN0VXNlcjMtMDAxAAAAAC2/bLOz73OuZDdOvWQYBdN+vXumbwn/q+WhvigJAvJbAQBA7ydPvdydNFD2UxvTi+MO0wTgHDefbBkbd2ZubthZRDXt8aTTCiDBpbLO9MT6H796v60WSUQ52SAkp9GVlBsE" + } +} + +20:14:20.212 [ws-worker-10] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=20 lineCode=0 prevLineNumber=8 thisLineNumber=4 prevLineHashLen=32 +20:14:20.217 [ws-worker-10] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=9, newHash=2fed478d20727fd20985cf8d7549040f762ce05329cb149ad4fb9913c91c02e4 +20:14:20.217 [ws-worker-10] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-42","status":200,"payload":{"serverLastGlobalNumber":9,"serverLastGlobalHash":"2fed478d20727fd20985cf8d7549040f762ce05329cb149ad4fb9913c91c02e4","ok":true}} +20:14:20.220 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "AddBlock", + "requestId": "it-addblock-43", + "payload": { + "blockchainName": "TestUser1-001", + "blockNumber": 10, + "prevBlockHash": "2fed478d20727fd20985cf8d7549040f762ce05329cb149ad4fb9913c91c02e4", + "blockBytesB64": "AAAv7UeNIHJ/0gmFz411SQQPdizgUynLFJrU+5kTyRwC5AAAAJYAAAAKAAAAAGm63WwAAwAVAAEAAAAAAAAACS/tR40gcn/SCYXPjXVJBA92LOBTKcsUmtT7mRPJHALkAAAABQ1UZXN0VXNlcjItMDAxAAAAAJnwBOhRmG+FEGFx4nsZzE/E1lz/+OtCU+kPYytKSgHdAQBgD15RKGGi+kaTjzM4vvlfgFJrkGvwXQtp32Snc+U87LzI5Qc0N0YDLUAVsVAxs8FIR88wfCquwSdfJbAhx0QO" + } +} + +20:14:20.224 [ws-worker-11] INFO shine.db.dao.BlocksDAO - DBG BlockEntry: type=3 sub=21 lineCode=0 prevLineNumber=9 thisLineNumber=5 prevLineHashLen=32 +20:14:20.229 [ws-worker-11] INFO s.l.w.J.h.b.Net_AddBlock_Handler - ✅ AddBlock ok: login=TestUser1, blockchainName=TestUser1-001, blockNumber=10, newHash=8487a02ae10bb1421de2192a5356ecc9c3db74a8e1ddf5df3c62c760bf70d663 +20:14:20.229 [ws-worker-11] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"AddBlock","requestId":"it-addblock-43","status":200,"payload":{"serverLastGlobalNumber":10,"serverLastGlobalHash":"8487a02ae10bb1421de2192a5356ecc9c3db74a8e1ddf5df3c62c760bf70d663","ok":true}} +20:14:20.231 [qtp1582446160-42] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:20.247 [qtp1582446160-37] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44576 +20:14:20.251 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-44", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1773854060233, + "value": "Anna", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "9CmwsushOVuTjNti9T75masmKU21MFMg6gkdHp4+UBitcHd3AYe84ajd7j1OvqC8a+QPCCBBVUjFhn+4BZZCDA==" + } +} + +20:14:20.265 [ws-worker-12] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1773854060233 +20:14:20.269 [ws-worker-12] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-44","status":200,"payload":{"ok":true}} +20:14:20.270 [qtp1582446160-37] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:20.275 [qtp1582446160-26] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44590 +20:14:20.277 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-45", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +20:14:20.281 [ws-worker-13] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-45","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1773854060233,"value":"Anna","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"9CmwsushOVuTjNti9T75masmKU21MFMg6gkdHp4+UBitcHd3AYe84ajd7j1OvqC8a+QPCCBBVUjFhn+4BZZCDA==","ok":true}} +20:14:20.283 [qtp1582446160-26] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:20.288 [qtp1582446160-56] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44606 +20:14:20.290 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-46", + "payload": { + "login": "TestUser1", + "param": "profile:city", + "time_ms": 1773854060243, + "value": "Amsterdam", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "WgY3Cr0UsgnGh2soYoPlGgUqjvd1RmqrVYsnJrfeohtIhcQpzN0r40KzKQOMJRaCBPKo9zi9bTRO1/UxA3lnCA==" + } +} + +20:14:20.294 [ws-worker-14] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:city, time_ms=1773854060243 +20:14:20.297 [ws-worker-14] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-46","status":200,"payload":{"ok":true}} +20:14:20.298 [qtp1582446160-56] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:20.304 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44620 +20:14:20.306 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "UpsertUserParam", + "requestId": "it-upsert-47", + "payload": { + "login": "TestUser1", + "param": "profile:name", + "time_ms": 1773854060253, + "value": "Anna Updated", + "device_key": "WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=", + "signature": "SMGur/5QI71MZXKyROa6dtNW/nPO5ZaLFXQjwA2BgUmK42k9v7OLSuWp9Vy+ALhUTJfMxsO8CLhrWQajuyGADg==" + } +} + +20:14:20.312 [ws-worker-15] INFO s.l.w.J.h.u.Net_UpsertUserParam_Handler - ✅ UpsertUserParam applied: login=TestUser1, param=profile:name, time_ms=1773854060253 +20:14:20.314 [ws-worker-15] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"UpsertUserParam","requestId":"it-upsert-47","status":200,"payload":{"ok":true}} +20:14:20.316 [qtp1582446160-41] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:20.321 [qtp1582446160-28] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44634 +20:14:20.323 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetUserParam", + "requestId": "it-getparam-48", + "payload": { + "login": "TestUser1", + "param": "profile:name" + } +} + +20:14:20.325 [ws-worker-16] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetUserParam","requestId":"it-getparam-48","status":200,"payload":{"login":"TestUser1","param":"profile:name","time_ms":1773854060253,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"SMGur/5QI71MZXKyROa6dtNW/nPO5ZaLFXQjwA2BgUmK42k9v7OLSuWp9Vy+ALhUTJfMxsO8CLhrWQajuyGADg==","ok":true}} +20:14:20.326 [qtp1582446160-28] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:20.331 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44650 +20:14:20.335 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "ListUserParams", + "requestId": "it-listparams-49", + "payload": { "login": "TestUser1" } +} + +20:14:20.342 [ws-worker-1] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"ListUserParams","requestId":"it-listparams-49","status":200,"payload":{"login":"TestUser1","params":[{"login":"TestUser1","param":"profile:name","time_ms":1773854060253,"value":"Anna Updated","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"SMGur/5QI71MZXKyROa6dtNW/nPO5ZaLFXQjwA2BgUmK42k9v7OLSuWp9Vy+ALhUTJfMxsO8CLhrWQajuyGADg=="},{"login":"TestUser1","param":"profile:city","time_ms":1773854060243,"value":"Amsterdam","device_key":"WzhtoDq2Iu7rQudUKxvN8KFflJwYuhEr45rZPywAUEQ=","signature":"WgY3Cr0UsgnGh2soYoPlGgUqjvd1RmqrVYsnJrfeohtIhcQpzN0r40KzKQOMJRaCBPKo9zi9bTRO1/UxA3lnCA=="}],"ok":true}} +20:14:20.344 [qtp1582446160-27] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye +20:14:20.349 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS connected: /127.0.0.1:44660 +20:14:20.351 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-50", + "payload": { + "login": "TestUser1" + } +} + +20:14:20.357 [ws-worker-2] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-50","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +20:14:20.361 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-51", + "payload": { + "login": "Testuser1" + } +} + +20:14:20.363 [ws-worker-3] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-51","status":200,"payload":{"login":"TestUser1","out_friends":["TestUser2"],"in_friends":["TestUser2"],"ok":true}} +20:14:20.364 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📥 Получено TEXT-сообщение от клиента: { + "op": "GetFriendsLists", + "requestId": "it-friends-52", + "payload": { + "login": "TestUser2" + } +} + +20:14:20.366 [ws-worker-4] INFO server.ws.BlockchainWsEndpoint - 📤 Отправляем ответ клиенту: {"op":"GetFriendsLists","requestId":"it-friends-52","status":200,"payload":{"login":"TestUser2","out_friends":["TestUser1"],"in_friends":["TestUser1"],"ok":true}} +20:14:20.368 [qtp1582446160-29] INFO server.ws.BlockchainWsEndpoint - WS closed: 1000 bye diff --git a/shine-server-blockchain/all_files.txt b/shine-server-blockchain/all_files.txt new file mode 100644 index 0000000..b4b46b2 --- /dev/null +++ b/shine-server-blockchain/all_files.txt @@ -0,0 +1,2884 @@ +package blockchain; + +import blockchain.body.BodyRecord; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.time.Instant; +import java.util.Arrays; +import java.util.Objects; + +/** + * BchBlockEntry — универсальный блок формата SHiNE (Frame v0). + * + * ========================================================================= + * FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА) + * ========================================================================= + * + * Все числа BigEndian. + * + * PREIMAGE (входит в blockSize, подписывается): + * [2] frameCode (uint16) код/версия рамки: + * - 0x0000 = Frame v0 (текущий) + * [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка) + * [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode, + * НО БЕЗ sigMarker и БЕЗ signature64 + * [4] blockNumber (int32) глобальный номер блока (>=0) + * [8] timestamp (int64) unix seconds + * [2] type (uint16) тип сообщения + * [2] subType (uint16) подтип сообщения + * [2] version (uint16) версия формата сообщения + * [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version) + * + * TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0): + * [2] sigMarker (uint16) маркер подписи: + * - 0x0100 (256) = далее подпись Ed25519 64 байта + * [64] signature64 (bytes) Ed25519 signature над hash32 + * + * hash32 НЕ хранится в блоке. + * hash32 вычисляется при парсинге: + * preimage = первые blockSize байт + * hash32 = SHA-256(preimage) + * + * Правила MVP-парсера (Frame v0): + * - frameCode должен быть строго 0x0000, иначе REJECT. + * - sigMarker должен быть строго 0x0100, иначе REJECT. + * - подпись обязана присутствовать всегда (sigMarker+signature64). + * - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”. + * + * Важно по безопасности: + * - sigMarker в v0 не входит в подписываемые байты → его можно подменить, + * поэтому единственная безопасная логика: "если не 0x0100 — reject". + * ========================================================================= + */ +public final class BchBlockEntry { + + public static final int SIGNATURE_LEN = 64; + public static final int HASH_LEN = 32; + + public static final int FRAME_CODE_LEN = 2; + public static final int SIG_MARKER_LEN = 2; + + /** Frame v0 */ + public static final int FRAME_CODE_V0 = 0x0000; + + /** sigMarker: 256 = 0x0100 */ + public static final int SIG_MARKER_ED25519 = 0x0100; + + /** + * Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature), + * чтобы не уложить сервер по памяти/диску. + */ + public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024; + + /** + * Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов). + * Если timestamp больше now + 60 сек — блок считаем неверным. + */ + public static final long MAX_FUTURE_SECONDS = 60; + + /** + * Размер фиксированной части PREIMAGE (без bodyBytes). + * + * PREIMAGE header: + * frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8) + * + type(2) + subType(2) + version(2) + */ + public static final int PREIMAGE_HEADER_SIZE = + 2 // frameCode + + 32 // prevHash32 + + 4 // blockSize + + 4 // blockNumber + + 8 // timestamp + + 2 // type + + 2 // subType + + 2; // version + + /** Минимальный полный размер блока (без bodyBytes). */ + public static final int MIN_FULL_BYTES = + PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN; + + // --- HEADER (PREIMAGE) --- + public final int frameCode; // uint16 (v0=0) + public final byte[] prevHash32; // 32 + public final int blockSize; // preimage size (включая frameCode) + public final int blockNumber; // >=0 + public final long timestamp; + public final short type; + public final short subType; + public final short version; + + // --- BODY (PREIMAGE) --- + public final byte[] bodyBytes; + + /** Распарсенное тело (создаётся сразу при парсинге блока). */ + public final BodyRecord body; + + // --- TAIL --- + public final int sigMarker; // uint16 (v0: 0x0100) + private final byte[] signature64; // 64 + + // --- derived --- + private final byte[] hash32; // 32, computed + private final byte[] preimage; // blockSize bytes + private final byte[] fullBytes; // preimage + sigMarker + signature + + /* ===================================================================== */ + /* ====================== Конструктор из байт ========================== */ + /* ===================================================================== */ + + public BchBlockEntry(byte[] fullBytes) { + Objects.requireNonNull(fullBytes, "fullBytes == null"); + + if (fullBytes.length < MIN_FULL_BYTES) { + throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES); + } + if (fullBytes.length > MAX_BLOCK_FULL_BYTES) { + throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES); + } + + ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN); + + // [2] frameCode + this.frameCode = Short.toUnsignedInt(bb.getShort()); + if (this.frameCode != FRAME_CODE_V0) { + throw new IllegalArgumentException(String.format( + "Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0 + )); + } + + // [32] prevHash32 + this.prevHash32 = new byte[32]; + bb.get(this.prevHash32); + + // [4] blockSize + this.blockSize = bb.getInt(); + if (blockSize < PREIMAGE_HEADER_SIZE) { + throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE); + } + + // fullLen must match exactly: blockSize + sigMarker(2) + signature(64) + int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN; + if (expectedFullLen != fullBytes.length) { + throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize + + " expectedFullLen=" + expectedFullLen + + " fullLen=" + fullBytes.length); + } + if (expectedFullLen > MAX_BLOCK_FULL_BYTES) { + throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES); + } + + // [4] blockNumber + this.blockNumber = bb.getInt(); + if (this.blockNumber < 0) { + throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber); + } + + // [8] timestamp + this.timestamp = bb.getLong(); + + // запрет “в будущее” больше чем на 1 минуту + long now = Instant.now().getEpochSecond(); + if (this.timestamp > now + MAX_FUTURE_SECONDS) { + throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp + + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS); + } + + // [2][2][2] type/subType/version + this.type = bb.getShort(); + this.subType = bb.getShort(); + this.version = bb.getShort(); + + // [N] bodyBytes + int bodyLen = blockSize - PREIMAGE_HEADER_SIZE; + if (bodyLen < 0) { + throw new IllegalArgumentException("Invalid body length: " + bodyLen); + } + this.bodyBytes = new byte[bodyLen]; + bb.get(this.bodyBytes); + + // TAIL: [2] sigMarker + this.sigMarker = Short.toUnsignedInt(bb.getShort()); + if (this.sigMarker != SIG_MARKER_ED25519) { + throw new IllegalArgumentException(String.format( + "Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519 + )); + } + + // TAIL: [64] signature64 + this.signature64 = new byte[SIGNATURE_LEN]; + bb.get(this.signature64); + + // preimage = первые blockSize байт (включая frameCode) + this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize); + + // hash32 = sha256(preimage) + this.hash32 = BchCryptoVerifier.sha256(preimage); + + // parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check() + this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes); + + this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length); + + if (bb.remaining() != 0) { + throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + } + } + + /* ===================================================================== */ + /* ====================== Конструктор сборки ============================ */ + /* ===================================================================== */ + + public BchBlockEntry(byte[] prevHash32, + int blockNumber, + long timestamp, + short type, + short subType, + short version, + byte[] bodyBytes, + byte[] signature64) { + + Objects.requireNonNull(prevHash32, "prevHash32 == null"); + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + Objects.requireNonNull(signature64, "signature64 == null"); + + if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32"); + if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64"); + + if (blockNumber < 0) { + throw new IllegalArgumentException("blockNumber < 0: " + blockNumber); + } + + // запрет “в будущее” больше чем на 1 минуту + long now = Instant.now().getEpochSecond(); + if (timestamp > now + MAX_FUTURE_SECONDS) { + throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp + + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS); + } + + this.frameCode = FRAME_CODE_V0; + this.prevHash32 = Arrays.copyOf(prevHash32, 32); + this.blockNumber = blockNumber; + this.timestamp = timestamp; + this.type = type; + this.subType = subType; + this.version = version; + this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length); + + // blockSize = размер preimage (включая frameCode) + this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length; + + int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN; + if (fullLen > MAX_BLOCK_FULL_BYTES) { + throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES); + } + + // parse body по header + ОБЯЗАТЕЛЬНЫЙ check() + this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes); + + // tail marker фиксирован + this.sigMarker = SIG_MARKER_ED25519; + this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN); + + // build preimage + ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN); + pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF)); + pre.put(this.prevHash32); + pre.putInt(this.blockSize); + pre.putInt(this.blockNumber); + pre.putLong(this.timestamp); + pre.putShort(this.type); + pre.putShort(this.subType); + pre.putShort(this.version); + pre.put(this.bodyBytes); + + this.preimage = pre.array(); + this.hash32 = BchCryptoVerifier.sha256(preimage); + + // build fullBytes: preimage + sigMarker + signature64 + ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN); + full.put(this.preimage); + full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF)); + full.put(this.signature64); + this.fullBytes = full.array(); + } + + /* ===================================================================== */ + /* ============================ Getters ================================= */ + /* ===================================================================== */ + + public byte[] getPreimageBytes() { + return Arrays.copyOf(preimage, preimage.length); + } + + /** Возвращает подпись Ed25519 (64 байта). */ + public byte[] getSignature64() { + return Arrays.copyOf(signature64, SIGNATURE_LEN); + } + + /** Возвращает hash32 = SHA-256(preimage). */ + public byte[] getHash32() { + return Arrays.copyOf(hash32, HASH_LEN); + } + + /** Возвращает полный блок: preimage + sigMarker + signature. */ + public byte[] toBytes() { + return Arrays.copyOf(fullBytes, fullBytes.length); + } + + @Override + public String toString() { + String timeIso; + try { + timeIso = Instant.ofEpochSecond(timestamp).toString(); + } catch (Exception e) { + timeIso = "некорректныйTimestamp"; + } + + return "BchBlockEntry{" + + "FRAME{frameCode=0x" + hex4(frameCode) + + "}, HDR{" + + "blockSize=" + blockSize + + ", blockNumber=" + blockNumber + + ", timestamp=" + timestamp + " (" + timeIso + ")" + + ", type=" + (type & 0xFFFF) + + ", subType=" + (subType & 0xFFFF) + + ", version=" + (version & 0xFFFF) + + ", prevHash32(hex)=" + toHex(prevHash32) + + "}" + + ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}" + + ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}" + + ", DERIVED{hash32(hex)=" + toHex(hash32) + "}" + + "}"; + } + + private static String hex4(int v) { + String s = Integer.toHexString(v & 0xFFFF); + while (s.length() < 4) s = "0" + s; + return s; + } + + 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 vv = bytes[i] & 0xFF; + out[i * 2] = HEX[vv >>> 4]; + out[i * 2 + 1] = HEX[vv & 0x0F]; + } + return new String(out); + } +} +package blockchain; + +import utils.crypto.Ed25519Util; + +import java.security.MessageDigest; +import java.util.Objects; + +/** + * Верификатор SHiNE (Frame v0): + * + * preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000), + * = всё до TAIL (sigMarker+signature). + * + * hash32 = SHA-256(preimage) + * verify = Ed25519.verify(hash32, signature64, pubKey32) + */ +public final class BchCryptoVerifier { + + private BchCryptoVerifier() {} + + public static byte[] sha256(byte[] data) { + Objects.requireNonNull(data, "data == null"); + try { + MessageDigest d = MessageDigest.getInstance("SHA-256"); + return d.digest(data); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) { + Objects.requireNonNull(block, "block == null"); + Objects.requireNonNull(publicKey32, "publicKey32 == null"); + + if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32"); + + byte[] hash32 = block.getHash32(); + byte[] sig64 = block.getSignature64(); + + return Ed25519Util.verify(hash32, sig64, publicKey32); + } +} +package blockchain.body; + +/** + * BodyHasLine — для типов, которые имеют линейные поля в body. + * + * Line-prefix (BigEndian) в НАЧАЛЕ bodyBytes: + * [4] lineCode код линии (root-идентификатор): + * - 0 для дефолтной линии/канала "0" (root = HEADER, blockNumber=0) + * - для канала "X": blockNumber root-блока канала (CREATE_CHANNEL) + * + * [4] prevLineBlockGlobalNumber глобальный номер предыдущего блока в этой линии + * [32] prevLineBlockHash32 hash32 предыдущего блока в этой линии + * + * [4] lineSeq порядковый номер сообщения внутри линии (1..N) + * + * Важно: + * - Проверка связности линии (prevLineBlockGlobalNumber ↔ prevLineBlockHash32) и корректности lineSeq + * выполняется на сервере/в БД при вставке (а не в body.check()). + */ +public interface BodyHasLine { + + int lineCode(); + + int prevLineBlockGlobalNumber(); + + byte[] prevLineBlockHash32(); + + int lineSeq(); +} +package blockchain.body; + +import utils.blockchain.BlockchainNameUtil; + +/** + * BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля). + * + * Новое правило: + * - toLogin НЕ храним в байтах блока. + * - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN". + * + * Все методы могут возвращать null. + */ +public interface BodyHasTarget { + + /** login цели (nullable). Вычисляется из toBchName(). */ + default String toLogin() { + String bch = toBchName(); + if (bch == null) return null; + return BlockchainNameUtil.loginFromBlockchainName(bch); + } + + /** blockchainName цели (nullable). */ + String toBchName(); + + /** globalNumber цели (nullable). */ + Integer toBlockGlobalNumber(); + + /** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */ + byte[] toBlockHashBytes(); +} +package blockchain.body; + +/** + * BodyRecord — общий контракт для всех типов body (тела блока). + * + * ВАЖНО (новый формат): + * - type/subType/version НЕ лежат в bodyBytes. + * - type/subType/version читаются из заголовка блока (BchBlockEntry). + * + * Поэтому из интерфейса УБРАНЫ: + * - type() + * - subType() + * - version() + * - expectedLineIndex() + */ +public interface BodyRecord { + + /** Проверить корректность содержимого и вернуть этот объект (или кинуть исключение). */ + BodyRecord check(); + + /** + * Сериализовать тело записи в байты (ровно то, что кладётся в block.bodyBytes). + * Важно: НЕ включает type/subType/version. + */ + byte[] toBytes(); +} +package blockchain.body; + +import blockchain.MsgSubType; +import utils.blockchain.BlockchainNameUtil; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** + * ConnectionBody — type=3, ver=1 (в заголовке блока). + * + * subType (в заголовке блока) как MsgSubType: + * FRIEND=10, UNFRIEND=11 + * CONTACT=20, UNCONTACT=21 + * FOLLOW=30, UNFOLLOW=31 + * + * bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ): + * [4] lineCode + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber + * + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber (int32) + * [32] toBlockHash32 (raw 32 bytes) + * + * toLogin вычисляется автоматически из toBlockchainName: + * toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) + */ + +/** + * ========================================================================= + * ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов) + * ========================================================================= + * + * Термины: + * - ROOT линии/канала = блок, который "начинает" линию: + * * для канала "0" root = HEADER (blockNumber=0) + * * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока) + * + * 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*): + * FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя: + * toBlockNumber = 0 + * toBlockHash32 = hash32(HEADER цели) + * + * 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE): + * FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER): + * toBlockNumber = 0 + * toBlockHash32 = hash32(HEADER цели) + * + * FOLLOW/подписка на конкретный канал пользователя -> + * цель = ROOT этого канала: + * - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER) + * - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL), + * toBlockHash32=hash32(CREATE_CHANNEL) + * + * 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД): + * - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено). + * - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала: + * разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL). + * + * Зачем так: + * - связи и подписки всегда стабильны и не ломаются при новых постах, + * - один понятный инвариант: "подписка всегда указывает на root линии". + * ========================================================================= + */ + +public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine { + + public static final short TYPE = 3; + public static final short VER = 1; + + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + public final short subType; // из header + public final short version; // из header + + // line + public final int lineCode; + public final int prevLineNumber; + public final byte[] prevLineHash32; + public final int thisLineNumber; + + // payload + public final String toBlockchainName; + public final int toBlockGlobalNumber; + public final byte[] toBlockHash32; + + public ConnectionBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + + this.subType = subType; + this.version = version; + + if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("ConnectionBody version must be 1, got=" + (this.version & 0xFFFF)); + } + if (!isValidSubType(this.subType)) { + throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF)); + } + + // минимум: + // lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32] + if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) { + throw new IllegalArgumentException("ConnectionBody too short"); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + + this.lineCode = bb.getInt(); + + this.prevLineNumber = bb.getInt(); + + this.prevLineHash32 = new byte[32]; + bb.get(this.prevLineHash32); + + this.thisLineNumber = bb.getInt(); + + int bchLen = Byte.toUnsignedInt(bb.get()); + if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); + if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short"); + + byte[] bchBytes = new byte[bchLen]; + bb.get(bchBytes); + this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8); + + this.toBlockGlobalNumber = bb.getInt(); + + this.toBlockHash32 = new byte[32]; + bb.get(this.toBlockHash32); + + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + } + + public ConnectionBody(int lineCode, + int prevLineNumber, + byte[] prevLineHash32, + int thisLineNumber, + short subType, + String toBlockchainName, + int toBlockGlobalNumber, + byte[] toBlockHash32) { + + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + + if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); + if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF)); + + if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + // Железное правило формата: bchName -> login + "-NNN" + if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) { + throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName); + } + + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + this.lineCode = lineCode; + + this.prevLineNumber = prevLineNumber; + this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + this.thisLineNumber = thisLineNumber; + + this.subType = subType; + this.version = VER; + + this.toBlockchainName = toBlockchainName; + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + } + + private static boolean isValidSubType(short st) { + int v = st & 0xFFFF; + return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF) + || v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF) + || v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF) + || v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF) + || v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF) + || v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF); + } + + @Override + public ConnectionBody check() { + if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); + if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF)); + + // line rule (как было) + if (prevLineNumber == -1) { + if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1"); + if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1"); + } else { + if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); + } + + if (toBlockchainName == null || toBlockchainName.isBlank()) + throw new IllegalArgumentException("toBlockchainName is blank"); + + // гарантируем вычислимый toLogin (иначе target “битый” по стандарту) + if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) + throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName); + + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); + + return this; + } + + @Override + public byte[] toBytes() { + byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (bchBytes.length == 0 || bchBytes.length > 255) + throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); + + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("toBlockHash32 != 32"); + + int cap = 4 + (4 + 32 + 4) + + 1 + bchBytes.length + + 4 + 32; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + + bb.putInt(lineCode); + + bb.putInt(prevLineNumber); + bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + bb.putInt(thisLineNumber); + + bb.put((byte) bchBytes.length); + bb.put(bchBytes); + + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + + return bb.array(); + } + + private static boolean isAllZero32(byte[] b) { + if (b == null || b.length != 32) return true; + for (int i = 0; i < 32; i++) if (b[i] != 0) return false; + return true; + } + + /* ====================== BodyHasLine ====================== */ + @Override public int lineCode() { return lineCode; } + @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } + @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } + @Override public int lineSeq() { return thisLineNumber; } + + /* ====================== BodyHasTarget ===================== */ + @Override public String toBchName() { return toBlockchainName; } + @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } + @Override public byte[] toBlockHashBytes() { return toBlockHash32; } +} +package blockchain.body; + +import blockchain.MsgSubType; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** + * CreateChannelBody — TECH сообщение создания канала. + * + * type=0, ver=1 (в заголовке блока) + * subType=MsgSubType.TECH_CREATE_CHANNEL (=1) + * + * Это сообщение идёт по ТЕХ-ЛИНИИ (hasLine): + * - prevLineNumber/hash указывают на предыдущее TECH-сообщение (HEADER или прошлый CREATE_CHANNEL) + * - thisLineNumber: 1,2,3... (тех-нумерация) + * + * bodyBytes (BigEndian), новый формат line-prefix: + * [4] lineCode (для TECH линии обычно 0) + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber + * [1] channelNameLen (uint8) + * [N] channelName UTF-8 (^[A-Za-z0-9_]+$) + * + * Важно: + * - канал "0" зарезервирован (создаётся по умолчанию от HEADER), создавать его нельзя. + */ +public final class CreateChannelBody implements BodyRecord, BodyHasLine { + + public static final short TYPE = 0; + public static final short VER = 1; + + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL; + + private static final byte[] ZERO32 = new byte[32]; + + public final short subType; // из header + public final short version; // из header + + // line + public final int lineCode; + public final int prevLineNumber; + public final byte[] prevLineHash32; // 32 + public final int thisLineNumber; + + // payload + public final String channelName; + + public CreateChannelBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + + this.subType = subType; + this.version = version; + + if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + (this.version & 0xFFFF)); + } + if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) { + throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF)); + } + + // минимум: lineCode(4) + line(4+32+4) + nameLen(1) + name(1) + if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) { + throw new IllegalArgumentException("CreateChannelBody too short"); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + + this.lineCode = bb.getInt(); + + this.prevLineNumber = bb.getInt(); + + this.prevLineHash32 = new byte[32]; + bb.get(this.prevLineHash32); + + this.thisLineNumber = bb.getInt(); + + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0"); + if (bb.remaining() != nameLen) { + throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen); + } + + byte[] nameBytes = new byte[nameLen]; + bb.get(nameBytes); + + this.channelName = new String(nameBytes, StandardCharsets.UTF_8); + + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + } + + public CreateChannelBody(int lineCode, + int prevLineNumber, + byte[] prevLineHash32, + int thisLineNumber, + String channelName) { + Objects.requireNonNull(channelName, "channelName == null"); + if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); + + this.subType = SUBTYPE; + this.version = VER; + + this.lineCode = lineCode; + this.prevLineNumber = prevLineNumber; + this.prevLineHash32 = (prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32)); + this.thisLineNumber = thisLineNumber; + + this.channelName = channelName; + } + + @Override + public CreateChannelBody check() { + if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); + + if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) + throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)"); + + if (channelName == null || channelName.isBlank()) + throw new IllegalArgumentException("channelName is blank"); + + if (!channelName.matches("^[A-Za-z0-9_]+$")) + throw new IllegalArgumentException("channelName must match ^[A-Za-z0-9_]+$"); + + if ("0".equals(channelName)) + throw new IllegalArgumentException("channelName \"0\" is reserved"); + + // tech-line: prev обязателен (минимум HEADER=0) + if (prevLineNumber < 0) + throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody"); + if (prevLineHash32 == null || prevLineHash32.length != 32) + throw new IllegalArgumentException("prevLineHash32 invalid"); + if (thisLineNumber <= 0) + throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody"); + + return this; + } + + @Override + public byte[] toBytes() { + byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8); + if (nameUtf8.length == 0 || nameUtf8.length > 255) + throw new IllegalArgumentException("channelName utf8 len must be 1..255"); + + int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length; + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + + bb.putInt(lineCode); + + bb.putInt(prevLineNumber); + bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32)); + bb.putInt(thisLineNumber); + + bb.put((byte) nameUtf8.length); + bb.put(nameUtf8); + + return bb.array(); + } + + /* ====================== BodyHasLine ====================== */ + @Override public int lineCode() { return lineCode; } + @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } + @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } + @Override public int lineSeq() { return thisLineNumber; } +} +package blockchain.body; + +import utils.config.ShineSignatureConstants; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * HeaderBody — type=0, version=1. + * + * В новом формате type/subType/version живут в HEADER блока, + * поэтому bodyBytes для HeaderBody содержат только payload: + * + * bodyBytes (BigEndian): + * [TAG_LEN] tag ASCII "SHiNE" + * [1] loginLength=N (uint8) + * [N] login UTF-8 + */ +public final class HeaderBody implements BodyRecord { + + public static final short TYPE = 0; + public static final short VER = 1; + + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + /** Для header subType всегда 0 (служебная совместимость). */ + public static final short SUBTYPE_COMPAT = 0; + + /** TAG формата (ASCII). */ + public static final String TAG = ShineSignatureConstants.BLOCKCHAIN_HEADER_TAG; + + private static final byte[] TAG_ASCII = TAG.getBytes(StandardCharsets.US_ASCII); + private static final int TAG_LEN = TAG_ASCII.length; + + public final short subType; // всегда 0 (из заголовка блока) + public final short version; // из заголовка блока + public final String tag; // "SHiNE" + public final String login; + + /** Десериализация из payload bodyBytes (без type/subType/version). */ + public HeaderBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + + this.subType = subType; + this.version = version; + + if ((this.subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) { + throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF)); + } + if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("HeaderBody version must be 1, got=" + (this.version & 0xFFFF)); + } + + // минимум: tag[TAG_LEN] + loginLen[1] + if (bodyBytes.length < TAG_LEN + 1) throw new IllegalArgumentException("HeaderBody too short"); + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + + byte[] tagBytes = new byte[TAG_LEN]; + bb.get(tagBytes); + String t = new String(tagBytes, StandardCharsets.US_ASCII); + if (!TAG.equals(t)) throw new IllegalArgumentException("Bad tag: " + t); + this.tag = t; + + int loginLen = Byte.toUnsignedInt(bb.get()); + if (loginLen <= 0 || bb.remaining() < loginLen) + throw new IllegalArgumentException("Bad login length"); + + byte[] loginBytes = new byte[loginLen]; + bb.get(loginBytes); + this.login = new String(loginBytes, StandardCharsets.UTF_8); + + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + } + + /** Создание “вручную”. */ + public HeaderBody(String login) { + Objects.requireNonNull(login, "login == null"); + this.subType = SUBTYPE_COMPAT; + this.version = VER; + this.tag = TAG; + this.login = login; + } + + @Override + public HeaderBody check() { + if ((subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) + throw new IllegalArgumentException("HeaderBody subType must be 0"); + + if (login == null || login.isBlank()) + throw new IllegalArgumentException("Login is blank"); + if (!login.matches("^[A-Za-z0-9_]+$")) + throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$"); + + return this; + } + + @Override + public byte[] toBytes() { + byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8); + if (loginUtf8.length == 0 || loginUtf8.length > 255) + throw new IllegalArgumentException("Login utf8 len must be 1..255"); + + int cap = TAG_LEN + 1 + loginUtf8.length; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.put(TAG_ASCII); + bb.put((byte) loginUtf8.length); + bb.put(loginUtf8); + + return bb.array(); + } + + @Override + public String toString() { + return """ + HeaderBody { + тип записи : HEADER (type=0, ver=1) [в заголовке блока] + subType : 0 (compat) + тег формата : "%s" + login владельца : "%s" + } + """.formatted(tag, login); + } +} +package blockchain.body; + +import blockchain.MsgSubType; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** + * ReactionBody — type=2, version=1 (в заголовке блока). + * + * subType (в заголовке блока): + * 1 = LIKE + * + * bodyBytes (BigEndian), новый формат: + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber (int32) + * [32] toBlockHash32 (raw 32 bytes) + * + * ЛИНИИ НЕТ. + */ +public final class ReactionBody implements BodyRecord, BodyHasTarget { + + public static final short TYPE = 2; + public static final short VER = 1; + + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + public final short subType; // из header + public final short version; // из header + + public final String toBlockchainName; + public final int toBlockGlobalNumber; + public final byte[] toBlockHash32; + + public ReactionBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + + this.subType = subType; + this.version = version; + + if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("ReactionBody version must be 1, got=" + (this.version & 0xFFFF)); + } + if ((this.subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) { + throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF)); + } + + // минимум: nameLen[1]+name[1]+global[4]+hash[32] + if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short"); + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); + if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short"); + + byte[] nameBytes = new byte[nameLen]; + bb.get(nameBytes); + this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); + + this.toBlockGlobalNumber = bb.getInt(); + + this.toBlockHash32 = new byte[32]; + bb.get(this.toBlockHash32); + + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + } + + public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) { + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + + this.subType = MsgSubType.REACTION_LIKE; + this.version = VER; + + if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + this.toBlockchainName = toBlockchainName; + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + } + + @Override + public ReactionBody check() { + if ((subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) + throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF)); + + if (toBlockchainName == null || toBlockchainName.isBlank()) + throw new IllegalArgumentException("toBlockchainName is blank"); + if (toBlockGlobalNumber < 0) + throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("toBlockHash32 invalid"); + + return this; + } + + @Override + public byte[] toBytes() { + byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameBytes.length == 0 || nameBytes.length > 255) + throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); + + int cap = 1 + nameBytes.length + 4 + 32; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.put((byte) nameBytes.length); + bb.put(nameBytes); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + + return bb.array(); + } + + /* ====================== BodyHasTarget ====================== */ + + @Override public String toBchName() { return toBlockchainName; } + @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } + @Override public byte[] toBlockHashBytes() { return toBlockHash32; } +} +package blockchain; + +import blockchain.body.*; + +/** + * Парсер body выбирает класс по header: type/subType/version, + * потому что bodyBytes больше НЕ содержат type/subType/version. + */ +public final class BodyRecordParser { + + private BodyRecordParser() {} + + public static BodyRecord parse(short type, short subType, short version, byte[] bodyBytes) { + if (bodyBytes == null) throw new IllegalArgumentException("bodyBytes == null"); + + int t = type & 0xFFFF; + int v = version & 0xFFFF; + + int key = (t << 16) | v; + + BodyRecord r = switch (key) { + case HeaderBody.KEY -> { + int st = subType & 0xFFFF; + if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF)) { + yield new HeaderBody(subType, version, bodyBytes); + } + if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)) { + yield new CreateChannelBody(subType, version, bodyBytes); + } + throw new IllegalArgumentException("Unknown TECH subType for type=0 ver=1: subType=" + st); + } + + // TEXT type=1 ver=1: выбираем класс по subType + case TextBody.KEY -> { + int st = subType & 0xFFFF; + + if (st == (MsgSubType.TEXT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + yield new TextLineBody(subType, version, bodyBytes); + } + + if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) + || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { + yield new TextReplyBody(subType, version, bodyBytes); + } + + throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st); + } + + case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes); + case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes); + case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes); + + default -> throw new IllegalArgumentException(String.format( + "Unknown body type/version from header: type=%d ver=%d subType=%d", + t, v, (subType & 0xFFFF) + )); + }; + + return r.check(); + } +} +package blockchain.body; + +import blockchain.MsgSubType; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** + * TextBody — type=1, ver=1 (в заголовке блока). + * + * subType (в заголовке блока): + * 10 = POST + * 11 = EDIT_POST + * 20 = REPLY + * 21 = EDIT_REPLY + * + * ========================================================================= + * КОНЦЕПЦИЯ ЛИНИЙ ДЛЯ ТЕКСТОВЫХ СООБЩЕНИЙ: + * + * POST и EDIT_POST принадлежат ЛИНИИ КАНАЛА и имеют hasLine. + * В новом формате добавлен lineCode: + * lineCode = 0 для канала "0" + * lineCode = blockNumber "заглавия линии/канала" (например CREATE_CHANNEL) + * + * REPLY и EDIT_REPLY НЕ имеют линии (нет hasLine в байтах). + * + * ========================================================================= + * ФОРМАТЫ bodyBytes (BigEndian): + * + * 1) POST (subType=10): + * [4] lineCode + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber + * [2] textLenBytes (uint16) + * [N] text UTF-8 + * + * 2) EDIT_POST (subType=11): + * [4] lineCode + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber + * + * hasTarget (на ОРИГИНАЛЬНЫЙ POST, toBchName НЕ хранить): + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * + * [2] textLenBytes (uint16) + * [N] text UTF-8 + * + * 3) REPLY (subType=20) — НЕ в линии: + * hasTarget: + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * + * [2] textLenBytes (uint16) + * [M] text UTF-8 + * + * 4) EDIT_REPLY (subType=21) — НЕ в линии: + * hasTarget (на ОРИГИНАЛЬНЫЙ REPLY, toBchName НЕ хранить): + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * + * [2] textLenBytes (uint16) + * [N] text UTF-8 + */ +public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine { + + public static final short TYPE = 1; + public static final short VER = 1; + + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + public final short subType; // из header + public final short version; // из header + + // ===== line fields (только для POST/EDIT_POST) ===== + // Для REPLY/EDIT_REPLY эти поля НЕ сериализуются; значения держим как "пустые". + public final int lineCode; // только для line-message; иначе -1 + public final int prevLineNumber; + public final byte[] prevLineHash32; // 32 or null + public final int thisLineNumber; + + // ===== message text ===== + public final String message; + + // ===== target fields ===== + // REPLY: toBlockchainName + globalNumber + hash32 + // EDIT_POST / EDIT_REPLY: только globalNumber + hash32 (без toBlockchainName) + public final String toBlockchainName; // nullable + public final Integer toBlockGlobalNumber; // nullable + public final byte[] toBlockHash32; // nullable (но если target есть -> 32) + + /* ===================================================================== */ + /* ====================== Конструктор из байт ========================== */ + /* ===================================================================== */ + + public TextBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + + this.subType = subType; + this.version = version; + + if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("TextBody version must be 1, got=" + (this.version & 0xFFFF)); + } + if (!isValidSubType(this.subType)) { + throw new IllegalArgumentException("Bad Text subType: " + (this.subType & 0xFFFF)); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + + int st = this.subType & 0xFFFF; + + if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { + // POST: hasLine(lineCode+line) + text + ensureMin(bb, (4 + 4 + 32 + 4) + 2, "POST too short"); + + this.lineCode = bb.getInt(); + this.prevLineNumber = bb.getInt(); + this.prevLineHash32 = new byte[32]; + bb.get(this.prevLineHash32); + this.thisLineNumber = bb.getInt(); + + this.message = readStrictUtf8Len16(bb, "POST text"); + + this.toBlockchainName = null; + this.toBlockGlobalNumber = null; + this.toBlockHash32 = null; + + ensureNoTail(bb, "POST"); + + } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + // EDIT_POST: hasLine(lineCode+line) + target(no bch) + text + ensureMin(bb, (4 + 4 + 32 + 4) + (4 + 32) + 2, "EDIT_POST too short"); + + this.lineCode = bb.getInt(); + this.prevLineNumber = bb.getInt(); + this.prevLineHash32 = new byte[32]; + bb.get(this.prevLineHash32); + this.thisLineNumber = bb.getInt(); + + int tgtNum = bb.getInt(); + byte[] tgtHash = new byte[32]; + bb.get(tgtHash); + + this.toBlockchainName = null; + this.toBlockGlobalNumber = tgtNum; + this.toBlockHash32 = tgtHash; + + this.message = readStrictUtf8Len16(bb, "EDIT_POST text"); + + ensureNoTail(bb, "EDIT_POST"); + + } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + // REPLY: target(with bch) + text (без line) + ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short"); + + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0"); + ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short"); + + byte[] nameBytes = new byte[nameLen]; + bb.get(nameBytes); + this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); + + this.toBlockGlobalNumber = bb.getInt(); + + this.toBlockHash32 = new byte[32]; + bb.get(this.toBlockHash32); + + this.message = readStrictUtf8Len16(bb, "REPLY text"); + + // line fields отсутствуют в байтах + this.lineCode = -1; + this.prevLineNumber = -1; + this.prevLineHash32 = null; + this.thisLineNumber = -1; + + ensureNoTail(bb, "REPLY"); + + } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { + // EDIT_REPLY: target(no bch) + text (без line) + ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short"); + + int tgtNum = bb.getInt(); + byte[] tgtHash = new byte[32]; + bb.get(tgtHash); + + this.toBlockchainName = null; + this.toBlockGlobalNumber = tgtNum; + this.toBlockHash32 = tgtHash; + + this.message = readStrictUtf8Len16(bb, "EDIT_REPLY text"); + + // line fields отсутствуют в байтах + this.lineCode = -1; + this.prevLineNumber = -1; + this.prevLineHash32 = null; + this.thisLineNumber = -1; + + ensureNoTail(bb, "EDIT_REPLY"); + + } else { + throw new IllegalArgumentException("Unsupported Text subType: " + st); + } + } + + /* ===================================================================== */ + /* ====================== Фабрики (удобно) ============================= */ + /* ===================================================================== */ + + public static TextBody newPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, String message) { + return new TextBody(MsgSubType.TEXT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber, + message, null, null, null); + } + + public static TextBody newEditPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, + int targetBlockNumber, byte[] targetHash32, + String message) { + return new TextBody(MsgSubType.TEXT_EDIT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber, + message, null, targetBlockNumber, targetHash32); + } + + public static TextBody newReply(String toBlockchainName, int targetBlockNumber, byte[] targetHash32, String message) { + return new TextBody(MsgSubType.TEXT_REPLY, -1, -1, null, -1, + message, toBlockchainName, targetBlockNumber, targetHash32); + } + + public static TextBody newEditReply(int targetBlockNumber, byte[] targetHash32, String message) { + return new TextBody(MsgSubType.TEXT_EDIT_REPLY, -1, -1, null, -1, + message, null, targetBlockNumber, targetHash32); + } + + /** + * Универсальный конструктор “вручную”. + * Для REPLY/EDIT_REPLY line поля игнорируются при сериализации (их в формате нет). + */ + public TextBody(short subType, + int lineCode, + int prevLineNumber, + byte[] prevLineHash32, + int thisLineNumber, + String message, + String toBlockchainName, + Integer toBlockGlobalNumber, + byte[] toBlockHash32) { + + Objects.requireNonNull(message, "message == null"); + + if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF)); + if (message.isBlank()) throw new IllegalArgumentException("message is blank"); + + this.subType = subType; + this.version = VER; + + int st = subType & 0xFFFF; + + // line применима только к POST/EDIT_POST + if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message"); + this.lineCode = lineCode; + this.prevLineNumber = prevLineNumber; + this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + this.thisLineNumber = thisLineNumber; + } else { + this.lineCode = -1; + this.prevLineNumber = -1; + this.prevLineHash32 = null; + this.thisLineNumber = -1; + } + + this.message = message; + + // target правила + if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { + this.toBlockchainName = null; + this.toBlockGlobalNumber = null; + this.toBlockHash32 = null; + + } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + this.toBlockchainName = null; // по ТЗ: не хранить + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + + } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + this.toBlockchainName = toBlockchainName; + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + + } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { + Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + this.toBlockchainName = null; // по ТЗ: не хранить + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + + } else { + this.toBlockchainName = null; + this.toBlockGlobalNumber = null; + this.toBlockHash32 = null; + } + } + + private static boolean isValidSubType(short st) { + int v = st & 0xFFFF; + return v == (MsgSubType.TEXT_POST & 0xFFFF) + || v == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) + || v == (MsgSubType.TEXT_REPLY & 0xFFFF) + || v == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); + } + + @Override + public TextBody check() { + if (!isValidSubType(subType)) + throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF)); + + if (message == null || message.isBlank()) + throw new IllegalArgumentException("Text message is blank"); + + int st = subType & 0xFFFF; + + // локальные проверки line (БД не трогаем) + if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message"); + if (prevLineHash32 == null || prevLineHash32.length != 32) + throw new IllegalArgumentException("prevLineHash32 invalid"); + } else { + // reply/edit_reply: line отсутствует + if (prevLineHash32 != null) + throw new IllegalArgumentException("REPLY/EDIT_REPLY must not contain line hash"); + } + + // target rules + if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { + if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null) + throw new IllegalArgumentException("POST must not contain target fields"); + + } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + if (toBlockchainName != null) + throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName in target"); + if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) + throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid"); + + } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + if (toBlockchainName == null || toBlockchainName.isBlank()) + throw new IllegalArgumentException("REPLY toBlockchainName is blank"); + if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) + throw new IllegalArgumentException("REPLY toBlockGlobalNumber invalid"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("REPLY toBlockHash32 invalid"); + + } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { + if (toBlockchainName != null) + throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName in target"); + if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) + throw new IllegalArgumentException("EDIT_REPLY toBlockGlobalNumber invalid"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 invalid"); + } + + return this; + } + + @Override + public byte[] toBytes() { + byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); + if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); + if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); + + int st = subType & 0xFFFF; + + if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { + // hasLine(lineCode+line) + text + int cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.putInt(lineCode); + bb.putInt(prevLineNumber); + bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + bb.putInt(thisLineNumber); + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + return bb.array(); + + } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + // hasLine(lineCode+line) + target(no bch) + text + if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32"); + + int cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.putInt(lineCode); + bb.putInt(prevLineNumber); + bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + bb.putInt(thisLineNumber); + + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + return bb.array(); + + } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + // target(with bch) + text + if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName"); + if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPLY missing toBlockGlobalNumber"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPLY toBlockHash32 != 32"); + + byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameUtf8.length == 0 || nameUtf8.length > 255) + throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255"); + + int cap = 1 + nameUtf8.length + 4 + 32 + + 2 + msgUtf8.length; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.put((byte) nameUtf8.length); + bb.put(nameUtf8); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + return bb.array(); + + } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { + // target(no bch) + text + if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_REPLY missing toBlockGlobalNumber"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 != 32"); + + int cap = (4 + 32) + 2 + msgUtf8.length; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + return bb.array(); + + } else { + throw new IllegalStateException("Unsupported Text subType: " + st); + } + } + + /* ===================================================================== */ + /* ========================== Helpers ================================== */ + /* ===================================================================== */ + + private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) { + int len = Short.toUnsignedInt(bb.getShort()); + if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty"); + if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); + + byte[] bytes = new byte[len]; + bb.get(bytes); + + var decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + + try { + String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); + if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); + return s; + } catch (CharacterCodingException e) { + throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); + } + } + + private static void ensureMin(ByteBuffer bb, int need, String msg) { + if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")"); + } + + private static void ensureNoTail(ByteBuffer bb, String ctx) { + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining()); + } + + /* ====================== BodyHasLine ====================== */ + @Override public int lineCode() { return lineCode; } + @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } + @Override public byte[] prevLineBlockHash32() { + if (prevLineHash32 == null) return null; + return Arrays.copyOf(prevLineHash32, 32); + } + @Override public int lineSeq() { return thisLineNumber; } + + /* ====================== BodyHasTarget ===================== */ + @Override public String toBchName() { return toBlockchainName; } + @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } + @Override public byte[] toBlockHashBytes() { return toBlockHash32; } + + /* ===================================================================== */ + /* ===================== Удобные хелперы (для ChainState) =============== */ + /* ===================================================================== */ + + /** true только для POST / EDIT_POST (т.е. это сообщение в линии канала). */ + public boolean isLineMessage() { + int st = subType & 0xFFFF; + return st == (MsgSubType.TEXT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); + } + + /** true только для EDIT_POST / EDIT_REPLY. */ + public boolean isEditMessage() { + int st = subType & 0xFFFF; + return st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); + } + + /** true только для REPLY / EDIT_REPLY (т.е. “не в линии”). */ + public boolean isReplyFamily() { + int st = subType & 0xFFFF; + return st == (MsgSubType.TEXT_REPLY & 0xFFFF) + || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); + } +} +package blockchain.body; + +import blockchain.MsgSubType; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** + * TextLineBody — type=1, ver=1. + * + * subType: + * - POST (10) + * - EDIT_POST (11) + * + * Формат bodyBytes (BigEndian): + * + * POST: + * [4] lineCode + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber + * [2] textLenBytes (uint16) + * [N] text UTF-8 + * + * EDIT_POST: + * [4] lineCode + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber + * [4] toBlockGlobalNumber (int32) + * [32] toBlockHash32 + * [2] textLenBytes (uint16) + * [N] text UTF-8 + */ +public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget { + + public static final short TYPE = 1; + public static final short VER = 1; + + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + public final short subType; // из header + public final short version; // из header (=1) + + // line + public final int lineCode; + public final int prevLineNumber; + public final byte[] prevLineHash32; // 32 (может быть нули) + public final int thisLineNumber; + + // target (только для EDIT_POST) + public final Integer toBlockGlobalNumber; // nullable для POST + public final byte[] toBlockHash32; // nullable для POST + + // text + public final String message; + + /* ====================== parse from bytes ====================== */ + + public TextLineBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + + this.subType = subType; + this.version = version; + + if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("TextLineBody version must be 1, got=" + (this.version & 0xFFFF)); + } + + int st = this.subType & 0xFFFF; + if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST, got subType=" + st); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + + // минимум line + textLen(2) + ensureMin(bb, (4 + 4 + 32 + 4) + 2, "TextLineBody too short"); + + this.lineCode = bb.getInt(); + this.prevLineNumber = bb.getInt(); + + this.prevLineHash32 = new byte[32]; + bb.get(this.prevLineHash32); + + this.thisLineNumber = bb.getInt(); + + if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + // нужен target + ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target"); + int tgtNum = bb.getInt(); + byte[] tgtHash = new byte[32]; + bb.get(tgtHash); + + this.toBlockGlobalNumber = tgtNum; + this.toBlockHash32 = tgtHash; + + } else { + this.toBlockGlobalNumber = null; + this.toBlockHash32 = null; + } + + this.message = readStrictUtf8Len16(bb, "TextLineBody text"); + + ensureNoTail(bb, "TextLineBody"); + } + + /* ====================== manual ctor ====================== */ + + public TextLineBody(int lineCode, + int prevLineNumber, + byte[] prevLineHash32, + int thisLineNumber, + short subType, + Integer toBlockGlobalNumber, + byte[] toBlockHash32, + String message) { + + Objects.requireNonNull(message, "message == null"); + + int st = subType & 0xFFFF; + if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST"); + } + + if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); + if (message.isBlank()) throw new IllegalArgumentException("message is blank"); + + this.subType = subType; + this.version = VER; + + this.lineCode = lineCode; + this.prevLineNumber = prevLineNumber; + this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + this.thisLineNumber = thisLineNumber; + + if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + } else { + this.toBlockGlobalNumber = null; + this.toBlockHash32 = null; + } + + this.message = message; + } + + @Override + public TextLineBody check() { + int st = subType & 0xFFFF; + if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) + throw new IllegalArgumentException("Bad TextLineBody subType: " + st); + + if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); + if (prevLineHash32 == null || prevLineHash32.length != 32) + throw new IllegalArgumentException("prevLineHash32 invalid"); + + if (message == null || message.isBlank()) + throw new IllegalArgumentException("Text message is blank"); + + if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) + throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid"); + } else { + if (toBlockGlobalNumber != null || toBlockHash32 != null) + throw new IllegalArgumentException("POST must not contain target fields"); + } + + return this; + } + + @Override + public byte[] toBytes() { + byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); + if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); + if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); + + int st = subType & 0xFFFF; + + int cap; + if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { + cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; + } else { + // EDIT_POST + if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32"); + cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length; + } + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + + bb.putInt(lineCode); + bb.putInt(prevLineNumber); + bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + bb.putInt(thisLineNumber); + + if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + } + + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + + return bb.array(); + } + + /* ====================== BodyHasLine ====================== */ + @Override public int lineCode() { return lineCode; } + @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } + @Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); } + @Override public int lineSeq() { return thisLineNumber; } + + /* ====================== BodyHasTarget ===================== */ + @Override public String toBchName() { return null; } // по ТЗ: не хранить + @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } + @Override public byte[] toBlockHashBytes() { return toBlockHash32; } + + /* ====================== helpers ====================== */ + + public boolean isEditPost() { + return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); + } + + private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) { + int len = Short.toUnsignedInt(bb.getShort()); + if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty"); + if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); + + byte[] bytes = new byte[len]; + bb.get(bytes); + + var decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + + try { + String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); + if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); + return s; + } catch (CharacterCodingException e) { + throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); + } + } + + private static void ensureMin(ByteBuffer bb, int need, String msg) { + if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")"); + } + + private static void ensureNoTail(ByteBuffer bb, String ctx) { + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining()); + } +} +package blockchain.body; + +import blockchain.MsgSubType; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** + * TextReplyBody — type=1, ver=1. + * + * subType: + * - REPLY (20) + * - EDIT_REPLY (21) + * + * Форматы bodyBytes (BigEndian): + * + * REPLY: + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * [2] textLenBytes (uint16) + * [M] text UTF-8 + * + * EDIT_REPLY: + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * [2] textLenBytes (uint16) + * [N] text UTF-8 + */ +public final class TextReplyBody implements BodyRecord, BodyHasTarget { + + public static final short TYPE = 1; + public static final short VER = 1; + + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + public final short subType; // из header + public final short version; // (=1) + + // target + public final String toBlockchainName; // nullable для EDIT_REPLY + public final int toBlockGlobalNumber; + public final byte[] toBlockHash32; // 32 + + // text + public final String message; + + public TextReplyBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + + this.subType = subType; + this.version = version; + + if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF)); + } + + int st = this.subType & 0xFFFF; + if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { + throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + + if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + // минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2] + ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short"); + + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0"); + ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short"); + + byte[] nameBytes = new byte[nameLen]; + bb.get(nameBytes); + this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); + + this.toBlockGlobalNumber = bb.getInt(); + + this.toBlockHash32 = new byte[32]; + bb.get(this.toBlockHash32); + + } else { + // EDIT_REPLY: target без имени + ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short"); + + this.toBlockchainName = null; + this.toBlockGlobalNumber = bb.getInt(); + + this.toBlockHash32 = new byte[32]; + bb.get(this.toBlockHash32); + } + + this.message = readStrictUtf8Len16(bb, "TextReplyBody text"); + ensureNoTail(bb, "TextReplyBody"); + } + + public TextReplyBody(short subType, + int toBlockGlobalNumber, + byte[] toBlockHash32, + String toBlockchainName, + String message) { + + Objects.requireNonNull(message, "message == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + + int st = subType & 0xFFFF; + if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { + throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY"); + } + + if (message.isBlank()) throw new IllegalArgumentException("message is blank"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + this.toBlockchainName = toBlockchainName; + } else { + // EDIT_REPLY: имя не хранить + this.toBlockchainName = null; + } + + this.subType = subType; + this.version = VER; + + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + + this.message = message; + } + + @Override + public TextReplyBody check() { + int st = subType & 0xFFFF; + if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) + throw new IllegalArgumentException("Bad TextReplyBody subType: " + st); + + if (message == null || message.isBlank()) + throw new IllegalArgumentException("Text message is blank"); + + if (toBlockGlobalNumber < 0) + throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("toBlockHash32 invalid"); + + if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + if (toBlockchainName == null || toBlockchainName.isBlank()) + throw new IllegalArgumentException("REPLY toBlockchainName is blank"); + } else { + if (toBlockchainName != null) + throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName"); + } + + return this; + } + + @Override + public byte[] toBytes() { + byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); + if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); + if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); + + int st = subType & 0xFFFF; + + if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName"); + + byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameUtf8.length == 0 || nameUtf8.length > 255) + throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255"); + + int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.put((byte) nameUtf8.length); + bb.put(nameUtf8); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + + return bb.array(); + } + + // EDIT_REPLY + int cap = (4 + 32) + 2 + msgUtf8.length; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); + bb.putShort((short) msgUtf8.length); + bb.put(msgUtf8); + + return bb.array(); + } + + /* ====================== BodyHasTarget ====================== */ + + @Override public String toBchName() { return toBlockchainName; } + @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } + @Override public byte[] toBlockHashBytes() { return toBlockHash32; } + + public boolean isEditReply() { + return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); + } + + /* ====================== helpers ====================== */ + + private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) { + int len = Short.toUnsignedInt(bb.getShort()); + if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty"); + if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); + + byte[] bytes = new byte[len]; + bb.get(bytes); + + var decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + + try { + String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); + if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); + return s; + } catch (CharacterCodingException e) { + throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); + } + } + + private static void ensureMin(ByteBuffer bb, int need, String msg) { + if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")"); + } + + private static void ensureNoTail(ByteBuffer bb, String ctx) { + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining()); + } +} +package blockchain.body; + +import blockchain.MsgSubType; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** + * UserParamBody — type=4, ver=1 (в заголовке блока). + * + * subType (в заголовке блока): + * 1 = TEXT_TEXT + * + * bodyBytes (BigEndian), новый формат: + * [4] lineCode + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber + * + * [2] keyLenBytes (uint16) + * [N] keyUtf8 + * + * [2] valueLenBytes (uint16) + * [M] valueUtf8 + */ +public final class UserParamBody implements BodyRecord, BodyHasLine { + + public static final short TYPE = 4; + public static final short VER = 1; + + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + public final short subType; // из header + public final short version; // из header + + // line + public final int lineCode; + public final int prevLineNumber; + public final byte[] prevLineHash32; + public final int thisLineNumber; + + public final String paramKey; + public final String paramValue; + + public UserParamBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + + this.subType = subType; + this.version = version; + + if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("UserParamBody version must be 1, got=" + (this.version & 0xFFFF)); + } + if ((this.subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF)) { + throw new IllegalArgumentException("Bad UserParam subType: " + (this.subType & 0xFFFF)); + } + + // минимум: lineCode(4)+line(4+32+4) + keyLen(2)+key(1) + valLen(2)+val(1) + if (bodyBytes.length < 4 + (4 + 32 + 4) + 2 + 1 + 2 + 1) { + throw new IllegalArgumentException("UserParamBody too short"); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + + this.lineCode = bb.getInt(); + + this.prevLineNumber = bb.getInt(); + + this.prevLineHash32 = new byte[32]; + bb.get(this.prevLineHash32); + + this.thisLineNumber = bb.getInt(); + + int keyLen = Short.toUnsignedInt(bb.getShort()); + if (keyLen <= 0) throw new IllegalArgumentException("paramKeyLen is 0"); + if (bb.remaining() < keyLen + 2) throw new IllegalArgumentException("UserParam key payload too short"); + + byte[] keyBytes = new byte[keyLen]; + bb.get(keyBytes); + + int valLen = Short.toUnsignedInt(bb.getShort()); + if (valLen <= 0) throw new IllegalArgumentException("paramValueLen is 0"); + if (bb.remaining() < valLen) throw new IllegalArgumentException("UserParam value payload too short"); + + byte[] valBytes = new byte[valLen]; + bb.get(valBytes); + + if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + + this.paramKey = strictUtf8(keyBytes, "paramKey"); + this.paramValue = strictUtf8(valBytes, "paramValue"); + + if (this.paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); + if (this.paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); + } + + public UserParamBody(int lineCode, + int prevLineNumber, + byte[] prevLineHash32, + int thisLineNumber, + String paramKey, + String paramValue) { + + Objects.requireNonNull(paramKey, "paramKey == null"); + Objects.requireNonNull(paramValue, "paramValue == null"); + + if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); + + this.subType = MsgSubType.USER_PARAM_TEXT_TEXT; + this.version = VER; + + this.lineCode = lineCode; + this.prevLineNumber = prevLineNumber; + this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + this.thisLineNumber = thisLineNumber; + + if (paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); + if (paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); + + this.paramKey = paramKey; + this.paramValue = paramValue; + } + + @Override + public UserParamBody check() { + if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); + + if ((subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF)) + throw new IllegalArgumentException("Bad UserParam subType: " + (subType & 0xFFFF)); + + if (prevLineNumber == -1) { + if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1"); + if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1"); + } else { + if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); + } + + if (paramKey == null || paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); + if (paramValue == null || paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); + + return this; + } + + @Override + public byte[] toBytes() { + byte[] keyUtf8 = paramKey.getBytes(StandardCharsets.UTF_8); + byte[] valUtf8 = paramValue.getBytes(StandardCharsets.UTF_8); + + if (keyUtf8.length == 0 || keyUtf8.length > 65535) throw new IllegalArgumentException("paramKey utf8 len must be 1..65535"); + if (valUtf8.length == 0 || valUtf8.length > 65535) throw new IllegalArgumentException("paramValue utf8 len must be 1..65535"); + + int cap = 4 + (4 + 32 + 4) + + 2 + keyUtf8.length + + 2 + valUtf8.length; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + + bb.putInt(lineCode); + + bb.putInt(prevLineNumber); + bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); + bb.putInt(thisLineNumber); + + bb.putShort((short) keyUtf8.length); + bb.put(keyUtf8); + + bb.putShort((short) valUtf8.length); + bb.put(valUtf8); + + return bb.array(); + } + + private static String strictUtf8(byte[] bytes, String fieldName) { + var decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + + try { + return decoder.decode(ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException e) { + throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); + } + } + + private static boolean isAllZero32(byte[] b) { + if (b == null || b.length != 32) return true; + for (int i = 0; i < 32; i++) if (b[i] != 0) return false; + return true; + } + + /* ====================== BodyHasLine ====================== */ + @Override public int lineCode() { return lineCode; } + @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } + @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } + @Override public int lineSeq() { return thisLineNumber; } +} +//package blockchain; +// +///** +// * LineIndex — канонические номера линий блокчейна. +// * +// * Линия = независимая последовательность блоков внутри одного блокчейна. +// */ +//public final class LineIndex { +// +// private LineIndex() {} +// +// public static final short HEADER = 0; // genesis / идентификация +// public static final short TEXT = 1; // сообщения да надо +// public static final short REACTION = 2; // реакции не надо +// public static final short CONNECTION = 3; // связи (friend/contact/follow) да надо +// public static final short USER_PARAM = 4; // параметры профиля да надо +//} +package blockchain; + +/** + * MsgSubType — единое место для ВСЕХ subType сообщений (msg_sub_type). + * + * Правило: + * - НИКАКИХ "магических чисел" subType по проекту. + * - В тестах, в body-классах и в SQL-триггерах используем только эти константы. + * + * Важно: + * - Значения менять после релиза нельзя (иначе сломается совместимость). + * + * ========================================================================= + * Про EDIT-типы (важные правила, чтобы не было “двойных правок”): + * + * 1) EDIT разрешён ТОЛЬКО автору (в своём блокчейне). + * Никаких “я отредачу чужое” — нельзя. + * + * 2) EDIT всегда ссылается ТОЛЬКО на ОРИГИНАЛ: + * - EDIT_POST -> на исходный POST + * - EDIT_REPLY -> на исходный REPLY + * НЕЛЬЗЯ ссылаться на предыдущий EDIT (цепочка edit-ов запрещена). + * + * 3) REPLY может ссылаться на блоки в чужих линиях / чужих каналах, + * и существование цели на уровне check() не проверяется + * (check() БД не видит). Если цели нет — “никто не увидит” и ок. + * ========================================================================= + */ +public final class MsgSubType { + + private MsgSubType() {} + + /* ===================== HEADER (msg_type=0) ===================== */ + + /** HeaderBody: subType всегда 0 (compat). */ + public static final short HEADER_COMPAT = 0; + public static final short TECH_CREATE_CHANNEL = 1; + + /* ===================== TEXT (msg_type=1) ===================== */ + + /** + * POST — обычный пост в канале (в линии канала). + * Имеет hasLine (prevLineNumber/prevLineHash32/thisLineNumber). + */ + public static final short TEXT_POST = 10; + + /** + * EDIT_POST — редактирование ПОСТА. + * Имеет hasLine (принадлежит линии канала) + * И имеет target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName). + */ + public static final short TEXT_EDIT_POST = 11; + + /** + * REPLY — ответ на сообщение. + * НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32). + * Может указывать на чужой блокчейн/чужую линию/чужой канал. + */ + public static final short TEXT_REPLY = 20; + + /** + * EDIT_REPLY — редактирование ОТВЕТА. + * НЕ в линии. Имеет target на ОРИГИНАЛЬНЫЙ REPLY (без toBlockchainName). + */ + public static final short TEXT_EDIT_REPLY = 21; + + /* ===================== REACTION (msg_type=2) ===================== */ + + /** Лайк (LIKE). */ + public static final short REACTION_LIKE = 1; + + /* ===================== CONNECTION (msg_type=3) ===================== */ + + /** Добавить в друзья. */ + public static final short CONNECTION_FRIEND = 10; + /** Удалить из друзей. */ + public static final short CONNECTION_UNFRIEND = 11; + + /** Добавить в контакты. */ + public static final short CONNECTION_CONTACT = 20; + /** Удалить из контактов. */ + public static final short CONNECTION_UNCONTACT = 21; + + /** Подписаться (follow). */ + public static final short CONNECTION_FOLLOW = 30; + /** Отписаться (unfollow). */ + public static final short CONNECTION_UNFOLLOW = 31; + + /* ===================== USER_PARAM (msg_type=4) ===================== */ + + /** Параметр профиля key/value (обе строки). */ + public static final short USER_PARAM_TEXT_TEXT = 1; +} +package utils.blockchain; + +import java.util.Objects; + +public final class BlockchainNameUtil { + + /** + * Теперь новое правило: + * blockchainName = login + "-"+ 3 цифры + * Пример: "Dima-001" -> "Dima" + * + * Сколько символов отрезаем с конца blockchainName, чтобы получить login: "-001" = 4 + */ + public static final int BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN = 4; + + private BlockchainNameUtil() {} + + /** + * Извлечь login из blockchainName: отрезаем последние 4 символа ("-NNN"). + * Пример: "Dima-001" -> "Dima" + */ + public static String loginFromBlockchainName(String blockchainName) { + if (blockchainName == null) return null; + + String s = blockchainName.trim(); + if (!hasDashAnd3DigitsSuffix(s)) return null; + + return s.substring(0, s.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN); + } + + /** + * Проверка правила: + * - blockchainName должен оканчиваться на "-"+3 цифры + * - blockchainName без суффикса "-NNN" должен равняться login + * + * ВАЖНО: + * - сравнение строгое (case-sensitive) + * - null/blank считаем невалидным + */ + public static boolean isBlockchainNameMatchesLogin(String blockchainName, String login) { + if (blockchainName == null || login == null) return false; + + String bn = blockchainName.trim(); + String lg = login.trim(); + + if (bn.isEmpty() || lg.isEmpty()) return false; + if (!hasDashAnd3DigitsSuffix(bn)) return false; + + String extracted = bn.substring(0, bn.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN); + return Objects.equals(extracted, lg); + } + + private static boolean hasDashAnd3DigitsSuffix(String s) { + if (s == null) return false; + int len = s.length(); + if (len <= BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN) return false; + + int dashPos = len - 4; + if (s.charAt(dashPos) != '-') return false; + + char c1 = s.charAt(len - 3); + char c2 = s.charAt(len - 2); + char c3 = s.charAt(len - 1); + + return isDigit(c1) && isDigit(c2) && isDigit(c3); + } + + private static boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } +} +package utils.files; + +import java.io.IOException; +import java.nio.file.*; +import java.util.Objects; + +/** + * =============================================================== + * FileStoreUtil — утилита работы с файлами в папке data/. + * + * Теперь поддерживает: + * - основной файл блокчейна: .bch + * - временный файл блокчейна: .tmp_bch + * + * Важное: + * - validateSimpleFileName() запрещает path traversal. + * - atomicReplaceBlockchainFile(): пытается сделать ATOMIC_MOVE (если ФС поддерживает), + * иначе делает обычный REPLACE_EXISTING move. + * =============================================================== + */ +public final class FileStoreUtil { + + /** Базовая папка для хранения всех файлов (создаётся автоматически). */ + public static final String DATA_DIR_NAME = "data"; + + /** Расширение основного файла блокчейна. */ + public static final String BLOCKCHAIN_FILE_EXTENSION = ".bch"; + + /** Расширение временного файла (старое+новое). */ + public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch"; + + private static final FileStoreUtil INSTANCE = new FileStoreUtil(); + + private final Path dataDirPath; + + private FileStoreUtil() { + this.dataDirPath = Paths.get(DATA_DIR_NAME); + ensureDataDirExists(); + } + + public static FileStoreUtil getInstance() { + return INSTANCE; + } + + /* ===================================================================== */ + /* ======================== Базовые операции =========================== */ + /* ===================================================================== */ + + public void newFile(String fileName, byte[] data) { + Objects.requireNonNull(data, "data == null"); + Path target = resolveSafe(fileName); + try { + Files.write(target, data, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE); + } catch (IOException e) { + throw new IllegalStateException("Не удалось записать файл: " + target, e); + } + } + + public void addDataToFile(String fileName, byte[] data) { + Objects.requireNonNull(data, "data == null"); + Path target = resolveSafe(fileName); + try { + Files.write(target, data, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND); + } catch (IOException e) { + throw new IllegalStateException("Не удалось дописать файл: " + target, e); + } + } + + public byte[] readAllDataFromFile(String fileName) { + Path target = resolveSafe(fileName); + if (!Files.exists(target)) { + throw new IllegalStateException("Файл не найден: " + target); + } + try { + return Files.readAllBytes(target); + } catch (IOException e) { + throw new IllegalStateException("Не удалось прочитать файл: " + target, e); + } + } + + public boolean exists(String fileName) { + Path target = resolveSafe(fileName); + return Files.exists(target); + } + + public long size(String fileName) { + Path target = resolveSafe(fileName); + try { + return Files.size(target); + } catch (IOException e) { + throw new IllegalStateException("Не удалось получить размер файла: " + target, e); + } + } + + /* ===================================================================== */ + /* ===================== Блокчейн-файлы по имени ======================= */ + /* ===================================================================== */ + + /** .bch */ + public String buildBlockchainFileName(String blockchainName) { + validateSimpleFileName(blockchainName); + return blockchainName + BLOCKCHAIN_FILE_EXTENSION; + } + + /** .tmp_bch */ + public String buildBlockchainTmpFileName(String blockchainName) { + validateSimpleFileName(blockchainName); + return blockchainName + BLOCKCHAIN_TMP_EXTENSION; + } + + public Path resolveBlockchainPath(String blockchainName) { + return resolveSafe(buildBlockchainFileName(blockchainName)); + } + + public Path resolveBlockchainTmpPath(String blockchainName) { + return resolveSafe(buildBlockchainTmpFileName(blockchainName)); + } + + public byte[] readBlockchain(String blockchainName) { + return readAllDataFromFile(buildBlockchainFileName(blockchainName)); + } + + public void writeBlockchainTmp(String blockchainName, byte[] data) { + newFile(buildBlockchainTmpFileName(blockchainName), data); + } + + /** + * Атомарно заменить основной файл блокчейна временным: + * .tmp_bch -> .bch + * + * Стратегия: + * 1) Пытаемся Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING) + * 2) Если ATOMIC_MOVE не поддерживается — делаем move с REPLACE_EXISTING без атомарности + * + * Важный нюанс: + * - атомарность гарантируется только в пределах одной файловой системы. + */ + public void atomicReplaceBlockchainFile(String blockchainName) { + Path tmp = resolveBlockchainTmpPath(blockchainName); + Path main = resolveBlockchainPath(blockchainName); + + if (!Files.exists(tmp)) { + throw new IllegalStateException("TMP-файл не найден: " + tmp); + } + + try { + // 1) Пытаемся атомарный move + Files.move(tmp, main, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + // 2) Если ФС не поддерживает атомарный move — делаем обычный replace + try { + Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException ex) { + throw new IllegalStateException("Не удалось заменить файл блокчейна (non-atomic): " + main, ex); + } + } catch (IOException e) { + throw new IllegalStateException("Не удалось заменить файл блокчейна (atomic): " + main, e); + } + } + + /* ===================================================================== */ + /* ============================ Helpers ================================= */ + /* ===================================================================== */ + + private void ensureDataDirExists() { + try { + if (!Files.exists(dataDirPath)) { + Files.createDirectories(dataDirPath); + } + } catch (IOException e) { + throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e); + } + } + + private Path resolveSafe(String fileName) { + validateSimpleFileName(fileName); + return dataDirPath.resolve(fileName); + } + + /** + * Валидация "простого имени": + * - запрещаем слэши, обратные слэши, ".." + * - запрещаем пустоту + * + * Важно: сюда у нас попадает и blockchainName (как часть имени файла), + * поэтому blockchainName должен быть "простым": без путей. + */ + private void validateSimpleFileName(String fileName) { + Objects.requireNonNull(fileName, "fileName == null"); + if (fileName.isBlank()) { + throw new IllegalArgumentException("Имя файла не должно быть пустым"); + } + if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) { + throw new IllegalArgumentException("Недопустимое имя файла: " + fileName); + } + } +} diff --git a/shine-server-crypto/build.gradle b/shine-server-crypto/build.gradle new file mode 100644 index 0000000..728b729 --- /dev/null +++ b/shine-server-crypto/build.gradle @@ -0,0 +1,29 @@ +plugins { + id 'java' +} + +group = 'shine' // можешь поставить свой group +version = '1.0.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // BouncyCastle для Ed25519 и SHA-256 + implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' + + implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера + + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/shine-server-db/all_files.txt b/shine-server-db/all_files.txt new file mode 100644 index 0000000..b1511ea --- /dev/null +++ b/shine-server-db/all_files.txt @@ -0,0 +1,2832 @@ +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.ActiveSessionEntry; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +/** + * DAO для таблицы active_sessions. + * + * Правило: + * - методы с Connection НЕ закрывают соединение + * - методы без Connection сами открывают и закрывают соединение + */ +public final class ActiveSessionsDAO { + + private static volatile ActiveSessionsDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private ActiveSessionsDAO() { } + + public static ActiveSessionsDAO getInstance() { + if (instance == null) { + synchronized (ActiveSessionsDAO.class) { + if (instance == null) instance = new ActiveSessionsDAO(); + } + } + return instance; + } + + // -------------------- INSERT -------------------- + + public void insert(Connection c, ActiveSessionEntry session) throws SQLException { + String sql = """ + INSERT INTO active_sessions ( + session_id, + login, + session_key, + storage_pwd, + session_created_at_ms, + last_authirificated_at_ms, + push_endpoint, + push_p256dh_key, + push_auth_key, + client_ip, + client_info_from_client, + client_info_from_request, + user_language + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, session.getSessionId()); + ps.setString(2, session.getLogin()); + ps.setString(3, session.getSessionKey()); + ps.setString(4, session.getStoragePwd()); + ps.setLong(5, session.getSessionCreatedAtMs()); + ps.setLong(6, session.getLastAuthirificatedAtMs()); + ps.setString(7, session.getPushEndpoint()); + ps.setString(8, session.getPushP256dhKey()); + ps.setString(9, session.getPushAuthKey()); + ps.setString(10, session.getClientIp()); + ps.setString(11, session.getClientInfoFromClient()); + ps.setString(12, session.getClientInfoFromRequest()); + ps.setString(13, session.getUserLanguage()); + ps.executeUpdate(); + } + } + + public void insert(ActiveSessionEntry session) throws SQLException { + try (Connection c = db.getConnection()) { + insert(c, session); + } + } + + // -------------------- SELECT -------------------- + + public ActiveSessionEntry getBySessionId(Connection c, String sessionId) throws SQLException { + String sql = """ + SELECT + session_id, + login, + session_key, + storage_pwd, + session_created_at_ms, + last_authirificated_at_ms, + push_endpoint, + push_p256dh_key, + push_auth_key, + client_ip, + client_info_from_client, + client_info_from_request, + user_language + FROM active_sessions + WHERE session_id = ? + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, sessionId); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + public ActiveSessionEntry getBySessionId(String sessionId) throws SQLException { + try (Connection c = db.getConnection()) { + return getBySessionId(c, sessionId); + } + } + + public List getByLogin(Connection c, String login) throws SQLException { + String sql = """ + SELECT + session_id, + login, + session_key, + storage_pwd, + session_created_at_ms, + last_authirificated_at_ms, + push_endpoint, + push_p256dh_key, + push_auth_key, + client_ip, + client_info_from_client, + client_info_from_request, + user_language + FROM active_sessions + WHERE login = ? + """; + + List result = new ArrayList<>(); + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) result.add(mapRow(rs)); + } + } + + return result; + } + + public List getByLogin(String login) throws SQLException { + try (Connection c = db.getConnection()) { + return getByLogin(c, login); + } + } + + // -------------------- UPDATE -------------------- + + public void updateLastAuthirificatedAtMs(Connection c, String sessionId, long lastAuthMs) throws SQLException { + String sql = """ + UPDATE active_sessions + SET last_authirificated_at_ms = ? + WHERE session_id = ? + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, lastAuthMs); + ps.setString(2, sessionId); + ps.executeUpdate(); + } + } + + public void updateLastAuthirificatedAtMs(String sessionId, long lastAuthMs) throws SQLException { + try (Connection c = db.getConnection()) { + updateLastAuthirificatedAtMs(c, sessionId, lastAuthMs); + } + } + + public void updateOnRefresh( + Connection c, + String sessionId, + long lastAuthMs, + String clientIp, + String clientInfoFromClient, + String clientInfoFromRequest, + String userLanguage + ) throws SQLException { + + String sql = """ + UPDATE active_sessions + SET + last_authirificated_at_ms = ?, + client_ip = ?, + client_info_from_client = ?, + client_info_from_request = ?, + user_language = ? + WHERE session_id = ? + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, lastAuthMs); + ps.setString(2, clientIp); + ps.setString(3, clientInfoFromClient); + ps.setString(4, clientInfoFromRequest); + ps.setString(5, userLanguage); + ps.setString(6, sessionId); + ps.executeUpdate(); + } + } + + public void updateOnRefresh( + String sessionId, + long lastAuthMs, + String clientIp, + String clientInfoFromClient, + String clientInfoFromRequest, + String userLanguage + ) throws SQLException { + try (Connection c = db.getConnection()) { + updateOnRefresh(c, sessionId, lastAuthMs, clientIp, clientInfoFromClient, clientInfoFromRequest, userLanguage); + } + } + + // -------------------- DELETE -------------------- + + public void deleteBySessionId(Connection c, String sessionId) throws SQLException { + String sql = "DELETE FROM active_sessions WHERE session_id = ?"; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, sessionId); + ps.executeUpdate(); + } + } + + public void deleteBySessionId(String sessionId) throws SQLException { + try (Connection c = db.getConnection()) { + deleteBySessionId(c, sessionId); + } + } + + // -------------------- MAPPER -------------------- + + private ActiveSessionEntry mapRow(ResultSet rs) throws SQLException { + String sessionId = rs.getString("session_id"); + String login = rs.getString("login"); + String sessionKey = rs.getString("session_key"); + String storagePwd = rs.getString("storage_pwd"); + long sessionCreatedAtMs = rs.getLong("session_created_at_ms"); + long lastAuthirificatedAtMs = rs.getLong("last_authirificated_at_ms"); + String pushEndpoint = rs.getString("push_endpoint"); + String pushP256dhKey = rs.getString("push_p256dh_key"); + String pushAuthKey = rs.getString("push_auth_key"); + String clientIp = rs.getString("client_ip"); + String clientInfoFromClient = rs.getString("client_info_from_client"); + String clientInfoFromRequest = rs.getString("client_info_from_request"); + String userLanguage = rs.getString("user_language"); + + return new ActiveSessionEntry( + sessionId, + login, + sessionKey, + storagePwd, + sessionCreatedAtMs, + lastAuthirificatedAtMs, + pushEndpoint, + pushP256dhKey, + pushAuthKey, + clientIp, + clientInfoFromClient, + clientInfoFromRequest, + userLanguage + ); + } +} +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.BlockchainStateEntry; + +import java.sql.*; + +public final class BlockchainStateDAO { + + private static volatile BlockchainStateDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private BlockchainStateDAO() {} + + public static BlockchainStateDAO getInstance() { + if (instance == null) { + synchronized (BlockchainStateDAO.class) { + if (instance == null) instance = new BlockchainStateDAO(); + } + } + return instance; + } + + /** Получить по blockchainName без внешнего соединения. Сам открывает/закрывает. */ + public BlockchainStateEntry getByBlockchainName(String blockchainName) throws SQLException { + try (Connection c = db.getConnection()) { + return getByBlockchainName(c, blockchainName); + } + } + + /** Получить по blockchainName с внешним соединением. Соединение НЕ закрывает. */ + public BlockchainStateEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { + String sql = """ + SELECT + blockchain_name, + login, + blockchain_key, + size_limit, + file_size_bytes, + last_block_number, + last_block_hash, + updated_at_ms + FROM blockchain_state + WHERE blockchain_name = ? + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, blockchainName); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + /** UPSERT без внешнего соединения. Сам открывает/закрывает. */ + public void upsert(BlockchainStateEntry e) throws SQLException { + try (Connection c = db.getConnection()) { + upsert(c, e); + } + } + + /** UPSERT с внешним соединением. Соединение НЕ закрывает. */ + public void upsert(Connection c, BlockchainStateEntry e) throws SQLException { + String sql = """ + INSERT INTO blockchain_state ( + blockchain_name, + login, + blockchain_key, + size_limit, + file_size_bytes, + last_block_number, + last_block_hash, + updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(blockchain_name) + DO UPDATE SET + login = excluded.login, + blockchain_key = excluded.blockchain_key, + size_limit = excluded.size_limit, + file_size_bytes = excluded.file_size_bytes, + last_block_number= excluded.last_block_number, + last_block_hash = excluded.last_block_hash, + updated_at_ms = excluded.updated_at_ms + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + + ps.setString(i++, e.getBlockchainName()); + ps.setString(i++, nn(e.getLogin())); + ps.setString(i++, nn(e.getBlockchainKey())); + + ps.setLong(i++, e.getSizeLimit()); + ps.setLong(i++, e.getFileSizeBytes()); + + ps.setInt(i++, e.getLastBlockNumber()); + setBytesNullable(ps, i++, e.getLastBlockHash()); + + ps.setLong(i++, e.getUpdatedAtMs()); + + ps.executeUpdate(); + } + } + + /** + * Атомарно увеличить file_size_bytes на deltaBytes, но только если НЕ превысим size_limit. + */ + public boolean tryIncreaseFileSizeWithinLimit(Connection c, String blockchainName, long deltaBytes, long nowMs) throws SQLException { + String sql = """ + UPDATE blockchain_state + SET + file_size_bytes = file_size_bytes + ?, + updated_at_ms = ? + WHERE + blockchain_name = ? + AND (file_size_bytes + ?) <= size_limit + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, deltaBytes); + ps.setLong(2, nowMs); + ps.setString(3, blockchainName); + ps.setLong(4, deltaBytes); + return ps.executeUpdate() > 0; + } + } + + private BlockchainStateEntry mapRow(ResultSet rs) throws SQLException { + BlockchainStateEntry e = new BlockchainStateEntry(); + + e.setBlockchainName(rs.getString("blockchain_name")); + e.setLogin(rs.getString("login")); + e.setBlockchainKey(rs.getString("blockchain_key")); + + e.setSizeLimit(rs.getLong("size_limit")); + e.setFileSizeBytes(rs.getLong("file_size_bytes")); + + e.setLastBlockNumber(rs.getInt("last_block_number")); + e.setLastBlockHash(rs.getBytes("last_block_hash")); // nullable + + e.setUpdatedAtMs(rs.getLong("updated_at_ms")); + + return e; + } + + private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException { + if (b != null) ps.setBytes(index, b); + else ps.setNull(index, Types.BLOB); + } + + private static String nn(String s) { return s == null ? "" : s; } +} +package shine.db.dao; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import shine.db.SqliteDbController; +import shine.db.entities.BlockEntry; + +import java.sql.*; + +/** + * DAO для таблицы blocks (новый формат). + * + * Правило: + * - методы с Connection НЕ закрывают соединение + * - методы без Connection сами открывают и закрывают соединение + * + * Ключ: + * - (bch_name, block_number) — уникальная пара в рамках общей БД сервера. + */ +public final class BlocksDAO { + + private static volatile BlocksDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class); + + private BlocksDAO() { } + + public static BlocksDAO getInstance() { + if (instance == null) { + synchronized (BlocksDAO.class) { + if (instance == null) instance = new BlocksDAO(); + } + } + return instance; + } + + // -------------------- INSERT -------------------- + + /** Вставка с внешним соединением. Соединение НЕ закрывает. */ + public void insert(Connection c, BlockEntry e) throws SQLException { + log.info("DBG BlockEntry: type={} sub={} lineCode={} prevLineNumber={} thisLineNumber={} prevLineHashLen={}", + e.getMsgType(), e.getMsgSubType(), + e.getLineCode(), e.getPrevLineNumber(), e.getThisLineNumber(), + e.getPrevLineHash() == null ? null : e.getPrevLineHash().length + ); + + String sql = """ + INSERT INTO blocks ( + login, + bch_name, + block_number, + msg_type, + msg_sub_type, + block_bytes, + to_login, + to_bch_name, + to_block_number, + to_block_hash, + block_hash, + block_signature, + edited_by_block_number, + line_code, + prev_line_number, + prev_line_hash, + this_line_number + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + + ps.setString(i++, e.getLogin()); + ps.setString(i++, e.getBchName()); + ps.setInt(i++, e.getBlockNumber()); + + ps.setInt(i++, e.getMsgType()); + ps.setInt(i++, e.getMsgSubType()); + + ps.setBytes(i++, e.getBlockBytes()); + + if (e.getToLogin() != null) ps.setString(i++, e.getToLogin()); + else ps.setNull(i++, Types.VARCHAR); + + if (e.getToBchName() != null) ps.setString(i++, e.getToBchName()); + else ps.setNull(i++, Types.VARCHAR); + + if (e.getToBlockNumber() != null) ps.setInt(i++, e.getToBlockNumber()); + else ps.setNull(i++, Types.INTEGER); + + if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash()); + else ps.setNull(i++, Types.BLOB); + + ps.setBytes(i++, e.getBlockHash()); + ps.setBytes(i++, e.getBlockSignature()); + + if (e.getEditedByBlockNumber() != null) ps.setInt(i++, e.getEditedByBlockNumber()); + else ps.setNull(i++, Types.INTEGER); + + // NEW: line_code + if (e.getLineCode() != null) ps.setInt(i++, e.getLineCode()); + else ps.setNull(i++, Types.INTEGER); + + if (e.getPrevLineNumber() != null) ps.setInt(i++, e.getPrevLineNumber()); + else ps.setNull(i++, Types.INTEGER); + + if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash()); + else ps.setNull(i++, Types.BLOB); + + if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber()); + else ps.setNull(i++, Types.INTEGER); + + ps.executeUpdate(); + } + } + + /** Вставка без внешнего соединения. Сам открывает/закрывает. */ + public void insert(BlockEntry e) throws SQLException { + try (Connection c = db.getConnection()) { + insert(c, e); + } + } + + // -------------------- SELECT: HASH BY NUMBER -------------------- + + /** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */ + public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException { + String sql = """ + SELECT block_hash + FROM blocks + WHERE bch_name = ? AND block_number = ? + LIMIT 1 + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, bchName); + ps.setInt(2, blockNumber); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return rs.getBytes("block_hash"); + } + } + } + + public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { + try (Connection c = db.getConnection()) { + return getHashByNumber(c, bchName, blockNumber); + } + } + + // -------------------- SELECT: FULL ENTRY -------------------- + + public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException { + String sql = """ + SELECT + login, + bch_name, + block_number, + msg_type, + msg_sub_type, + block_bytes, + to_login, + to_bch_name, + to_block_number, + to_block_hash, + block_hash, + block_signature, + edited_by_block_number, + line_code, + prev_line_number, + prev_line_hash, + this_line_number + FROM blocks + WHERE bch_name = ? AND block_number = ? + LIMIT 1 + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, bchName); + ps.setInt(2, blockNumber); + + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException { + try (Connection c = db.getConnection()) { + return getByNumber(c, bchName, blockNumber); + } + } + + // -------------------- INTERNAL -------------------- + + private BlockEntry mapRow(ResultSet rs) throws SQLException { + BlockEntry e = new BlockEntry(); + + e.setLogin(rs.getString("login")); + e.setBchName(rs.getString("bch_name")); + e.setBlockNumber(rs.getInt("block_number")); + + e.setMsgType(rs.getInt("msg_type")); + e.setMsgSubType(rs.getInt("msg_sub_type")); + + e.setBlockBytes(rs.getBytes("block_bytes")); + + String toLogin = rs.getString("to_login"); + if (rs.wasNull()) toLogin = null; + e.setToLogin(toLogin); + + String toBchName = rs.getString("to_bch_name"); + if (rs.wasNull()) toBchName = null; + e.setToBchName(toBchName); + + Integer toBlockNumber = (Integer) rs.getObject("to_block_number"); + e.setToBlockNumber(toBlockNumber); + + byte[] toHash = rs.getBytes("to_block_hash"); + if (rs.wasNull()) toHash = null; + e.setToBlockHash(toHash); + + e.setBlockHash(rs.getBytes("block_hash")); + e.setBlockSignature(rs.getBytes("block_signature")); + + Integer editedBy = (Integer) rs.getObject("edited_by_block_number"); + e.setEditedByBlockNumber(editedBy); + + // NEW: line_code + Integer lineCode = (Integer) rs.getObject("line_code"); + e.setLineCode(lineCode); + + Integer prevLn = (Integer) rs.getObject("prev_line_number"); + e.setPrevLineNumber(prevLn); + + byte[] prevLh = rs.getBytes("prev_line_hash"); + if (rs.wasNull()) prevLh = null; + e.setPrevLineHash(prevLh); + + Integer thisLn = (Integer) rs.getObject("this_line_number"); + e.setThisLineNumber(thisLn); + + return e; + } +} +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.IpGeoCacheEntry; + +import java.sql.*; + +/** + * DAO для таблицы ip_geo_cache. + * + * Таблица: + * - ip TEXT PRIMARY KEY + * - geo TEXT + * - updated_at_ms INTEGER NOT NULL + * + * Правило: + * - методы с Connection НЕ закрывают соединение + * - методы без Connection сами открывают и закрывают соединение + */ +public final class IpGeoCacheDAO { + + private static volatile IpGeoCacheDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private IpGeoCacheDAO() { } + + public static IpGeoCacheDAO getInstance() { + if (instance == null) { + synchronized (IpGeoCacheDAO.class) { + if (instance == null) instance = new IpGeoCacheDAO(); + } + } + return instance; + } + + // -------------------- UPSERT -------------------- + + /** UPSERT с внешним соединением. Соединение НЕ закрывает. */ + public void upsert(Connection c, IpGeoCacheEntry entry) throws SQLException { + String sql = """ + INSERT INTO ip_geo_cache (ip, geo, updated_at_ms) + VALUES (?, ?, ?) + ON CONFLICT(ip) + DO UPDATE SET + geo = excluded.geo, + updated_at_ms = excluded.updated_at_ms + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getIp()); + ps.setString(2, entry.getGeo()); + ps.setLong(3, entry.getUpdatedAtMs()); + ps.executeUpdate(); + } + } + + /** UPSERT без внешнего соединения. Сам открывает/закрывает. */ + public void upsert(IpGeoCacheEntry entry) throws SQLException { + try (Connection c = db.getConnection()) { + upsert(c, entry); + } + } + + // -------------------- SELECT -------------------- + + /** Получить по IP с внешним соединением. Соединение НЕ закрывает. */ + public IpGeoCacheEntry getByIp(Connection c, String ip) throws SQLException { + String sql = """ + SELECT ip, geo, updated_at_ms + FROM ip_geo_cache + WHERE ip = ? + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ip); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + /** Получить по IP без внешнего соединения. Сам открывает/закрывает. */ + public IpGeoCacheEntry getByIp(String ip) throws SQLException { + try (Connection c = db.getConnection()) { + return getByIp(c, ip); + } + } + + // -------------------- DELETE -------------------- + + /** Удалить старые записи с внешним соединением. Соединение НЕ закрывает. */ + public int deleteOlderThan(Connection c, long thresholdMs) throws SQLException { + String sql = "DELETE FROM ip_geo_cache WHERE updated_at_ms < ?"; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, thresholdMs); + return ps.executeUpdate(); + } + } + + /** Удалить старые записи без внешнего соединения. Сам открывает/закрывает. */ + public int deleteOlderThan(long thresholdMs) throws SQLException { + try (Connection c = db.getConnection()) { + return deleteOlderThan(c, thresholdMs); + } + } + + // -------------------- MAPPER -------------------- + + private IpGeoCacheEntry mapRow(ResultSet rs) throws SQLException { + String ip = rs.getString("ip"); + String geo = rs.getString("geo"); + long updatedAtMs = rs.getLong("updated_at_ms"); + return new IpGeoCacheEntry(ip, geo, updatedAtMs); + } +} +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.SolanaUserEntry; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +/** + * SolanaUsersDAO — локальная таблица пользователей из Solana. + * + * Таблица: solana_users + * + * Колонки: + * - login TEXT PRIMARY KEY (COLLATE NOCASE) + * - blockchain_name TEXT NOT NULL + * - solana_key TEXT NOT NULL + * - blockchain_key TEXT NOT NULL + * - device_key TEXT NOT NULL + * + * Правило работы с соединениями: + * - методы с Connection НЕ закрывают соединение + * - методы без Connection сами открывают и закрывают соединение + */ +public final class SolanaUsersDAO { + + private static volatile SolanaUsersDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private SolanaUsersDAO() {} + + public static SolanaUsersDAO getInstance() { + if (instance == null) { + synchronized (SolanaUsersDAO.class) { + if (instance == null) instance = new SolanaUsersDAO(); + } + } + return instance; + } + + // -------------------- INSERT -------------------- + + /** Вставка с внешним соединением. Соединение НЕ закрывает. */ + public void insert(Connection c, SolanaUserEntry user) throws SQLException { + String sql = """ + INSERT INTO solana_users ( + login, blockchain_name, solana_key, blockchain_key, device_key + ) VALUES (?, ?, ?, ?, ?) + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, user.getLogin()); + ps.setString(2, user.getBlockchainName()); + ps.setString(3, user.getSolanaKey()); + ps.setString(4, user.getBlockchainKey()); + ps.setString(5, user.getDeviceKey()); + ps.executeUpdate(); + } + } + + /** Вставка без внешнего соединения. Сам открывает/закрывает. */ + public void insert(SolanaUserEntry user) throws SQLException { + try (Connection c = db.getConnection()) { + insert(c, user); + } + } + + // -------------------- EXISTS -------------------- + + /** Проверка существования по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */ + public boolean existsByLogin(Connection c, String login) throws SQLException { + String sql = """ + SELECT 1 + FROM solana_users + WHERE LOWER(login) = LOWER(?) + LIMIT 1 + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + /** Проверка существования по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */ + public boolean existsByLogin(String login) throws SQLException { + try (Connection c = db.getConnection()) { + return existsByLogin(c, login); + } + } + + /** Проверка существования по blockchain_name (case-sensitive, как в БД) с внешним соединением. */ + public boolean existsByBlockchainName(Connection c, String blockchainName) throws SQLException { + String sql = """ + SELECT 1 + FROM solana_users + WHERE blockchain_name = ? + LIMIT 1 + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, blockchainName); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + /** Проверка существования по blockchain_name без внешнего соединения. */ + public boolean existsByBlockchainName(String blockchainName) throws SQLException { + try (Connection c = db.getConnection()) { + return existsByBlockchainName(c, blockchainName); + } + } + + // -------------------- SELECT -------------------- + + /** Получить по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */ + public SolanaUserEntry getByLogin(Connection c, String login) throws SQLException { + String sql = """ + SELECT + login, + blockchain_name, + solana_key, + blockchain_key, + device_key + FROM solana_users + WHERE LOWER(login) = LOWER(?) + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + /** Получить по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */ + public SolanaUserEntry getByLogin(String login) throws SQLException { + try (Connection c = db.getConnection()) { + return getByLogin(c, login); + } + } + + /** Получить по blockchain_name (case-sensitive) с внешним соединением. Соединение НЕ закрывает. */ + public SolanaUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { + String sql = """ + SELECT + login, + blockchain_name, + solana_key, + blockchain_key, + device_key + FROM solana_users + WHERE blockchain_name = ? + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, blockchainName); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + /** Получить по blockchain_name без внешнего соединения. */ + public SolanaUserEntry getByBlockchainName(String blockchainName) throws SQLException { + try (Connection c = db.getConnection()) { + return getByBlockchainName(c, blockchainName); + } + } + + /** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */ + public List searchByLoginPrefix(Connection c, String prefix) throws SQLException { + String sql = """ + SELECT + login, + blockchain_name, + solana_key, + blockchain_key, + device_key + FROM solana_users + WHERE LOWER(login) LIKE ? + ORDER BY login + LIMIT 5 + """; + + List result = new ArrayList<>(); + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, prefix.toLowerCase() + "%"); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) result.add(mapRow(rs)); + } + } + + return result; + } + + /** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */ + public List searchByLoginPrefix(String prefix) throws SQLException { + try (Connection c = db.getConnection()) { + return searchByLoginPrefix(c, prefix); + } + } + + // -------------------- MAPPER -------------------- + + private SolanaUserEntry mapRow(ResultSet rs) throws SQLException { + SolanaUserEntry e = new SolanaUserEntry(); + + e.setLogin(rs.getString("login")); + e.setBlockchainName(rs.getString("blockchain_name")); + e.setSolanaKey(rs.getString("solana_key")); + e.setBlockchainKey(rs.getString("blockchain_key")); + e.setDeviceKey(rs.getString("device_key")); + + return e; + } +} +package shine.db.dao; + +import shine.db.MsgSubType; +import shine.db.SqliteDbController; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +/** + * SubscriptionsDAO — агрегатный DAO для "каналов" (подписок). + * + * Возвращает по каждой активной подписке (FOLLOW) + "сам на себя": + * - login цели (channelLogin) + * - blockchainName цели (channelBchName) + * - count публикаций (TEXT_NEW) + * - last publication: bytes оригинального блока (для timestamp) + * - last publication: bytes актуального блока (edit или orig) — для текста превью + * + * Важно: + * - это НЕ таблица => сущность результата хранится вложенным классом. + * - методы с Connection НЕ закрывают соединение + * - методы без Connection сами открывают и закрывают соединение + */ +public final class SubscriptionsDAO { + + private static volatile SubscriptionsDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private SubscriptionsDAO() {} + + public static SubscriptionsDAO getInstance() { + if (instance == null) { + synchronized (SubscriptionsDAO.class) { + if (instance == null) instance = new SubscriptionsDAO(); + } + } + return instance; + } + + /** Результат одной строки ("канал") для подписок. */ + public static final class ChannelRow { + + private final String channelLogin; + private final String channelBchName; + + private final int publicationsCount; + + /** Последняя публикация: global number (nullable если публикаций нет). */ + private final Integer lastPublicationGlobalNumber; + + /** Байты оригинальной публикации (FULL bytes блока) — для timestamp (nullable). */ + private final byte[] lastPublicationBlockBytes; + + /** Если публикация редактировалась: global number edit-блока (nullable). */ + private final Integer lastEditGlobalNumber; + + /** Байты edit-блока (FULL bytes блока) (nullable). */ + private final byte[] lastEditBlockBytes; + + public ChannelRow(String channelLogin, + String channelBchName, + int publicationsCount, + Integer lastPublicationGlobalNumber, + byte[] lastPublicationBlockBytes, + Integer lastEditGlobalNumber, + byte[] lastEditBlockBytes) { + + this.channelLogin = channelLogin; + this.channelBchName = channelBchName; + this.publicationsCount = publicationsCount; + this.lastPublicationGlobalNumber = lastPublicationGlobalNumber; + this.lastPublicationBlockBytes = lastPublicationBlockBytes; + this.lastEditGlobalNumber = lastEditGlobalNumber; + this.lastEditBlockBytes = lastEditBlockBytes; + } + + public String getChannelLogin() { return channelLogin; } + public String getChannelBchName() { return channelBchName; } + + public int getPublicationsCount() { return publicationsCount; } + + public Integer getLastPublicationGlobalNumber() { return lastPublicationGlobalNumber; } + public byte[] getLastPublicationBlockBytes() { return lastPublicationBlockBytes; } + + public Integer getLastEditGlobalNumber() { return lastEditGlobalNumber; } + public byte[] getLastEditBlockBytes() { return lastEditBlockBytes; } + } + + // В проекте msg_type=1 означает TEXT (у тебя это уже зафиксировано). + private static final int MSG_TYPE_TEXT = 1; + + /** + * Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой: + * - count публикаций (TEXT_NEW) + * - последнюю публикацию (orig bytes) + её edit (если есть) + * + * Поведение при 0 публикаций: + * - publications_count = 0 + * - last_pub_* = NULL + * - last_edit_* = NULL + */ + public List getSubscribedChannels(Connection c, String requesterLogin) throws SQLException { + + String sql = """ + WITH subs AS ( + -- 1) FOLLOW-каналы + SELECT + cs.to_login AS channel_login, + cs.to_bch_name AS channel_bch_name + FROM connections_state cs + WHERE cs.login = ? + AND cs.rel_type = ? + + UNION + + -- 2) self: все блокчейны пользователя (если их несколько) + SELECT + bs.login AS channel_login, + bs.blockchain_name AS channel_bch_name + FROM blockchain_state bs + WHERE bs.login = ? + ), + pub_counts AS ( + SELECT + b.login AS channel_login, + b.bch_name AS channel_bch_name, + COUNT(*) AS publications_count + FROM blocks b + JOIN subs s + ON s.channel_login = b.login + AND s.channel_bch_name = b.bch_name + WHERE b.msg_type = ? + AND b.msg_sub_type = ? + GROUP BY b.login, b.bch_name + ), + last_pub AS ( + SELECT + b.login AS channel_login, + b.bch_name AS channel_bch_name, + MAX(b.block_global_number) AS last_pub_global_number + FROM blocks b + JOIN subs s + ON s.channel_login = b.login + AND s.channel_bch_name = b.bch_name + WHERE b.msg_type = ? + AND b.msg_sub_type = ? + GROUP BY b.login, b.bch_name + ), + last_pub_block AS ( + SELECT + b.login AS channel_login, + b.bch_name AS channel_bch_name, + b.block_global_number AS last_pub_global_number, + b.block_byte AS last_pub_block_bytes, + b.edited_by_block_global_number AS last_edit_global_number + FROM blocks b + JOIN last_pub lp + ON lp.channel_login = b.login + AND lp.channel_bch_name = b.bch_name + AND lp.last_pub_global_number = b.block_global_number + ), + last_edit_block AS ( + SELECT + e.login AS channel_login, + e.bch_name AS channel_bch_name, + e.block_global_number AS last_edit_global_number, + e.block_byte AS last_edit_block_bytes + FROM blocks e + JOIN last_pub_block p + ON p.channel_login = e.login + AND p.channel_bch_name = e.bch_name + AND p.last_edit_global_number = e.block_global_number + ) + SELECT + s.channel_login, + s.channel_bch_name, + COALESCE(pc.publications_count, 0) AS publications_count, + p.last_pub_global_number, + p.last_pub_block_bytes, + p.last_edit_global_number, + e.last_edit_block_bytes + FROM subs s + LEFT JOIN pub_counts pc + ON pc.channel_login = s.channel_login + AND pc.channel_bch_name = s.channel_bch_name + LEFT JOIN last_pub_block p + ON p.channel_login = s.channel_login + AND p.channel_bch_name = s.channel_bch_name + LEFT JOIN last_edit_block e + ON e.channel_login = s.channel_login + AND e.channel_bch_name = s.channel_bch_name + ORDER BY s.channel_login, s.channel_bch_name + """; + + List out = new ArrayList<>(); + + try (PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + + // FOLLOW + ps.setString(i++, requesterLogin); + ps.setInt(i++, (int) MsgSubType.CONNECTION_FOLLOW); + + // self + ps.setString(i++, requesterLogin); + + // pub_counts + ps.setInt(i++, MSG_TYPE_TEXT); + ps.setInt(i++, (int) MsgSubType.TEXT_NEW); + + // last_pub + ps.setInt(i++, MSG_TYPE_TEXT); + ps.setInt(i++, (int) MsgSubType.TEXT_NEW); + + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String channelLogin = rs.getString("channel_login"); + String channelBchName = rs.getString("channel_bch_name"); + + int publicationsCount = rs.getInt("publications_count"); + + Integer lastPubGn = (Integer) rs.getObject("last_pub_global_number"); + byte[] lastPubBytes = rs.getBytes("last_pub_block_bytes"); + + Integer lastEditGn = (Integer) rs.getObject("last_edit_global_number"); + byte[] lastEditBytes = rs.getBytes("last_edit_block_bytes"); + + out.add(new ChannelRow( + channelLogin, + channelBchName, + publicationsCount, + lastPubGn, + lastPubBytes, + lastEditGn, + lastEditBytes + )); + } + } + } + + return out; + } + + /** Вариант без внешнего соединения. Сам открывает/закрывает. */ + public List getSubscribedChannels(String requesterLogin) throws SQLException { + try (Connection c = db.getConnection()) { + return getSubscribedChannels(c, requesterLogin); + } + } +} +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.SolanaUserEntry; + +import java.sql.*; + +/** + * UserCreateDAO — атомарное добавление пользователя: + * - solana_users (login, blockchain_name, solana_key, blockchain_key, device_key) + * - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...) + * + * ВАЖНО: + * - только INSERT (без перезаписи существующих записей) + * - если login или blockchainName заняты — возвращаем false (пользователь уже есть/занято) + */ +public final class UserCreateDAO { + + private static volatile UserCreateDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + private final SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance(); + + private UserCreateDAO() {} + + public static UserCreateDAO getInstance() { + if (instance == null) { + synchronized (UserCreateDAO.class) { + if (instance == null) instance = new UserCreateDAO(); + } + } + return instance; + } + + /** + * @return true если добавили; false если занято (login уже есть или blockchainName уже существует). + */ + public boolean insertUserWithBlockchain( + String login, + String blockchainName, + String solanaKey, + String blockchainKey, + String deviceKey, + long sizeLimit, + long nowMs + ) throws SQLException { + + try (Connection c = db.getConnection()) { + boolean oldAuto = c.getAutoCommit(); + c.setAutoCommit(false); + + // BEGIN IMMEDIATE — чтобы сразу взять write-lock и не ловить гонки + try (Statement st = c.createStatement()) { + st.execute("BEGIN IMMEDIATE"); + } + + try { + // 1) solana_users + SolanaUserEntry u = new SolanaUserEntry(); + u.setLogin(login); + u.setBlockchainName(blockchainName); + u.setSolanaKey(solanaKey); + u.setBlockchainKey(blockchainKey); + u.setDeviceKey(deviceKey); + + usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint + + // 2) blockchain_state — строго INSERT, без UPSERT (иначе можно перезаписать существующую цепочку) + insertBlockchainStateStrict( + c, + blockchainName, + login, + blockchainKey, + sizeLimit, + nowMs + ); + + c.commit(); + return true; + + } catch (SQLException e) { + c.rollback(); + + String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(); + if (msg.contains("constraint")) { + return false; + } + throw e; + + } finally { + c.setAutoCommit(oldAuto); + } + } + } + + private static void insertBlockchainStateStrict( + Connection c, + String blockchainName, + String login, + String blockchainKey, + long sizeLimit, + long nowMs + ) throws SQLException { + + String sql = """ + INSERT INTO blockchain_state ( + blockchain_name, + login, + blockchain_key, + size_limit, + file_size_bytes, + last_block_number, + last_block_hash, + updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + ps.setString(i++, blockchainName); + ps.setString(i++, login); + ps.setString(i++, blockchainKey); + + ps.setLong(i++, sizeLimit); + ps.setLong(i++, 0L); + + ps.setInt(i++, -1); + ps.setNull(i++, Types.BLOB); // старт: блоков ещё нет + ps.setLong(i++, nowMs); + + ps.executeUpdate(); // если blockchainName занят -> constraint (PK) + } + } +} +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.UserParamEntry; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +/** + * UserParamsDAO — хранение сохранённых параметров пользователя. + * + * Правило: + * - методы с Connection НЕ закрывают соединение + * - методы без Connection сами открывают и закрывают соединение + * + * ЛОГИКА time_ms: + * - БД принимает запись только если она "новее" (time_ms строго больше текущего). + * - Реализовано атомарно одним SQL: UPSERT + WHERE users_params.time_ms < excluded.time_ms + */ +public final class UserParamsDAO { + + private static volatile UserParamsDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private UserParamsDAO() { } + + public static UserParamsDAO getInstance() { + if (instance == null) { + synchronized (UserParamsDAO.class) { + if (instance == null) instance = new UserParamsDAO(); + } + } + return instance; + } + + // -------------------- UPSERT (IF NEWER) -------------------- + + public int upsertIfNewer(Connection c, UserParamEntry e) throws SQLException { + String sql = """ + INSERT INTO users_params ( + login, + param, + time_ms, + value, + device_key, + signature + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(login, param) + DO UPDATE SET + time_ms = excluded.time_ms, + value = excluded.value, + device_key = excluded.device_key, + signature = excluded.signature + WHERE users_params.time_ms < excluded.time_ms + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, e.getLogin()); + ps.setString(2, e.getParam()); + ps.setLong(3, e.getTimeMs()); + ps.setString(4, e.getValue()); + + if (e.getDeviceKey() != null) ps.setString(5, e.getDeviceKey()); + else ps.setNull(5, Types.VARCHAR); + + if (e.getSignature() != null) ps.setString(6, e.getSignature()); + else ps.setNull(6, Types.VARCHAR); + + return ps.executeUpdate(); + } + } + + public int upsertIfNewer(UserParamEntry e) throws SQLException { + try (Connection c = db.getConnection()) { + return upsertIfNewer(c, e); + } + } + + // -------------------- SELECT -------------------- + + public UserParamEntry getByLoginAndParam(Connection c, String login, String param) throws SQLException { + String sql = """ + SELECT + login, + param, + time_ms, + value, + device_key, + signature + FROM users_params + WHERE login = ? AND param = ? + LIMIT 1 + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setString(2, param); + + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + public UserParamEntry getByLoginAndParam(String login, String param) throws SQLException { + try (Connection c = db.getConnection()) { + return getByLoginAndParam(c, login, param); + } + } + + public List getByLogin(Connection c, String login) throws SQLException { + String sql = """ + SELECT + login, + param, + time_ms, + value, + device_key, + signature + FROM users_params + WHERE login = ? + ORDER BY time_ms DESC + """; + + List list = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) list.add(mapRow(rs)); + } + } + return list; + } + + public List getByLogin(String login) throws SQLException { + try (Connection c = db.getConnection()) { + return getByLogin(c, login); + } + } + + // -------------------- MAPPER -------------------- + + private static UserParamEntry mapRow(ResultSet rs) throws SQLException { + UserParamEntry e = new UserParamEntry(); + e.setLogin(rs.getString("login")); + e.setParam(rs.getString("param")); + e.setTimeMs(rs.getLong("time_ms")); + e.setValue(rs.getString("value")); + + String dk = rs.getString("device_key"); + if (rs.wasNull()) dk = null; + e.setDeviceKey(dk); + + String sig = rs.getString("signature"); + if (rs.wasNull()) sig = null; + e.setSignature(sig); + + return e; + } +} +package shine.db; + +import utils.config.AppConfig; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.*; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; + +/** + * DatabaseInitializer — создание новой SQLite-БД по схеме SHiNE. + * + * В этой версии: + * - создаём ТОЛЬКО таблицы/индексы + * - в конце вызываем DatabaseTriggersInstaller.createAllTriggers(st) + * + * v2 (sessions): + * - active_sessions.session_pwd удалён + * - active_sessions.session_key хранит публичный ключ сессии (sessionPubKeyB64) + */ +public final class DatabaseInitializer { + + private DatabaseInitializer() {} + + /* ===================== TEXT (msg_type=1) ===================== */ + + public static final short TEXT_NEW = 1; + public static final short TEXT_REPLY = 2; + public static final short TEXT_REPOST = 3; + public static final short TEXT_EDIT = 10; + + /* ===================== REACTION (msg_type=2) ===================== */ + + public static final short REACTION_LIKE = 1; + + /* ===================== CONNECTION (msg_type=3) ===================== */ + public static final short CONNECTION_FRIEND = 10; + public static final short CONNECTION_UNFRIEND = 11; + + public static final short CONNECTION_CONTACT = 20; + public static final short CONNECTION_UNCONTACT = 21; + + public static final short CONNECTION_FOLLOW = 30; + public static final short CONNECTION_UNFOLLOW = 31; + + public static void createNewDB(String[] args) { + AppConfig config = AppConfig.getInstance(); + String dbPath = config.getParam("db.path"); + + if (dbPath == null || dbPath.isBlank()) { + System.err.println("Параметр db.path не задан в application.properties"); + return; + } + + Path dbFile = Paths.get(dbPath); + try { + Path parent = dbFile.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + + if (Files.exists(dbFile)) { + System.out.println("Файл базы данных уже существует: " + dbFile.toAbsolutePath()); + System.out.print("Пересоздать БД (СТАРАЯ БУДЕТ УДАЛЕНА)? [y/N]: "); + + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + String answer = reader.readLine(); + if (!"y".equalsIgnoreCase(answer) && !"yes".equalsIgnoreCase(answer)) { + System.out.println("Операция отменена. БД не изменена."); + return; + } + + Files.delete(dbFile); + System.out.println("Старый файл БД удалён."); + } + + createSchema("jdbc:sqlite:" + dbPath); + System.out.println("Новая БД успешно создана по пути: " + dbFile.toAbsolutePath()); + + } catch (IOException e) { + System.err.println("Ошибка работы с файлом БД: " + e.getMessage()); + } catch (SQLException e) { + System.err.println("Ошибка создания схемы БД: " + e.getMessage()); + } + } + + private static void createSchema(String jdbcUrl) throws SQLException { + try { + Class.forName("org.sqlite.JDBC"); + } catch (ClassNotFoundException e) { + throw new RuntimeException("SQLite JDBC driver not found", e); + } + + try (Connection conn = DriverManager.getConnection(jdbcUrl); + Statement st = conn.createStatement()) { + + st.execute("PRAGMA foreign_keys = ON"); + + // 1. solana_users + // ВАЖНО: + // - Все требуемые поля теперь лежат в solana_users: + // login, blockchain_name, solana_key, blockchain_key, device_key + // - Поиск по login в DAO сделан case-insensitive. + // - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY. + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS solana_users ( + login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, + blockchain_name TEXT NOT NULL, + solana_key TEXT NOT NULL, + blockchain_key TEXT NOT NULL, + device_key TEXT NOT NULL + ); + """); + + st.executeUpdate(""" + CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_users_blockchain_name + ON solana_users (blockchain_name); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_solana_users_login + ON solana_users (login); + """); + + // 2. active_sessions (v2) + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS active_sessions ( + session_id TEXT NOT NULL PRIMARY KEY, + login TEXT NOT NULL, + session_key TEXT NOT NULL, + storage_pwd TEXT NOT NULL, + session_created_at_ms INTEGER NOT NULL, + last_authirificated_at_ms INTEGER NOT NULL, + push_endpoint TEXT, + push_p256dh_key TEXT, + push_auth_key TEXT, + client_ip TEXT, + client_info_from_client TEXT, + client_info_from_request TEXT, + user_language TEXT, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_active_sessions_login + ON active_sessions (login); + """); + + // 3. users_params + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS users_params ( + login TEXT NOT NULL, + param TEXT NOT NULL, + time_ms INTEGER NOT NULL, + value TEXT NOT NULL, + device_key TEXT, + signature TEXT, + FOREIGN KEY (login) REFERENCES solana_users(login), + UNIQUE (login, param) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_users_params_login + ON users_params (login); + """); + + // 4. ip_geo_cache + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS ip_geo_cache ( + ip TEXT NOT NULL PRIMARY KEY, + geo TEXT, + updated_at_ms INTEGER NOT NULL + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_ip_geo_cache_updated_at + ON ip_geo_cache (updated_at_ms); + """); + + // 5. blockchain_state + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS blockchain_state ( + blockchain_name TEXT NOT NULL PRIMARY KEY, + login TEXT NOT NULL, + blockchain_key TEXT NOT NULL, + + size_limit INTEGER NOT NULL, + file_size_bytes INTEGER NOT NULL, + + last_block_number INTEGER NOT NULL, + last_block_hash BLOB, + + updated_at_ms INTEGER NOT NULL, + + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_blockchain_state_login + ON blockchain_state (login); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at + ON blockchain_state (updated_at_ms); + """); + + // 6. blocks (+ line_code) + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS blocks ( + login TEXT NOT NULL, + bch_name TEXT NOT NULL, + block_number INTEGER NOT NULL CHECK(block_number >= 0), + + msg_type INTEGER NOT NULL, + msg_sub_type INTEGER NOT NULL, + + block_bytes BLOB NOT NULL, + + -- target (reply/like/edit и т.д.) + to_login TEXT, + to_bch_name TEXT, + to_block_number INTEGER CHECK(to_block_number IS NULL OR to_block_number >= 0), + to_block_hash BLOB, + + -- собственные данные + block_hash BLOB NOT NULL, + block_signature BLOB NOT NULL, + + -- если этот блок был изменён последним edit'ом + edited_by_block_number INTEGER CHECK(edited_by_block_number IS NULL OR edited_by_block_number >= 0), + + -- линейность (опционально) + line_code INTEGER CHECK(line_code IS NULL OR line_code >= 0), + prev_line_number INTEGER CHECK(prev_line_number IS NULL OR prev_line_number >= 0), + prev_line_hash BLOB, + this_line_number INTEGER CHECK(this_line_number IS NULL OR this_line_number >= 0), + + FOREIGN KEY (login) REFERENCES solana_users(login), + FOREIGN KEY (bch_name) REFERENCES blockchain_state(blockchain_name), + + UNIQUE (bch_name, block_number) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number + ON blocks (bch_name, block_number); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_blocks_to_target + ON blocks (to_login, to_bch_name, to_block_number); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_blocks_by_line + ON blocks (bch_name, line_code, this_line_number); + """); + + // 7) connections_state + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS connections_state ( + login TEXT NOT NULL, + rel_type INTEGER NOT NULL, + to_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER, + to_block_hash BLOB, + + FOREIGN KEY (login) REFERENCES solana_users(login), + + UNIQUE (login, rel_type, to_login) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_connections_state_login + ON connections_state (login); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_connections_state_to_login + ON connections_state (to_login); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_connections_state_pair + ON connections_state (login, to_login); + """); + + // 8) message_stats + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS message_stats ( + to_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER NOT NULL, + to_block_hash BLOB NOT NULL, + + likes_count INTEGER NOT NULL DEFAULT 0, + replies_count INTEGER NOT NULL DEFAULT 0, + edits_count INTEGER NOT NULL DEFAULT 0, + + UNIQUE ( + to_login, + to_bch_name, + to_block_number, + to_block_hash + ) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_message_stats_target + ON message_stats (to_bch_name, to_block_number, to_block_hash); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_message_stats_login + ON message_stats (to_login); + """); + + DatabaseTriggersInstaller.createAllTriggers(st); + } + } +} +package shine.db; + +import java.sql.SQLException; +import java.sql.Statement; + +/** + * DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД. + * + * Мы специально сделали триггеры максимально "совместимыми": + * - НЕТ динамических сообщений в RAISE(...): только фиксированные строки. + * (Некоторые SQLite-сборки / просмотрщики падают на "||" внутри RAISE.) + * - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него: + * INSERT OR IGNORE + UPDATE + * (Старые SQLite не знают UPSERT.) + * + * ============================================================================= + * ОПИСАНИЕ ТРИГГЕРОВ + * ============================================================================= + * + * [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks) + * Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number). + * + * Зачем это нужно: + * - В каналах/ветках/действиях ты хочешь иметь "линейную" последовательность, + * где каждый следующий блок явно ссылается на предыдущий блок линии + * и подтверждает, что ссылка не подменена. + * + * Когда срабатывает: + * - ТОЛЬКО если при вставке передано ХОТЯ БЫ ОДНО из line-полей. + * - Если line-поля не переданы — триггер вообще не работает (это важно). + * + * Что проверяет: + * A) line-поля допускаются только для msg_type: + * 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM) + * B) Если пришло хоть одно line-поле — обязаны прийти ВСЕ 4 (никаких "частичных") + * C) prev-блок линии существует в той же цепочке bch_name + * D) prev_hash совпадает с block_hash найденного prev-блока + * E) line_code корректный: + * - либо первый шаг после root: prev_line_number == line_code + * - либо prev уже принадлежит этой линии: p.line_code == NEW.line_code + * F) this_line_number: + * - первый шаг после root: + * TEXT: this_line_number = 0 + * TECH/CONNECTION/USER_PARAM: this_line_number = 1 + * - обычный шаг: + * TEXT: допускаем same или +1 (чтобы "edit" мог не двигать шаг) + * TECH/CONNECTION/USER_PARAM: строго prev.this + 1 + * + * Какие ошибки кидает: + * - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE + * - LINE_ERR_PARTIAL_FIELDS + * - LINE_ERR_NO_PREV + * - LINE_ERR_PREV_HASH_MISMATCH + * - LINE_ERR_LINE_CODE_MISMATCH + * - LINE_ERR_FIRST_STEP_BAD_THIS + * - LINE_ERR_THIS_LINE_BAD_STEP + * + * [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3) + * Поддерживает таблицу connections_state как "текущее состояние" отношений: + * - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние + * - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние + * + * [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE) + * Поддерживает likes_count в message_stats для цели (to_*). + * + * [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY) + * Поддерживает replies_count в message_stats. + * + * [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT) + * Логика edit: + * - помечает исходный блок edited_by_block_number = NEW.block_number + * - увеличивает edits_count в message_stats + */ +public final class DatabaseTriggersInstaller { + + private DatabaseTriggersInstaller() {} + + public static void createAllTriggers(Statement st) throws SQLException { + // На всякий случай убираем старые "криво названные" триггеры, + // если они когда-то попадали в БД. + st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;"); + st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;"); + + st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai;"); + st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai;"); + st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai;"); + st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai;"); + + createLineIntegrityTrigger(st); + createConnectionStateTrigger(st); + createMessageStatsLikeTrigger(st); + createMessageStatsReplyTrigger(st); + createEditApplyTrigger(st); + } + + private static void createLineIntegrityTrigger(Statement st) throws SQLException { + st.executeUpdate(""" + CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi + BEFORE INSERT ON blocks + WHEN + NEW.line_code IS NOT NULL + OR NEW.prev_line_number IS NOT NULL + OR NEW.prev_line_hash IS NOT NULL + OR NEW.this_line_number IS NOT NULL + BEGIN + SELECT RAISE(ABORT, 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE') + WHERE NOT (NEW.msg_type IN (0, 1, 3, 4)); + + SELECT RAISE(ABORT, 'LINE_ERR_PARTIAL_FIELDS') + WHERE NEW.line_code IS NULL + OR NEW.prev_line_number IS NULL + OR NEW.prev_line_hash IS NULL + OR NEW.this_line_number IS NULL; + + SELECT RAISE(ABORT, 'LINE_ERR_NO_PREV') + WHERE NOT EXISTS( + SELECT 1 + FROM blocks p + WHERE p.bch_name = NEW.bch_name + AND p.block_number = NEW.prev_line_number + LIMIT 1 + ); + + SELECT RAISE(ABORT, 'LINE_ERR_PREV_HASH_MISMATCH') + WHERE NOT EXISTS( + SELECT 1 + FROM blocks p + WHERE p.bch_name = NEW.bch_name + AND p.block_number = NEW.prev_line_number + AND p.block_hash = NEW.prev_line_hash + LIMIT 1 + ); + + SELECT RAISE(ABORT, 'LINE_ERR_LINE_CODE_MISMATCH') + WHERE NEW.prev_line_number <> NEW.line_code + AND NOT EXISTS( + SELECT 1 + FROM blocks p + WHERE p.bch_name = NEW.bch_name + AND p.block_number = NEW.prev_line_number + AND p.line_code = NEW.line_code + LIMIT 1 + ); + + SELECT RAISE(ABORT, 'LINE_ERR_FIRST_STEP_BAD_THIS') + WHERE NEW.prev_line_number = NEW.line_code + AND NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END); + + SELECT RAISE(ABORT, 'LINE_ERR_THIS_LINE_BAD_STEP') + WHERE NEW.prev_line_number <> NEW.line_code + AND NOT EXISTS( + SELECT 1 + FROM blocks p + WHERE p.bch_name = NEW.bch_name + AND p.block_number = NEW.prev_line_number + AND p.this_line_number IS NOT NULL + AND ( + (NEW.msg_type = 1 AND + (NEW.this_line_number = p.this_line_number OR NEW.this_line_number = p.this_line_number + 1) + ) + OR + (NEW.msg_type IN (0,3,4) AND NEW.this_line_number = p.this_line_number + 1) + ) + LIMIT 1 + ); + END; + """); + } + + private static void createConnectionStateTrigger(Statement st) throws SQLException { + int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND; + int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT; + int FOLLOW = (int) DatabaseInitializer.CONNECTION_FOLLOW; + + int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND; + int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT; + int UNFOLLOW = (int) DatabaseInitializer.CONNECTION_UNFOLLOW; + + st.executeUpdate(""" + CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai + AFTER INSERT ON blocks + WHEN NEW.msg_type = 3 + BEGIN + -- FRIEND/CONTACT/FOLLOW: + -- 1) если записи нет — создаём + INSERT OR IGNORE INTO connections_state ( + login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash + ) + SELECT + NEW.login, + NEW.msg_sub_type, + NEW.to_login, + NEW.to_bch_name, + NEW.to_block_number, + NEW.to_block_hash + WHERE NEW.msg_sub_type IN (%d, %d, %d) + AND NEW.to_login IS NOT NULL + AND NEW.to_bch_name IS NOT NULL; + + -- 2) если запись есть — обновляем актуальные to_* + UPDATE connections_state + SET + to_bch_name = NEW.to_bch_name, + to_block_number = NEW.to_block_number, + to_block_hash = NEW.to_block_hash + WHERE login = NEW.login + AND rel_type = NEW.msg_sub_type + AND to_login = NEW.to_login + AND NEW.msg_sub_type IN (%d, %d, %d) + AND NEW.to_login IS NOT NULL + AND NEW.to_bch_name IS NOT NULL; + + -- UNFRIEND/UNCONTACT/UNFOLLOW: + -- удаляем соответствующее "позитивное" состояние + DELETE FROM connections_state + WHERE login = NEW.login + AND to_login = NEW.to_login + AND rel_type = CASE NEW.msg_sub_type + WHEN %d THEN %d + WHEN %d THEN %d + WHEN %d THEN %d + ELSE rel_type + END + AND NEW.msg_sub_type IN (%d, %d, %d); + END; + """.formatted( + FRIEND, CONTACT, FOLLOW, + FRIEND, CONTACT, FOLLOW, + + UNFRIEND, FRIEND, + UNCONTACT, CONTACT, + UNFOLLOW, FOLLOW, + + UNFRIEND, UNCONTACT, UNFOLLOW + )); + } + + private static void createMessageStatsLikeTrigger(Statement st) throws SQLException { + int LIKE = (int) DatabaseInitializer.REACTION_LIKE; + + st.executeUpdate(""" + CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai + AFTER INSERT ON blocks + WHEN NEW.msg_type = 2 AND NEW.msg_sub_type = %d + BEGIN + -- создаём строку, если её не было + INSERT OR IGNORE INTO message_stats ( + to_login, to_bch_name, to_block_number, to_block_hash, + likes_count, replies_count, edits_count + ) + SELECT + NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, + 0, 0, 0 + WHERE NEW.to_login IS NOT NULL + AND NEW.to_bch_name IS NOT NULL + AND NEW.to_block_number IS NOT NULL + AND NEW.to_block_hash IS NOT NULL; + + -- +1 like + UPDATE message_stats + SET likes_count = likes_count + 1 + WHERE to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash + AND NEW.to_login IS NOT NULL + AND NEW.to_bch_name IS NOT NULL + AND NEW.to_block_number IS NOT NULL + AND NEW.to_block_hash IS NOT NULL; + END; + """.formatted(LIKE)); + } + + private static void createMessageStatsReplyTrigger(Statement st) throws SQLException { + int REPLY = (int) DatabaseInitializer.TEXT_REPLY; + + st.executeUpdate(""" + CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_reply_ai + AFTER INSERT ON blocks + WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d + BEGIN + INSERT OR IGNORE INTO message_stats ( + to_login, to_bch_name, to_block_number, to_block_hash, + likes_count, replies_count, edits_count + ) + SELECT + NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, + 0, 0, 0 + WHERE NEW.to_login IS NOT NULL + AND NEW.to_bch_name IS NOT NULL + AND NEW.to_block_number IS NOT NULL + AND NEW.to_block_hash IS NOT NULL; + + UPDATE message_stats + SET replies_count = replies_count + 1 + WHERE to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash + AND NEW.to_login IS NOT NULL + AND NEW.to_bch_name IS NOT NULL + AND NEW.to_block_number IS NOT NULL + AND NEW.to_block_hash IS NOT NULL; + END; + """.formatted(REPLY)); + } + + private static void createEditApplyTrigger(Statement st) throws SQLException { + int EDIT = (int) DatabaseInitializer.TEXT_EDIT; + + st.executeUpdate(""" + CREATE TRIGGER IF NOT EXISTS trg_blocks_edit_apply_ai + AFTER INSERT ON blocks + WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d + BEGIN + -- 1) помечаем исходный блок, что его "перекрыл" этот edit + UPDATE blocks + SET edited_by_block_number = NEW.block_number + WHERE login = NEW.login + AND bch_name = NEW.bch_name + AND block_number = NEW.to_block_number + AND NEW.to_block_number IS NOT NULL; + + -- 2) создаём stats-строку если её не было + INSERT OR IGNORE INTO message_stats ( + to_login, to_bch_name, to_block_number, to_block_hash, + likes_count, replies_count, edits_count + ) + SELECT + NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, + 0, 0, 0 + WHERE NEW.to_login IS NOT NULL + AND NEW.to_bch_name IS NOT NULL + AND NEW.to_block_number IS NOT NULL + AND NEW.to_block_hash IS NOT NULL; + + -- 3) +1 edit + UPDATE message_stats + SET edits_count = edits_count + 1 + WHERE to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash + AND NEW.to_login IS NOT NULL + AND NEW.to_bch_name IS NOT NULL + AND NEW.to_block_number IS NOT NULL + AND NEW.to_block_hash IS NOT NULL; + END; + """.formatted(EDIT)); + } +} +package shine.db.entities; + +/** + * Модель активной сессии (таблица active_sessions). + */ +public class ActiveSessionEntry { + + private String sessionId; + private String login; + + /** session_key: публичный ключ сессии (base64 от 32 байт). */ + private String sessionKey; + + private String storagePwd; + private long sessionCreatedAtMs; + private long lastAuthirificatedAtMs; + + private String pushEndpoint; + private String pushP256dhKey; + private String pushAuthKey; + + private String clientIp; + private String clientInfoFromClient; + private String clientInfoFromRequest; + private String userLanguage; + + public ActiveSessionEntry() { } + + public ActiveSessionEntry(String sessionId, + String login, + String sessionKey, + String storagePwd, + long sessionCreatedAtMs, + long lastAuthirificatedAtMs, + String pushEndpoint, + String pushP256dhKey, + String pushAuthKey, + String clientIp, + String clientInfoFromClient, + String clientInfoFromRequest, + String userLanguage) { + this.sessionId = sessionId; + this.login = login; + this.sessionKey = sessionKey; + this.storagePwd = storagePwd; + this.sessionCreatedAtMs = sessionCreatedAtMs; + this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; + this.pushEndpoint = pushEndpoint; + this.pushP256dhKey = pushP256dhKey; + this.pushAuthKey = pushAuthKey; + this.clientIp = clientIp; + this.clientInfoFromClient = clientInfoFromClient; + this.clientInfoFromRequest = clientInfoFromRequest; + this.userLanguage = userLanguage; + } + + public String getSessionId() { return sessionId; } + public void setSessionId(String sessionId) { this.sessionId = sessionId; } + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public String getSessionKey() { return sessionKey; } + public void setSessionKey(String sessionKey) { this.sessionKey = sessionKey; } + + public String getStoragePwd() { return storagePwd; } + public void setStoragePwd(String storagePwd) { this.storagePwd = storagePwd; } + + public long getSessionCreatedAtMs() { return sessionCreatedAtMs; } + public void setSessionCreatedAtMs(long sessionCreatedAtMs) { this.sessionCreatedAtMs = sessionCreatedAtMs; } + + public long getLastAuthirificatedAtMs() { return lastAuthirificatedAtMs; } + public void setLastAuthirificatedAtMs(long lastAuthirificatedAtMs) { this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; } + + public String getPushEndpoint() { return pushEndpoint; } + public void setPushEndpoint(String pushEndpoint) { this.pushEndpoint = pushEndpoint; } + + public String getPushP256dhKey() { return pushP256dhKey; } + public void setPushP256dhKey(String pushP256dhKey) { this.pushP256dhKey = pushP256dhKey; } + + public String getPushAuthKey() { return pushAuthKey; } + public void setPushAuthKey(String pushAuthKey) { this.pushAuthKey = pushAuthKey; } + + public String getClientIp() { return clientIp; } + public void setClientIp(String clientIp) { this.clientIp = clientIp; } + + 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 getUserLanguage() { return userLanguage; } + public void setUserLanguage(String userLanguage) { this.userLanguage = userLanguage; } +} +package shine.db.entities; + +import java.util.Base64; + +/** + * Агрегатная сущность текущего состояния блокчейна. + * + * ВАЖНО: + * - Убраны все поля линий line0..7 (они больше не нужны). + * - Оставляем: + * last_block_number + * last_block_hash + * + * Остальные поля (login, blockchain_key, лимиты) оставлены как в проекте, + * потому что серверу они реально нужны (ключ подписи/лимит файла). + */ +public final class BlockchainStateEntry { + + private String blockchainName; + private String login; + + private String blockchainKey; // Base64(32) + + private long sizeLimit; + private long fileSizeBytes; + + private int lastBlockNumber; // было last_global_number + private byte[] lastBlockHash; // было last_global_hash (nullable) + + private long updatedAtMs; + + public BlockchainStateEntry() {} + + public String getBlockchainName() { return blockchainName; } + public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public String getBlockchainKey() { return blockchainKey; } + public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } + + public byte[] getBlockchainKeyBytes() { + if (blockchainKey == null) return null; + String s = blockchainKey.trim(); + if (s.isEmpty()) return null; + try { + byte[] b = Base64.getDecoder().decode(s); + return (b != null && b.length == 32) ? b : null; + } catch (IllegalArgumentException e) { + return null; + } + } + + public long getSizeLimit() { return sizeLimit; } + public void setSizeLimit(long sizeLimit) { this.sizeLimit = sizeLimit; } + + public long getFileSizeBytes() { return fileSizeBytes; } + public void setFileSizeBytes(long fileSizeBytes) { this.fileSizeBytes = fileSizeBytes; } + + public int getLastBlockNumber() { return lastBlockNumber; } + public void setLastBlockNumber(int lastBlockNumber) { this.lastBlockNumber = lastBlockNumber; } + + public byte[] getLastBlockHash() { return lastBlockHash; } + public void setLastBlockHash(byte[] lastBlockHash) { this.lastBlockHash = lastBlockHash; } + + public long getUpdatedAtMs() { return updatedAtMs; } + public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; } +} +package shine.db.entities; + +/** + * Запись блока (таблица blocks) — обновлённая модель под новый формат. + * + * Храним: + * - login, bch_name (как было в проекте, чтобы не ломать общую БД) + * - block_number (глобальный номер в этой цепочке) + * - block_bytes (полный блок: preimage + signature) + * - block_hash (32 байта вычисленный SHA-256(preimage)) + * - block_signature (64 байта) + * + * Опционально: + * - line_code / prev_line_number / prev_line_hash / this_line_number + * + * Плюс поля индексации: + * - msg_type / msg_sub_type + * - to_* (если есть target) + * - edited_by_block_number (для TEXT_EDIT) + */ +public class BlockEntry { + + private String login; + private String bchName; + + private int blockNumber; + + private int msgType; + private int msgSubType; + + private byte[] blockBytes; + + private String toLogin; + private String toBchName; + private Integer toBlockNumber; + private byte[] toBlockHash; + + private byte[] blockHash; + private byte[] blockSignature; + + private Integer editedByBlockNumber; + + // NEW: + private Integer lineCode; + + private Integer prevLineNumber; + private byte[] prevLineHash; + private Integer thisLineNumber; + + public BlockEntry() {} + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public String getBchName() { return bchName; } + public void setBchName(String bchName) { this.bchName = bchName; } + + public int getBlockNumber() { return blockNumber; } + public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; } + + public int getMsgType() { return msgType; } + public void setMsgType(int msgType) { this.msgType = msgType; } + + public int getMsgSubType() { return msgSubType; } + public void setMsgSubType(int msgSubType) { this.msgSubType = msgSubType; } + + public byte[] getBlockBytes() { return blockBytes; } + public void setBlockBytes(byte[] blockBytes) { this.blockBytes = blockBytes; } + + public String getToLogin() { return toLogin; } + public void setToLogin(String toLogin) { this.toLogin = toLogin; } + + public String getToBchName() { return toBchName; } + public void setToBchName(String toBchName) { this.toBchName = toBchName; } + + public Integer getToBlockNumber() { return toBlockNumber; } + public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; } + + public byte[] getToBlockHash() { return toBlockHash; } + public void setToBlockHash(byte[] toBlockHash) { this.toBlockHash = toBlockHash; } + + public byte[] getBlockHash() { return blockHash; } + public void setBlockHash(byte[] blockHash) { this.blockHash = blockHash; } + + public byte[] getBlockSignature() { return blockSignature; } + public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; } + + public Integer getEditedByBlockNumber() { return editedByBlockNumber; } + public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; } + + // NEW: + public Integer getLineCode() { return lineCode; } + public void setLineCode(Integer lineCode) { this.lineCode = lineCode; } + + public Integer getPrevLineNumber() { return prevLineNumber; } + public void setPrevLineNumber(Integer prevLineNumber) { this.prevLineNumber = prevLineNumber; } + + public byte[] getPrevLineHash() { return prevLineHash; } + public void setPrevLineHash(byte[] prevLineHash) { this.prevLineHash = prevLineHash; } + + public Integer getThisLineNumber() { return thisLineNumber; } + public void setThisLineNumber(Integer thisLineNumber) { this.thisLineNumber = thisLineNumber; } +} +package shine.db.entities; + +/** + * Запись в таблице ip_geo_cache. + */ +public class IpGeoCacheEntry { + + private String ip; + private String geo; + private long updatedAtMs; + + public IpGeoCacheEntry() { + } + + public IpGeoCacheEntry(String ip, String geo, long updatedAtMs) { + this.ip = ip; + this.geo = geo; + this.updatedAtMs = updatedAtMs; + } + + public String getIp() { + return ip; + } + + public void setIp(String ip) { + this.ip = ip; + } + + public String getGeo() { + return geo; + } + + public void setGeo(String geo) { + this.geo = geo; + } + + public long getUpdatedAtMs() { + return updatedAtMs; + } + + public void setUpdatedAtMs(long updatedAtMs) { + this.updatedAtMs = updatedAtMs; + } +} +package shine.db.entities; + +import java.util.Base64; + +/** + * SolanaUserEntry — локальная запись пользователя из Solana. + * + * Таблица: solana_users + * + * Поля: + * - login — PRIMARY KEY (TEXT) (case-insensitive на уровне COLLATE NOCASE) + * - blockchain_name — TEXT NOT NULL + * - solana_key — TEXT NOT NULL + * - blockchain_key — TEXT NOT NULL + * - device_key — TEXT NOT NULL + */ +public class SolanaUserEntry { + + private String login; + + private String blockchainName; + + /** Ключ пользователя Solana (публичный ключ логина) */ + private String solanaKey; + + /** Ключ блокчейна (публичный ключ блокчейна) */ + private String blockchainKey; + + /** Ключ устройства (публичный ключ устройства) */ + private String deviceKey; + + public SolanaUserEntry() {} + + public SolanaUserEntry(String login, + String blockchainName, + String solanaKey, + String blockchainKey, + String deviceKey) { + this.login = login; + this.blockchainName = blockchainName; + this.solanaKey = solanaKey; + this.blockchainKey = blockchainKey; + this.deviceKey = deviceKey; + } + + 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; } + + // оставляю этот метод как утилиту (иногда удобно), но он работает только для deviceKey: + public byte[] getDeviceKeyByte() { + if (deviceKey == null) return null; + String s = deviceKey.trim(); + if (s.isEmpty()) return null; + + try { + byte[] b = Base64.getDecoder().decode(s); + if (b != null && b.length == 32) return b; + } catch (IllegalArgumentException ignore) {} + + if (s.length() == 64 && s.matches("^[0-9a-fA-F]+$")) { + byte[] out = new byte[32]; + for (int i = 0; i < 32; i++) { + int hi = Character.digit(s.charAt(i * 2), 16); + int lo = Character.digit(s.charAt(i * 2 + 1), 16); + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + + return null; + } +} +package shine.db.entities; + +/** + * UserParamEntry — сохранённый параметр пользователя. + * + * Таблица: users_params + * - login TEXT NOT NULL + * - param TEXT NOT NULL + * - time_ms INTEGER NOT NULL + * - value TEXT NOT NULL + * - device_key TEXT NULL + * - signature TEXT NULL + */ +public class UserParamEntry { + + private String login; + private String param; + private long timeMs; + private String value; + + private String deviceKey; + private String signature; + + public UserParamEntry() {} + + public UserParamEntry(String login, String param, long timeMs, String value, String deviceKey, String signature) { + this.login = login; + this.param = param; + this.timeMs = timeMs; + this.value = value; + this.deviceKey = deviceKey; + this.signature = 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 getTimeMs() { return timeMs; } + public void setTimeMs(long timeMs) { this.timeMs = timeMs; } + + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + + public String getDeviceKey() { return deviceKey; } + public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } + + public String getSignature() { return signature; } + public void setSignature(String signature) { this.signature = signature; } +} +package shine.db; + +/** + * MsgSubType — единое место для ВСЕХ subType сообщений (msg_sub_type). + * + * ВАЖНО: + * - Значения должны совпадать с body-классами (TextBody/ReactionBody/ConnectionBody/UserParamBody/HeaderBody). + * - После релиза менять числа нельзя (иначе ломается совместимость данных). + */ +public final class MsgSubType { + + private MsgSubType() {} + + /* ===================== HEADER (msg_type=0) ===================== */ + + /** HeaderBody: subType всегда 0 (compat). */ + public static final short HEADER_COMPAT = 0; + + /* ===================== TEXT (msg_type=1) ===================== */ + + /** Новая публикация. */ + public static final short TEXT_NEW = 1; + + /** Ответ (reply). */ + public static final short TEXT_REPLY = 2; + + /** Репост (repost). */ + public static final short TEXT_REPOST = 3; + + /** Редактирование (edit). */ + public static final short TEXT_EDIT = 10; + + /* ===================== REACTION (msg_type=2) ===================== */ + + /** Лайк (LIKE). */ + public static final short REACTION_LIKE = 1; + + /* ===================== CONNECTION (msg_type=3) ===================== */ + /** + * Совпадает с ConnectionBody: + * SET: FRIEND=10, CONTACT=20, FOLLOW=30 + * UNSET: UNFRIEND=11, UNCONTACT=21, UNFOLLOW=31 + */ + + /** Добавить в друзья. */ + public static final short CONNECTION_FRIEND = 10; + + /** Удалить из друзей. */ + public static final short CONNECTION_UNFRIEND = 11; + + /** Добавить в контакты. */ + public static final short CONNECTION_CONTACT = 20; + + /** Удалить из контактов. */ + public static final short CONNECTION_UNCONTACT = 21; + + /** Подписаться (follow). */ + public static final short CONNECTION_FOLLOW = 30; + + /** Отписаться (unfollow). */ + public static final short CONNECTION_UNFOLLOW = 31; + + /* ===================== USER_PARAM (msg_type=4) ===================== */ + + /** Параметр профиля key/value (обе строки). */ + public static final short USER_PARAM_TEXT_TEXT = 1; + + /* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */ + // Если позже захочешь BLOCK/UNBLOCK — лучше добавить НОВЫЕ значения, + // не трогая 10/20/30 и 11/21/31 (например, 40/41). + // public static final short CONNECTION_BLOCK = 40; + // public static final short CONNECTION_UNBLOCK = 41; +} +package shine.db; + +import utils.config.AppConfig; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; + +public final class SqliteDbController { + + private static volatile SqliteDbController instance; + + private final String jdbcUrl; + + private SqliteDbController() { + try { + Class.forName("org.sqlite.JDBC"); + } catch (ClassNotFoundException e) { + throw new RuntimeException("SQLite JDBC driver not found", e); + } + + String dbPath = AppConfig.getInstance().getParam("db.path"); + if (dbPath == null || dbPath.isBlank()) { + throw new RuntimeException("Config param 'db.path' is not set in application.properties"); + } + + Path dbFile = Paths.get(dbPath); + + if (!Files.exists(dbFile)) { + System.out.println("[DB] Файл БД не найден: " + dbFile.toAbsolutePath()); + System.out.println("[DB] Создаём новую БД с помощью DatabaseInitializer..."); + DatabaseInitializer.createNewDB(new String[0]); + } + + this.jdbcUrl = "jdbc:sqlite:" + dbPath; + } + + public static SqliteDbController getInstance() { + if (instance == null) { + synchronized (SqliteDbController.class) { + if (instance == null) { + instance = new SqliteDbController(); + } + } + } + return instance; + } + + public Connection getConnection() throws SQLException { + Connection conn = DriverManager.getConnection(jdbcUrl); + conn.setAutoCommit(true); + + try (Statement st = conn.createStatement()) { + st.execute("PRAGMA foreign_keys = ON"); + st.execute("PRAGMA journal_mode = WAL"); + st.execute("PRAGMA synchronous = NORMAL"); + st.execute("PRAGMA busy_timeout = 5000"); + } + + return conn; + } + + public void close() { + // no-op + } +} diff --git a/shine-server-net-protocol/all_files.txt b/shine-server-net-protocol/all_files.txt new file mode 100644 index 0000000..8d18326 --- /dev/null +++ b/shine-server-net-protocol/all_files.txt @@ -0,0 +1,4739 @@ +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 bySessionId = new ConcurrentHashMap<>(); + + // login (String) -> множество ConnectionContext для этого пользователя + private final ConcurrentHashMap> 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 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 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 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 getByLogin(String login) { + if (login == null || login.isBlank()) return Set.of(); + Set 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 sessions; + + public List getSessions() { + return sessions; + } + + public void setSessions(List 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 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 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 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 channels; + + public List getChannels() { return channels; } + public void setChannels(List 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 rows = dao.getSubscribedChannels(c, req.getLogin()); +// List 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 logins = new ArrayList<>(); + + public List getLogins() { return logins; } + public void setLogins(List 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 должен быть вида: -NNN + if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_BLOCKCHAIN_NAME", + "blockchainName должен быть вида -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 users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5 + + List 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 params = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public List getParams() { return params; } + public void setParams(List 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 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 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 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> 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 getHandlers() { + return HANDLERS; + } + + public static Map> 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 JSON_HANDLERS = + JsonHandlerRegistry.getHandlers(); + + private static final Map> 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={}): ", 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 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; + } +} diff --git a/shine-server-net-protocol/src/main/java/server/all_files.txt b/shine-server-net-protocol/src/main/java/server/all_files.txt new file mode 100644 index 0000000..cce7330 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/all_files.txt @@ -0,0 +1,4742 @@ +// file: server/logic/ws_protocol/B64.java +package server.logic.ws_protocol; + +import java.util.Base64; + +/** + * Единая утилита Base64 для всего WS-протокола. + * + * Правило: используем ТОЛЬКО стандартный Base64 (RFC 4648): + * - алфавит: A-Z a-z 0-9 + / + * - padding: "=" (Java encoder добавляет по умолчанию) + * + * Никаких Base64url ("-" "_") и никаких "без padding" в протоколе. + */ +public final class B64 { + + private B64() {} + + /** Кодирует байты в стандартный Base64 (с padding). */ + public static String enc(byte[] bytes) { + if (bytes == null) throw new IllegalArgumentException("bytes == null"); + return Base64.getEncoder().encodeToString(bytes); + } + + /** Декодирует стандартный Base64 в байты. */ + public static byte[] dec(String b64) { + if (b64 == null) throw new IllegalArgumentException("base64 == null"); + String s = b64.trim(); + if (s.isEmpty()) throw new IllegalArgumentException("base64 == empty"); + // Строго стандартный декодер (не url-safe) + return Base64.getDecoder().decode(s); + } + + /** Декодирует и проверяет, что длина результата ровно expectedLen. */ + public static byte[] decLen(String b64, int expectedLen, String fieldName) { + byte[] out = dec(b64); + if (out.length != expectedLen) { + throw new IllegalArgumentException(fieldName + " must decode to " + expectedLen + " bytes, got " + out.length); + } + return out; + } + + public static byte[] dec32(String b64, String fieldName) { + return decLen(b64, 32, fieldName); + } + + public static byte[] dec64(String b64, String fieldName) { + return decLen(b64, 64, fieldName); + } +} +package server.logic.ws_protocol; + +import java.util.Base64; + +/** + * Единая утилита Base64 для всего WS-протокола. + * + * ВАЖНО: + * - Используем ТОЛЬКО стандартный Base64 (RFC 4648) алфавит: '+' и '/'. + * - Без padding '=' (чтобы строки были короче и стабильнее для JSON). + * - Декодер при этом спокойно принимает и с '=' и без '='. + */ +public final class Base64Ws { + + private static final Base64.Encoder ENC = Base64.getEncoder().withoutPadding(); + private static final Base64.Decoder DEC = Base64.getDecoder(); + + private Base64Ws() {} + + public static String encode(byte[] bytes) { + if (bytes == null) throw new IllegalArgumentException("bytes == null"); + return ENC.encodeToString(bytes); + } + + public static byte[] decode(String b64) throws IllegalArgumentException { + if (b64 == null) throw new IllegalArgumentException("base64 is null"); + String s = b64.trim(); + if (s.isEmpty()) throw new IllegalArgumentException("base64 is empty"); + return DEC.decode(s); + } + + public static byte[] decodeLen(String b64, int expectedLen, String fieldName) throws IllegalArgumentException { + byte[] v = decode(b64); + if (v.length != expectedLen) { + String f = (fieldName == null || fieldName.isBlank()) ? "value" : fieldName; + throw new IllegalArgumentException(f + " must be " + expectedLen + " bytes, got " + v.length); + } + return v; + } +} +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 bySessionId = new ConcurrentHashMap<>(); + + // login (String) -> множество ConnectionContext для этого пользователя + private final ConcurrentHashMap> 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 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 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 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 getByLogin(String login) { + if (login == null || login.isBlank()) return Set.of(); + Set 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 { + + /** Клиентский пароль для хранения данных (base64 от 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": "base64(32)" + * } + * } + */ +public class Net_CreateAuthSession_Response extends Net_Response { + + /** Идентификатор сессии, base64 от 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 sessions; + + public List getSessions() { + return sessions; + } + + public void setSessions(List 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": "base64(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": "base64(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.Base64Ws; +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; + +/** + * 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 = Base64Ws.encode(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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.Base64Ws; +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 org.eclipse.jetty.websocket.api.Session; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.sql.SQLException; + +/** + * 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 = Base64Ws.decode(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 = Base64Ws.decode(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 Base64Ws.encode(buf); + } +} +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 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 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.Base64Ws; +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; + +/** + * 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 = Base64Ws.encode(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.Base64Ws; +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; + +/** + * 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 (Base64(32)) + 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 { + + // pubKey: Base64(32). (Ed25519Util.keyFromBase64 должен использовать стандартный Base64) + byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64); + + // signature: Base64(64) через единую утилиту WS-протокола + byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); + + String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; + byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); + + return Ed25519Util.verify(preimage, signature64, publicKey32); + } +} +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.Base64Ws; +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.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 Base64Ws.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 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.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Запрос GetFriendsLists — получить два списка "друзей" по connections_state. + * + * { + * "op": "GetFriendsLists", + * "requestId": "req-100", + * "payload": { + * "login": "anya" + * } + * } + * + * Возвращает: + * - out_friends: кому login поставил FRIEND + * - in_friends: кто поставил FRIEND этому login + * + * ПРО ДОСТУП (на будущее): + * Сейчас (MVP) без ограничений. Позже можно ограничить видимость связей. + */ +public class Net_GetFriendsLists_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.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +/** + * Ответ GetFriendsLists. + * + * { + * "op": "GetFriendsLists", + * "requestId": "req-100", + * "status": 200, + * "payload": { + * "login": "Anya", // канонический регистр из БД + * "out_friends": ["Bob", "Kate"], // кому login поставил FRIEND + * "in_friends": ["Alex", "Kate"] // кто поставил FRIEND login + * } + * } + */ +public class Net_GetFriendsLists_Response extends Net_Response { + + private String login; + + private List out_friends = new ArrayList<>(); + private List in_friends = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public List getOut_friends() { return out_friends; } + public void setOut_friends(List out_friends) { this.out_friends = out_friends; } + + public List getIn_friends() { return in_friends; } + public void setIn_friends(List in_friends) { this.in_friends = in_friends; } +} +package server.logic.ws_protocol.JSON.handlers.connections; + +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.connections.entyties.Net_GetFriendsLists_Request; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.MsgSubType; +import shine.db.SqliteDbController; +import shine.db.dao.ConnectionsStateDAO; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.List; + +/** + * GetFriendsLists — получить 2 списка: + * - out_friends: кому login поставил FRIEND + * - in_friends: кто поставил FRIEND этому login + * + * ВАЖНО: + * - login в запросе может быть любым регистром + * - в ответе возвращаем канонический регистр (как в solana_users.login) + * + * ПРИМЕЧАНИЕ: + * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL. + */ +public class Net_GetFriendsLists_Handler implements JsonMessageHandler { + + private static final Logger log = LoggerFactory.getLogger(Net_GetFriendsLists_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetFriendsLists_Request req = (Net_GetFriendsLists_Request) baseRequest; + + if (req.getLogin() == null || req.getLogin().isBlank()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_FIELDS", + "Некорректные поля: login" + ); + } + + final String loginAnyCase = req.getLogin().trim(); + + try { + SqliteDbController db = SqliteDbController.getInstance(); + ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance(); + + try (Connection c = db.getConnection()) { + + // 1) Канонизируем login через solana_users (NOCASE) + String canonicalLogin = findCanonicalLogin(c, loginAnyCase); + if (canonicalLogin == null) { + return NetExceptionResponseFactory.error( + req, + 404, + "USER_NOT_FOUND", + "Пользователь не найден" + ); + } + + int relType = (int) MsgSubType.CONNECTION_FRIEND; + + // 2) Два списка (логины канонические) + List outFriends = dao.listOutgoingByRelTypeCanonical(c, canonicalLogin, relType); + List inFriends = dao.listIncomingByRelTypeCanonical(c, canonicalLogin, relType); + + Net_GetFriendsLists_Response resp = new Net_GetFriendsLists_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + resp.setLogin(canonicalLogin); + resp.setOut_friends(outFriends); + resp.setIn_friends(inFriends); + + return resp; + } + + } catch (Exception e) { + log.error("❌ Internal error GetFriendsLists", e); + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.INTERNAL_ERROR, + "INTERNAL_ERROR", + "Внутренняя ошибка сервера" + ); + } + } + + private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { + String sql = """ + SELECT login + FROM solana_users + WHERE login = ? COLLATE NOCASE + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, loginAnyCase); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return rs.getString("login"); + } + } + } +} +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.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 logins = new ArrayList<>(); + + public List getLogins() { return logins; } + public void setLogins(List 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.Base64Ws; +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; + +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 должен быть вида: -NNN + if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_BLOCKCHAIN_NAME", + "blockchainName должен быть вида -NNN (пример: anya-001)" + ); + } + + int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0) + ? TEST_BCH_LIMIT + : req.getBchLimit(); + + try { + // базовая валидация форматов ключей: Base64(32 bytes) + byte[] solanaKey32; + byte[] blockchainKey32; + byte[] deviceKey32; + + try { + solanaKey32 = Base64Ws.decodeLen(req.getSolanaKey(), 32, "solanaKey"); + blockchainKey32 = Base64Ws.decodeLen(req.getBlockchainKey(), 32, "blockchainKey"); + deviceKey32 = Base64Ws.decodeLen(req.getDeviceKey(), 32, "deviceKey"); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_KEY_FORMAT", + e.getMessage() + ); + } + + // (переменные не используются дальше, но оставляем для ясности проверки длины) + if (solanaKey32.length != 32 || blockchainKey32.length != 32 || deviceKey32.length != 32) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_KEY_FORMAT", + "solanaKey/blockchainKey/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 (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 users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5 + + List 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 params = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public List getParams() { return params; } + public void setParams(List 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 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 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.Base64Ws; +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; + +/** + * 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 = Base64Ws.decodeLen(deviceKeyB64, 32, "device_key"); + sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_BASE64", + "device_key/signature должны быть Base64" + ); + } + + // ---------------- 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; + +// --- subscriptions --- + +// --- NEW: connections friends lists --- +import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request; + +import java.util.Map; + +/** + * JsonHandlerRegistry — единое место, где руками регистрируются + * JSON-операции: op → handler и op → requestClass. + */ +public final class JsonHandlerRegistry { + + private static final Map 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()), + + // --- connections --- + Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()) + + // --- subscriptions --- +// Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler()) + ); + + private static final Map> 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), + + + // --- connections --- + Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class) + ); + + private JsonHandlerRegistry() { } + + public static Map getHandlers() { + return HANDLERS; + } + + public static Map> 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 JSON_HANDLERS = + JsonHandlerRegistry.getHandlers(); + + private static final Map> 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={}): ", 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 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; + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/all_files.txt b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/all_files.txt new file mode 100644 index 0000000..59f8d1b --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/all_files.txt @@ -0,0 +1,4548 @@ +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 bySessionId = new ConcurrentHashMap<>(); + + // login (String) -> множество ConnectionContext для этого пользователя + private final ConcurrentHashMap> 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 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 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 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 getByLogin(String login) { + if (login == null || login.isBlank()) return Set.of(); + Set 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 { + + /** Клиентский пароль для хранения данных (base64 от 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": "base64(32)" + * } + * } + */ +public class Net_CreateAuthSession_Response extends Net_Response { + + /** Идентификатор сессии, base64 от 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 sessions; + + public List getSessions() { + return sessions; + } + + public void setSessions(List 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": "base64(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": "base64(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.Base64Ws; +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; + +/** + * 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 = Base64Ws.encode(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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.Base64Ws; +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 org.eclipse.jetty.websocket.api.Session; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.sql.SQLException; + +/** + * 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 = Base64Ws.decode(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 = Base64Ws.decode(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 Base64Ws.encode(buf); + } +} +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 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 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.Base64Ws; +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; + +/** + * 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 = Base64Ws.encode(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.Base64Ws; +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; + +/** + * 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 (Base64(32)) + 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 { + + // pubKey: Base64(32). (Ed25519Util.keyFromBase64 должен использовать стандартный Base64) + byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64); + + // signature: Base64(64) через единую утилиту WS-протокола + byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); + + String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; + byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); + + return Ed25519Util.verify(preimage, signature64, publicKey32); + } +} +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.Base64Ws; +import server.logic.ws_protocol.JSON.ConnectionContext; +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.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.concurrent.locks.ReentrantLock; + +/** + * Net_AddBlock_Handler — единый хэндлер добавления блока (JSON). + * + * Изменение (v3): + * - ВСЕ ошибки теперь возвращаются в стандартном формате Net_Exception_Response: + * status != 200, payload: { code, message, serverLastGlobalNumber, serverLastGlobalHash } + * - Успех — как и раньше Net_AddBlock_Response (status=200). + */ +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() + ); + + // ✅ УСПЕХ: как раньше + if (r.isOk()) { + Net_AddBlock_Response resp = new Net_AddBlock_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + resp.setReasonCode(null); + resp.setServerLastGlobalNumber(r.serverLastBlockNumber); + resp.setServerLastGlobalHash(r.serverLastBlockHashHex); + + return resp; + } + + // ✅ ОШИБКА: стандартный формат (code + message) + доп.поля для ресинка + return error(req, r.httpStatus, r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex); + + } finally { + lock.unlock(); + } + } + + private Net_Response error(Net_AddBlock_Request req, + int status, + String reasonCode, + int serverLastNum, + String serverLastHashHex) { + + AddBlockExceptionResponse resp = new AddBlockExceptionResponse(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(status); + + // code — машинный + resp.setCode(reasonCode != null ? reasonCode : "add_block_error"); + // message — человеческий (можешь улучшать тексты как угодно) + resp.setMessage(humanMessage(reasonCode)); + + // полезно клиенту для ресинка + resp.setServerLastGlobalNumber(serverLastNum); + resp.setServerLastGlobalHash(serverLastHashHex); + + return resp; + } + + private static String humanMessage(String code) { + if (code == null) return "Ошибка добавления блока"; + + return switch (code) { + case "empty_blockchain_name" -> "Пустое имя блокчейна"; + case "bad_blockchain_name" -> "Некорректное имя блокчейна"; + case "db_error" -> "Ошибка базы данных"; + case "blockchain_state_not_found" -> "Состояние блокчейна не найдено"; + case "state_last_hash_invalid" -> "Повреждено состояние блокчейна: неверный last_block_hash"; + case "bad_block_base64" -> "Некорректный base64 блока"; + case "limit_exceeded" -> "Превышен лимит размера блокчейна"; + case "limit_check_failed" -> "Ошибка проверки лимита размера"; + case "bad_block_format" -> "Некорректный формат блока"; + case "bad_block_body" -> "Некорректное тело блока"; + case "bad_block_number" -> "Некорректный номер блока"; + case "req_global_mismatch" -> "Номер блока в запросе не совпадает с номером в блоке"; + case "bad_prev_hash" -> "Некорректный prevHash (цепочка не совпадает)"; + case "bad_blockchain_key_len" -> "Некорректный ключ блокчейна в состоянии (ожидалось 32 байта)"; + case "signature_verify_failed" -> "Ошибка проверки подписи блока"; + case "bad_signature" -> "Некорректная подпись блока"; + case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии"; + case "bad_prev_line_hash" -> "Некорректный prevLineHash"; + case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; + case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; + default -> "Ошибка: " + code; + }; + } + + 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; + try { + serverLastHash32 = (serverLastNum < 0) + ? new byte[32] + : require32OrThrow(st.getLastBlockHash(), "state.last_block_hash is null/invalid"); + } catch (Exception e) { + // ✅ Раньше тут мог вылететь неожиданный 500 через внешний try/catch. + log.error("AddBlock: state_last_hash_invalid (login={}, blockchainName={}, serverLastNum={})", + login, blockchainName, serverLastNum, e); + return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "state_last_hash_invalid", serverLastNum, ""); + } + + 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, "signature_verify_failed", 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 Base64Ws.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); + } + + /** + * Спец-ответ ошибки AddBlock: стандартный code/message + поля для ресинка. + * В wire-формате это окажется внутри payload. + */ + public static final class AddBlockExceptionResponse extends Net_Exception_Response { + private Integer serverLastGlobalNumber; + private String serverLastGlobalHash; + + public Integer getServerLastGlobalNumber() { + return serverLastGlobalNumber; + } + + public void setServerLastGlobalNumber(Integer serverLastGlobalNumber) { + this.serverLastGlobalNumber = serverLastGlobalNumber; + } + + public String getServerLastGlobalHash() { + return serverLastGlobalHash; + } + + public void setServerLastGlobalHash(String serverLastGlobalHash) { + this.serverLastGlobalHash = serverLastGlobalHash; + } + } + + 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 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.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Запрос GetFriendsLists — получить два списка "друзей" по connections_state. + * + * { + * "op": "GetFriendsLists", + * "requestId": "req-100", + * "payload": { + * "login": "anya" + * } + * } + * + * Возвращает: + * - out_friends: кому login поставил FRIEND + * - in_friends: кто поставил FRIEND этому login + * + * ПРО ДОСТУП (на будущее): + * Сейчас (MVP) без ограничений. Позже можно ограничить видимость связей. + */ +public class Net_GetFriendsLists_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.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +/** + * Ответ GetFriendsLists. + * + * { + * "op": "GetFriendsLists", + * "requestId": "req-100", + * "status": 200, + * "payload": { + * "login": "Anya", // канонический регистр из БД + * "out_friends": ["Bob", "Kate"], // кому login поставил FRIEND + * "in_friends": ["Alex", "Kate"] // кто поставил FRIEND login + * } + * } + */ +public class Net_GetFriendsLists_Response extends Net_Response { + + private String login; + + private List out_friends = new ArrayList<>(); + private List in_friends = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public List getOut_friends() { return out_friends; } + public void setOut_friends(List out_friends) { this.out_friends = out_friends; } + + public List getIn_friends() { return in_friends; } + public void setIn_friends(List in_friends) { this.in_friends = in_friends; } +} +package server.logic.ws_protocol.JSON.handlers.connections; + +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.connections.entyties.Net_GetFriendsLists_Request; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.MsgSubType; +import shine.db.SqliteDbController; +import shine.db.dao.ConnectionsStateDAO; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.List; + +/** + * GetFriendsLists — получить 2 списка: + * - out_friends: кому login поставил FRIEND + * - in_friends: кто поставил FRIEND этому login + * + * ВАЖНО: + * - login в запросе может быть любым регистром + * - в ответе возвращаем канонический регистр (как в solana_users.login) + * + * ПРИМЕЧАНИЕ: + * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL. + */ +public class Net_GetFriendsLists_Handler implements JsonMessageHandler { + + private static final Logger log = LoggerFactory.getLogger(Net_GetFriendsLists_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetFriendsLists_Request req = (Net_GetFriendsLists_Request) baseRequest; + + if (req.getLogin() == null || req.getLogin().isBlank()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_FIELDS", + "Некорректные поля: login" + ); + } + + final String loginAnyCase = req.getLogin().trim(); + + try { + SqliteDbController db = SqliteDbController.getInstance(); + ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance(); + + try (Connection c = db.getConnection()) { + + // 1) Канонизируем login через solana_users (NOCASE) + String canonicalLogin = findCanonicalLogin(c, loginAnyCase); + if (canonicalLogin == null) { + return NetExceptionResponseFactory.error( + req, + 404, + "USER_NOT_FOUND", + "Пользователь не найден" + ); + } + + int relType = (int) MsgSubType.CONNECTION_FRIEND; + + // 2) Два списка (логины канонические) + List outFriends = dao.listOutgoingByRelTypeCanonical(c, canonicalLogin, relType); + List inFriends = dao.listIncomingByRelTypeCanonical(c, canonicalLogin, relType); + + Net_GetFriendsLists_Response resp = new Net_GetFriendsLists_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + resp.setLogin(canonicalLogin); + resp.setOut_friends(outFriends); + resp.setIn_friends(inFriends); + + return resp; + } + + } catch (Exception e) { + log.error("❌ Internal error GetFriendsLists", e); + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.INTERNAL_ERROR, + "INTERNAL_ERROR", + "Внутренняя ошибка сервера" + ); + } + } + + private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { + String sql = """ + SELECT login + FROM solana_users + WHERE login = ? COLLATE NOCASE + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, loginAnyCase); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return rs.getString("login"); + } + } + } +} +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.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Ping: + * { + * "op": "Ping", + * "requestId": "req-1", + * "payload": { "ts": 1700000000000 } + * } + * + * Сервер ничего не проверяет, поле ts можно слать любое. + */ +public class Net_Ping_Request extends Net_Request { + + private long ts; + + public long getTs() { return ts; } + public void setTs(long ts) { this.ts = ts; } +} +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +/** + * Pong-ответ: + * { + * "op": "Ping", + * "requestId": "req-1", + * "status": 200, + * "payload": { "ts": 1700000000123 } + * } + */ +public class Net_Ping_Response extends Net_Response { + + private long ts; + + public long getTs() { return ts; } + public void setTs(long ts) { this.ts = ts; } +} +package server.logic.ws_protocol.JSON.handlers.system; + +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.system.entyties.Net_Ping_Request; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Response; +import server.logic.ws_protocol.WireCodes; + +/** + * Ping — keep-alive. + * В ответ кладём только ts (текущее время сервера в мс). + */ +public class Net_Ping_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_Ping_Request req = (Net_Ping_Request) baseRequest; + + Net_Ping_Response resp = new Net_Ping_Response(); + resp.setOp(req.getOp()); // "Ping" + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + // ничего не проверяем, просто отдаём серверное время + resp.setTs(System.currentTimeMillis()); + + return resp; + } +} +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 logins = new ArrayList<>(); + + public List getLogins() { return logins; } + public void setLogins(List 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.Base64Ws; +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; + +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 должен быть вида: -NNN + if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_BLOCKCHAIN_NAME", + "blockchainName должен быть вида -NNN (пример: anya-001)" + ); + } + + int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0) + ? TEST_BCH_LIMIT + : req.getBchLimit(); + + try { + // базовая валидация форматов ключей: Base64(32 bytes) + byte[] solanaKey32; + byte[] blockchainKey32; + byte[] deviceKey32; + + try { + solanaKey32 = Base64Ws.decodeLen(req.getSolanaKey(), 32, "solanaKey"); + blockchainKey32 = Base64Ws.decodeLen(req.getBlockchainKey(), 32, "blockchainKey"); + deviceKey32 = Base64Ws.decodeLen(req.getDeviceKey(), 32, "deviceKey"); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_KEY_FORMAT", + e.getMessage() + ); + } + + // (переменные не используются дальше, но оставляем для ясности проверки длины) + if (solanaKey32.length != 32 || blockchainKey32.length != 32 || deviceKey32.length != 32) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_KEY_FORMAT", + "solanaKey/blockchainKey/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 (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 users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5 + + List 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 params = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public List getParams() { return params; } + public void setParams(List 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 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 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.Base64Ws; +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; + +/** + * 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 = Base64Ws.decodeLen(deviceKeyB64, 32, "device_key"); + sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_BASE64", + "device_key/signature должны быть Base64" + ); + } + + // ---------------- 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; + +// --- NEW: connections friends lists --- +import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request; + +// --- NEW: Ping --- +import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request; + +import java.util.Map; + +/** + * JsonHandlerRegistry — единое место, где руками регистрируются + * JSON-операции: op → handler и op → requestClass. + */ +public final class JsonHandlerRegistry { + + private static final Map 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()), + + // --- connections --- + Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()), + + // --- system --- + Map.entry("Ping", new Net_Ping_Handler()) + + // --- subscriptions --- +// Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler()) + ); + + private static final Map> 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), + + // --- connections --- + Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class), + + // --- system --- + Map.entry("Ping", Net_Ping_Request.class) + ); + + private JsonHandlerRegistry() { } + + public static Map getHandlers() { + return HANDLERS; + } + + public static Map> 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 JSON_HANDLERS = + JsonHandlerRegistry.getHandlers(); + + private static final Map> 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={}): ", 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 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; + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/concat_to_file.sh b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/concat_to_file.sh new file mode 100755 index 0000000..f6db1f1 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/concat_to_file.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUTFILE="all_files.txt" + +# очищаем или создаём файл +: > "$OUTFILE" + +# собрать только *.java файлы и вывести их содержимое в файл +find . -type f -name "*.java" | sort | while read -r f; do + cat "$f" >> "$OUTFILE" + echo >> "$OUTFILE" # пустая строка-разделитель +done + +# скопировать весь файл в буфер обмена (Wayland) +wl-copy < "$OUTFILE" + +echo "Готово!" +echo "Все .java файлы собраны в $OUTFILE" +echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/all_files.txt b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/all_files.txt new file mode 100644 index 0000000..25f556f --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/all_files.txt @@ -0,0 +1,140 @@ +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; + } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/concat_to_file.sh b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/concat_to_file.sh new file mode 100755 index 0000000..f6db1f1 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/concat_to_file.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUTFILE="all_files.txt" + +# очищаем или создаём файл +: > "$OUTFILE" + +# собрать только *.java файлы и вывести их содержимое в файл +find . -type f -name "*.java" | sort | while read -r f; do + cat "$f" >> "$OUTFILE" + echo >> "$OUTFILE" # пустая строка-разделитель +done + +# скопировать весь файл в буфер обмена (Wayland) +wl-copy < "$OUTFILE" + +echo "Готово!" +echo "Все .java файлы собраны в $OUTFILE" +echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/all_files.txt b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/all_files.txt new file mode 100644 index 0000000..397359c --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/all_files.txt @@ -0,0 +1,3475 @@ +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 { + + /** Клиентский пароль для хранения данных (base64 от 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": "base64(32)" + * } + * } + */ +public class Net_CreateAuthSession_Response extends Net_Response { + + /** Идентификатор сессии, base64 от 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 sessions; + + public List getSessions() { + return sessions; + } + + public void setSessions(List 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": "base64(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": "base64(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.Base64Ws; +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; + +/** + * 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 = Base64Ws.encode(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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.Base64Ws; +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 org.eclipse.jetty.websocket.api.Session; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.sql.SQLException; + +/** + * 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 = Base64Ws.decode(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 = Base64Ws.decode(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 Base64Ws.encode(buf); + } +} +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 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 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.Base64Ws; +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; + +/** + * 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 = Base64Ws.encode(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.Base64Ws; +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; + +/** + * 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 (Base64(32)) + 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 { + + // pubKey: Base64(32). (Ed25519Util.keyFromBase64 должен использовать стандартный Base64) + byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64); + + // signature: Base64(64) через единую утилиту WS-протокола + byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); + + String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; + byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); + + return Ed25519Util.verify(preimage, signature64, publicKey32); + } +} +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.Base64Ws; +import server.logic.ws_protocol.JSON.ConnectionContext; +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.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.concurrent.locks.ReentrantLock; + +/** + * Net_AddBlock_Handler — единый хэндлер добавления блока (JSON). + * + * Изменение (v3): + * - ВСЕ ошибки теперь возвращаются в стандартном формате Net_Exception_Response: + * status != 200, payload: { code, message, serverLastGlobalNumber, serverLastGlobalHash } + * - Успех — как и раньше Net_AddBlock_Response (status=200). + */ +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() + ); + + // ✅ УСПЕХ: как раньше + if (r.isOk()) { + Net_AddBlock_Response resp = new Net_AddBlock_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + resp.setReasonCode(null); + resp.setServerLastGlobalNumber(r.serverLastBlockNumber); + resp.setServerLastGlobalHash(r.serverLastBlockHashHex); + + return resp; + } + + // ✅ ОШИБКА: стандартный формат (code + message) + доп.поля для ресинка + return error(req, r.httpStatus, r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex); + + } finally { + lock.unlock(); + } + } + + private Net_Response error(Net_AddBlock_Request req, + int status, + String reasonCode, + int serverLastNum, + String serverLastHashHex) { + + AddBlockExceptionResponse resp = new AddBlockExceptionResponse(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(status); + + // code — машинный + resp.setCode(reasonCode != null ? reasonCode : "add_block_error"); + // message — человеческий (можешь улучшать тексты как угодно) + resp.setMessage(humanMessage(reasonCode)); + + // полезно клиенту для ресинка + resp.setServerLastGlobalNumber(serverLastNum); + resp.setServerLastGlobalHash(serverLastHashHex); + + return resp; + } + + private static String humanMessage(String code) { + if (code == null) return "Ошибка добавления блока"; + + return switch (code) { + case "empty_blockchain_name" -> "Пустое имя блокчейна"; + case "bad_blockchain_name" -> "Некорректное имя блокчейна"; + case "db_error" -> "Ошибка базы данных"; + case "blockchain_state_not_found" -> "Состояние блокчейна не найдено"; + case "state_last_hash_invalid" -> "Повреждено состояние блокчейна: неверный last_block_hash"; + case "bad_block_base64" -> "Некорректный base64 блока"; + case "limit_exceeded" -> "Превышен лимит размера блокчейна"; + case "limit_check_failed" -> "Ошибка проверки лимита размера"; + case "bad_block_format" -> "Некорректный формат блока"; + case "bad_block_body" -> "Некорректное тело блока"; + case "bad_block_number" -> "Некорректный номер блока"; + case "req_global_mismatch" -> "Номер блока в запросе не совпадает с номером в блоке"; + case "bad_prev_hash" -> "Некорректный prevHash (цепочка не совпадает)"; + case "bad_blockchain_key_len" -> "Некорректный ключ блокчейна в состоянии (ожидалось 32 байта)"; + case "signature_verify_failed" -> "Ошибка проверки подписи блока"; + case "bad_signature" -> "Некорректная подпись блока"; + case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии"; + case "bad_prev_line_hash" -> "Некорректный prevLineHash"; + case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; + case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; + default -> "Ошибка: " + code; + }; + } + + 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; + try { + serverLastHash32 = (serverLastNum < 0) + ? new byte[32] + : require32OrThrow(st.getLastBlockHash(), "state.last_block_hash is null/invalid"); + } catch (Exception e) { + // ✅ Раньше тут мог вылететь неожиданный 500 через внешний try/catch. + log.error("AddBlock: state_last_hash_invalid (login={}, blockchainName={}, serverLastNum={})", + login, blockchainName, serverLastNum, e); + return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "state_last_hash_invalid", serverLastNum, ""); + } + + 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, "signature_verify_failed", 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 Base64Ws.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); + } + + /** + * Спец-ответ ошибки AddBlock: стандартный code/message + поля для ресинка. + * В wire-формате это окажется внутри payload. + */ + public static final class AddBlockExceptionResponse extends Net_Exception_Response { + private Integer serverLastGlobalNumber; + private String serverLastGlobalHash; + + public Integer getServerLastGlobalNumber() { + return serverLastGlobalNumber; + } + + public void setServerLastGlobalNumber(Integer serverLastGlobalNumber) { + this.serverLastGlobalNumber = serverLastGlobalNumber; + } + + public String getServerLastGlobalHash() { + return serverLastGlobalHash; + } + + public void setServerLastGlobalHash(String serverLastGlobalHash) { + this.serverLastGlobalHash = serverLastGlobalHash; + } + } + + 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 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.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Запрос GetFriendsLists — получить два списка "друзей" по connections_state. + * + * { + * "op": "GetFriendsLists", + * "requestId": "req-100", + * "payload": { + * "login": "anya" + * } + * } + * + * Возвращает: + * - out_friends: кому login поставил FRIEND + * - in_friends: кто поставил FRIEND этому login + * + * ПРО ДОСТУП (на будущее): + * Сейчас (MVP) без ограничений. Позже можно ограничить видимость связей. + */ +public class Net_GetFriendsLists_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.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +/** + * Ответ GetFriendsLists. + * + * { + * "op": "GetFriendsLists", + * "requestId": "req-100", + * "status": 200, + * "payload": { + * "login": "Anya", // канонический регистр из БД + * "out_friends": ["Bob", "Kate"], // кому login поставил FRIEND + * "in_friends": ["Alex", "Kate"] // кто поставил FRIEND login + * } + * } + */ +public class Net_GetFriendsLists_Response extends Net_Response { + + private String login; + + private List out_friends = new ArrayList<>(); + private List in_friends = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public List getOut_friends() { return out_friends; } + public void setOut_friends(List out_friends) { this.out_friends = out_friends; } + + public List getIn_friends() { return in_friends; } + public void setIn_friends(List in_friends) { this.in_friends = in_friends; } +} +package server.logic.ws_protocol.JSON.handlers.connections; + +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.connections.entyties.Net_GetFriendsLists_Request; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.MsgSubType; +import shine.db.SqliteDbController; +import shine.db.dao.ConnectionsStateDAO; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.List; + +/** + * GetFriendsLists — получить 2 списка: + * - out_friends: кому login поставил FRIEND + * - in_friends: кто поставил FRIEND этому login + * + * ВАЖНО: + * - login в запросе может быть любым регистром + * - в ответе возвращаем канонический регистр (как в solana_users.login) + * + * ПРИМЕЧАНИЕ: + * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL. + */ +public class Net_GetFriendsLists_Handler implements JsonMessageHandler { + + private static final Logger log = LoggerFactory.getLogger(Net_GetFriendsLists_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetFriendsLists_Request req = (Net_GetFriendsLists_Request) baseRequest; + + if (req.getLogin() == null || req.getLogin().isBlank()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_FIELDS", + "Некорректные поля: login" + ); + } + + final String loginAnyCase = req.getLogin().trim(); + + try { + SqliteDbController db = SqliteDbController.getInstance(); + ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance(); + + try (Connection c = db.getConnection()) { + + // 1) Канонизируем login через solana_users (NOCASE) + String canonicalLogin = findCanonicalLogin(c, loginAnyCase); + if (canonicalLogin == null) { + return NetExceptionResponseFactory.error( + req, + 404, + "USER_NOT_FOUND", + "Пользователь не найден" + ); + } + + int relType = (int) MsgSubType.CONNECTION_FRIEND; + + // 2) Два списка (логины канонические) + List outFriends = dao.listOutgoingByRelTypeCanonical(c, canonicalLogin, relType); + List inFriends = dao.listIncomingByRelTypeCanonical(c, canonicalLogin, relType); + + Net_GetFriendsLists_Response resp = new Net_GetFriendsLists_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + resp.setLogin(canonicalLogin); + resp.setOut_friends(outFriends); + resp.setIn_friends(inFriends); + + return resp; + } + + } catch (Exception e) { + log.error("❌ Internal error GetFriendsLists", e); + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.INTERNAL_ERROR, + "INTERNAL_ERROR", + "Внутренняя ошибка сервера" + ); + } + } + + private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { + String sql = """ + SELECT login + FROM solana_users + WHERE login = ? COLLATE NOCASE + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, loginAnyCase); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return rs.getString("login"); + } + } + } +} +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.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Ping: + * { + * "op": "Ping", + * "requestId": "req-1", + * "payload": { "ts": 1700000000000 } + * } + * + * Сервер ничего не проверяет, поле ts можно слать любое. + */ +public class Net_Ping_Request extends Net_Request { + + private long ts; + + public long getTs() { return ts; } + public void setTs(long ts) { this.ts = ts; } +} +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +/** + * Pong-ответ: + * { + * "op": "Ping", + * "requestId": "req-1", + * "status": 200, + * "payload": { "ts": 1700000000123 } + * } + */ +public class Net_Ping_Response extends Net_Response { + + private long ts; + + public long getTs() { return ts; } + public void setTs(long ts) { this.ts = ts; } +} +package server.logic.ws_protocol.JSON.handlers.system; + +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.system.entyties.Net_Ping_Request; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Response; +import server.logic.ws_protocol.WireCodes; + +/** + * Ping — keep-alive. + * В ответ кладём только ts (текущее время сервера в мс). + */ +public class Net_Ping_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_Ping_Request req = (Net_Ping_Request) baseRequest; + + Net_Ping_Response resp = new Net_Ping_Response(); + resp.setOp(req.getOp()); // "Ping" + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + // ничего не проверяем, просто отдаём серверное время + resp.setTs(System.currentTimeMillis()); + + return resp; + } +} +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 logins = new ArrayList<>(); + + public List getLogins() { return logins; } + public void setLogins(List 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.Base64Ws; +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; + +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 должен быть вида: -NNN + if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_BLOCKCHAIN_NAME", + "blockchainName должен быть вида -NNN (пример: anya-001)" + ); + } + + int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0) + ? TEST_BCH_LIMIT + : req.getBchLimit(); + + try { + // базовая валидация форматов ключей: Base64(32 bytes) + byte[] solanaKey32; + byte[] blockchainKey32; + byte[] deviceKey32; + + try { + solanaKey32 = Base64Ws.decodeLen(req.getSolanaKey(), 32, "solanaKey"); + blockchainKey32 = Base64Ws.decodeLen(req.getBlockchainKey(), 32, "blockchainKey"); + deviceKey32 = Base64Ws.decodeLen(req.getDeviceKey(), 32, "deviceKey"); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_KEY_FORMAT", + e.getMessage() + ); + } + + // (переменные не используются дальше, но оставляем для ясности проверки длины) + if (solanaKey32.length != 32 || blockchainKey32.length != 32 || deviceKey32.length != 32) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_KEY_FORMAT", + "solanaKey/blockchainKey/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 (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 users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5 + + List 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 params = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public List getParams() { return params; } + public void setParams(List 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 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 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.Base64Ws; +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; + +/** + * 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 = Base64Ws.decodeLen(deviceKeyB64, 32, "device_key"); + sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_BASE64", + "device_key/signature должны быть Base64" + ); + } + + // ---------------- 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", + "Внутренняя ошибка сервера" + ); + } + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/all_files.txt b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/all_files.txt new file mode 100644 index 0000000..d89a693 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/all_files.txt @@ -0,0 +1,1439 @@ +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 { + + /** Клиентский пароль для хранения данных (base64 от 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": "base64(32)" + * } + * } + */ +public class Net_CreateAuthSession_Response extends Net_Response { + + /** Идентификатор сессии, base64 от 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 sessions; + + public List getSessions() { + return sessions; + } + + public void setSessions(List 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": "base64(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": "base64(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.Base64Ws; +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; + +/** + * 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 = Base64Ws.encode(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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.Base64Ws; +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 org.eclipse.jetty.websocket.api.Session; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.sql.SQLException; + +/** + * 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 = Base64Ws.decode(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 = Base64Ws.decode(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 Base64Ws.encode(buf); + } +} +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 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 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.Base64Ws; +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; + +/** + * 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 = Base64Ws.encode(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.Base64Ws; +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; + +/** + * 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 (Base64(32)) + 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 { + + // pubKey: Base64(32). (Ed25519Util.keyFromBase64 должен использовать стандартный Base64) + byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64); + + // signature: Base64(64) через единую утилиту WS-протокола + byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); + + String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; + byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); + + return Ed25519Util.verify(preimage, signature64, publicKey32); + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/concat_to_file.sh b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/concat_to_file.sh new file mode 100755 index 0000000..f6db1f1 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/concat_to_file.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUTFILE="all_files.txt" + +# очищаем или создаём файл +: > "$OUTFILE" + +# собрать только *.java файлы и вывести их содержимое в файл +find . -type f -name "*.java" | sort | while read -r f; do + cat "$f" >> "$OUTFILE" + echo >> "$OUTFILE" # пустая строка-разделитель +done + +# скопировать весь файл в буфер обмена (Wayland) +wl-copy < "$OUTFILE" + +echo "Готово!" +echo "Все .java файлы собраны в $OUTFILE" +echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/concat_to_file.sh b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/concat_to_file.sh new file mode 100755 index 0000000..f6db1f1 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/concat_to_file.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUTFILE="all_files.txt" + +# очищаем или создаём файл +: > "$OUTFILE" + +# собрать только *.java файлы и вывести их содержимое в файл +find . -type f -name "*.java" | sort | while read -r f; do + cat "$f" >> "$OUTFILE" + echo >> "$OUTFILE" # пустая строка-разделитель +done + +# скопировать весь файл в буфер обмена (Wayland) +wl-copy < "$OUTFILE" + +echo "Готово!" +echo "Все .java файлы собраны в $OUTFILE" +echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/all_files.txt b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/all_files.txt new file mode 100644 index 0000000..430f54f --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/all_files.txt @@ -0,0 +1,180 @@ +package server.logic.ws_protocol.JSON.handlers.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Запрос GetFriendsLists — получить два списка "друзей" по connections_state. + * + * { + * "op": "GetFriendsLists", + * "requestId": "req-100", + * "payload": { + * "login": "anya" + * } + * } + * + * Возвращает: + * - out_friends: кому login поставил FRIEND + * - in_friends: кто поставил FRIEND этому login + * + * ПРО ДОСТУП (на будущее): + * Сейчас (MVP) без ограничений. Позже можно ограничить видимость связей. + */ +public class Net_GetFriendsLists_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.connections.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +/** + * Ответ GetFriendsLists. + * + * { + * "op": "GetFriendsLists", + * "requestId": "req-100", + * "status": 200, + * "payload": { + * "login": "Anya", // канонический регистр из БД + * "out_friends": ["Bob", "Kate"], // кому login поставил FRIEND + * "in_friends": ["Alex", "Kate"] // кто поставил FRIEND login + * } + * } + */ +public class Net_GetFriendsLists_Response extends Net_Response { + + private String login; + + private List out_friends = new ArrayList<>(); + private List in_friends = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public List getOut_friends() { return out_friends; } + public void setOut_friends(List out_friends) { this.out_friends = out_friends; } + + public List getIn_friends() { return in_friends; } + public void setIn_friends(List in_friends) { this.in_friends = in_friends; } +} +package server.logic.ws_protocol.JSON.handlers.connections; + +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.connections.entyties.Net_GetFriendsLists_Request; +import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.MsgSubType; +import shine.db.SqliteDbController; +import shine.db.dao.ConnectionsStateDAO; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.List; + +/** + * GetFriendsLists — получить 2 списка: + * - out_friends: кому login поставил FRIEND + * - in_friends: кто поставил FRIEND этому login + * + * ВАЖНО: + * - login в запросе может быть любым регистром + * - в ответе возвращаем канонический регистр (как в solana_users.login) + * + * ПРИМЕЧАНИЕ: + * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL. + */ +public class Net_GetFriendsLists_Handler implements JsonMessageHandler { + + private static final Logger log = LoggerFactory.getLogger(Net_GetFriendsLists_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetFriendsLists_Request req = (Net_GetFriendsLists_Request) baseRequest; + + if (req.getLogin() == null || req.getLogin().isBlank()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_FIELDS", + "Некорректные поля: login" + ); + } + + final String loginAnyCase = req.getLogin().trim(); + + try { + SqliteDbController db = SqliteDbController.getInstance(); + ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance(); + + try (Connection c = db.getConnection()) { + + // 1) Канонизируем login через solana_users (NOCASE) + String canonicalLogin = findCanonicalLogin(c, loginAnyCase); + if (canonicalLogin == null) { + return NetExceptionResponseFactory.error( + req, + 404, + "USER_NOT_FOUND", + "Пользователь не найден" + ); + } + + int relType = (int) MsgSubType.CONNECTION_FRIEND; + + // 2) Два списка (логины канонические) + List outFriends = dao.listOutgoingByRelTypeCanonical(c, canonicalLogin, relType); + List inFriends = dao.listIncomingByRelTypeCanonical(c, canonicalLogin, relType); + + Net_GetFriendsLists_Response resp = new Net_GetFriendsLists_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + resp.setLogin(canonicalLogin); + resp.setOut_friends(outFriends); + resp.setIn_friends(inFriends); + + return resp; + } + + } catch (Exception e) { + log.error("❌ Internal error GetFriendsLists", e); + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.INTERNAL_ERROR, + "INTERNAL_ERROR", + "Внутренняя ошибка сервера" + ); + } + } + + private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { + String sql = """ + SELECT login + FROM solana_users + WHERE login = ? COLLATE NOCASE + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, loginAnyCase); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return rs.getString("login"); + } + } + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/concat_to_file.sh b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/concat_to_file.sh new file mode 100755 index 0000000..f6db1f1 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/concat_to_file.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUTFILE="all_files.txt" + +# очищаем или создаём файл +: > "$OUTFILE" + +# собрать только *.java файлы и вывести их содержимое в файл +find . -type f -name "*.java" | sort | while read -r f; do + cat "$f" >> "$OUTFILE" + echo >> "$OUTFILE" # пустая строка-разделитель +done + +# скопировать весь файл в буфер обмена (Wayland) +wl-copy < "$OUTFILE" + +echo "Готово!" +echo "Все .java файлы собраны в $OUTFILE" +echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/all_files.txt b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/all_files.txt new file mode 100644 index 0000000..f226a58 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/all_files.txt @@ -0,0 +1,240 @@ +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; } +} +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; + +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 должен быть вида: -NNN + if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_BLOCKCHAIN_NAME", + "blockchainName должен быть вида -NNN (пример: anya-001)" + ); + } + + int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0) + ? TEST_BCH_LIMIT + : req.getBchLimit(); + + try { + 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)" + ); + } + + SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); + BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); + + SqliteDbController db = SqliteDbController.getInstance(); + + try (Connection c = db.getConnection()) { + c.setAutoCommit(false); + + // 1. Проверяем, что пользователя нет + if (usersDAO.getByLogin(req.getLogin()) != null) { + return NetExceptionResponseFactory.error( + req, + 409, + "USER_ALREADY_EXISTS", + "Пользователь с таким login уже существует" + ); + } + + // 2. Проверяем, что blockchain_state ещё нет + if (stateDAO.getByBlockchainName(req.getBlockchainName()) != null) { + return NetExceptionResponseFactory.error( + req, + 409, + "BLOCKCHAIN_ALREADY_EXISTS", + "blockchain_state уже существует" + ); + } + + // 3. Создаём пользователя (solanaKey + deviceKey) + SolanaUserEntry user = new SolanaUserEntry( + req.getLogin(), + req.getSolanaKey(), + req.getDeviceKey() + ); + + usersDAO.insert(c, user); + + // 4. Создаём INITIAL blockchain_state (blockchainKey) + 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", + "Внутренняя ошибка сервера" + ); + } + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/all_files.txt b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/all_files.txt new file mode 100644 index 0000000..4db8a35 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/all_files.txt @@ -0,0 +1,640 @@ +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 params = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public List getParams() { return params; } + public void setParams(List 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 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 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", + "Внутренняя ошибка сервера" + ); + } + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/concat_to_file.sh b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/concat_to_file.sh new file mode 100755 index 0000000..f6db1f1 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/concat_to_file.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUTFILE="all_files.txt" + +# очищаем или создаём файл +: > "$OUTFILE" + +# собрать только *.java файлы и вывести их содержимое в файл +find . -type f -name "*.java" | sort | while read -r f; do + cat "$f" >> "$OUTFILE" + echo >> "$OUTFILE" # пустая строка-разделитель +done + +# скопировать весь файл в буфер обмена (Wayland) +wl-copy < "$OUTFILE" + +echo "Готово!" +echo "Все .java файлы собраны в $OUTFILE" +echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/src/main/all_files.txt b/src/main/all_files.txt new file mode 100644 index 0000000..83d0c14 --- /dev/null +++ b/src/main/all_files.txt @@ -0,0 +1,552 @@ +package server.logic; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.binary.handlers.*; +import server.logic.ws_protocol.WireCodes; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Map; + +/** + * Обработчик входящих сообщение на сервер. + * По коду сообщения (первые 4 байта сообщения) находи нужный хэндлер и передаёт в него сообщение + * Получает и возвращает ответ от хэндлера + */ +public final class InboundMessageProcessor { + private static final Logger log = LoggerFactory.getLogger(InboundMessageProcessor.class); + + private static final Map HANDLERS = Map.of( +// WireCodes.Op.PING, new PingHandler() +// WireCodes.Op.ADD_BLOCK, new AddBlockHandler(), +// WireCodes.Op.GET_BLOCKCHAIN,new GetBlockchainHandler() +// WireCodes.Op.SEARCH_USERS, new SearchUsersHandler(), +// WireCodes.Op.GET_LAST_BLOCK_INFO,new GetLastBlockInfoHandler() + + ); + + private InboundMessageProcessor() {} + + public static byte[] process(byte[] msg) { + if (msg == null || msg.length < 4) + return intTo4Bytes(WireCodes.Status.BAD_REQUEST); + + int op = first4ToInt(msg); + MessageHandler h = HANDLERS.get(op); + if (h == null) { + log.warn("Неизвестная операция: {}", op); + return intTo4Bytes(WireCodes.Status.BAD_REQUEST); + } + + try { + return h.handle(msg); + } catch (Exception e) { + log.error("Ошибка при обработке операции {}", op, e); + return intTo4Bytes(WireCodes.Status.INTERNAL_ERROR); + } + } + + private static int first4ToInt(byte[] msg) { + return ByteBuffer.wrap(msg, 0, 4) + .order(ByteOrder.BIG_ENDIAN) + .getInt(); + } + + public static byte[] intTo4Bytes(int code) { + return ByteBuffer.allocate(4) + .order(ByteOrder.BIG_ENDIAN) + .putInt(code) + .array(); + } + + + +} + + +package server.logic.ws_protocol.binary.handlers; + +/** + * Общий интерфейс для всех обработчиков входящих сообщений. + */ +public interface MessageHandler { + /** + * Обработать входящее сообщение и вернуть бинарный ответ. + */ + byte[] handle(byte[] msg); +} + +package server.ws; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import shine.db.dao.BlockchainStateDAO; +import shine.db.entities.BlockchainStateEntry; +import utils.files.FileStoreUtil; +import shine.log.BlockchainAdminNotifier; + +import java.io.IOException; +import java.nio.file.*; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * =============================================================== + * BlockchainTmpRecoveryOnStartup — восстановление консистентности + * blockchain файлов при старте сервера. + * + * Сценарий проблемы: + * - при добавлении блока сначала пишется .tmp_bch + * - потом коммитится БД (state.fileSizeBytes) + * - потом tmp переименовывается поверх .bch (атомарно, если возможно) + * + * Если сервер упал в середине, может остаться tmp: + * - tmp есть, а основной .bch остался старым + * - tmp есть, а основной .bch уже удалили/заменить не успели + * - tmp есть, а БД успела/не успела обновиться + * + * Этот класс при старте: + * - ищет все *.tmp_bch в data/ + * - сравнивает размеры: + * - tmp + * - main (если есть) + * - state.fileSizeBytes (если есть) + * + * Правила: + * + * A) state есть: + * - если stateSize == mainSize => tmp удаляем + * - если stateSize == tmpSize => tmp ставим на место main (atomicReplaceBlockchainFile) + * - иначе => КРИТИЧЕСКАЯ ОШИБКА: сервер останавливаем + уведомление администратору + * + * B) state НЕТ: + * - если main НЕТ и tmp ЕСТЬ => tmp удаляем (мусор после падения/неуспешной транзакции) + * - если main ЕСТЬ и tmp ЕСТЬ => КРИТИЧЕСКАЯ ОШИБКА: уведомление администратору + стоп сервера + * + * Логирование: + * - обо всех восстановленных/удалённых tmp пишем в лог + * - если tmp-файлов нет — тоже пишем в лог + * =============================================================== + */ +public final class BlockchainTmpRecoveryOnStartup { + + private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class); + + private BlockchainTmpRecoveryOnStartup() {} + + /** + * Запуск восстановления. + * Если обнаружена ситуация, когда размеры не совпали и сервер сам не может чинить — бросаем исключение. + */ + public static void runRecoveryOrThrow() { + FileStoreUtil fs = FileStoreUtil.getInstance(); + BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); + + Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME); + ensureDirExists(dataDir); + + List tmpFiles = listTmpFiles(dataDir); + + if (tmpFiles.isEmpty()) { + log.info("🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется."); + return; + } + + log.warn("🟡 BlockchainTmpRecovery: найдено временных файлов: {}", tmpFiles.size()); + + for (Path tmpPath : tmpFiles) { + String fileName = tmpPath.getFileName().toString(); + String blockchainName = extractBlockchainNameFromTmp(fileName); + + if (blockchainName == null || blockchainName.isBlank()) { + // странное имя — не трогаем автоматически, но это уже повод дернуть админа + BlockchainAdminNotifier.critical( + "НАЙДЕН TMP-ФАЙЛ С НЕОЖИДАННЫМ ИМЕНЕМ: " + fileName + " (не могу определить blockchainName).", + null + ); + throw new IllegalStateException("Bad tmp file name: " + fileName); + } + + Path mainPath = dataDir.resolve(fs.buildBlockchainFileName(blockchainName)); + + long tmpSize = safeSize(tmpPath); + boolean mainExists = Files.exists(mainPath); + long mainSize = mainExists ? safeSize(mainPath) : -1L; + + BlockchainStateEntry st = null; + try { + st = stateDAO.getByBlockchainName(blockchainName); + } catch (SQLException e) { + BlockchainAdminNotifier.critical( + "ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ TMP: blockchainName=" + blockchainName + " (сервер остановлен).", + e + ); + throw new IllegalStateException("DB error during tmp recovery for " + blockchainName, e); + } + + // ============================================================ + // CASE B) state НЕТ + // ============================================================ + if (st == null) { + + if (!mainExists) { + // НЕТ state, НЕТ main, есть tmp => удаляем tmp + log.warn("🟠 BlockchainTmpRecovery: state отсутствует и main отсутствует, но tmp найден => удаляем tmp. blockchainName={}, tmpSize={}", + blockchainName, tmpSize); + safeDelete(tmpPath); + continue; + } + + // НЕТ state, но main есть и tmp есть => это уже подозрительно + BlockchainAdminNotifier.critical( + "НЕСОГЛАСОВАННОСТЬ: ЕСТЬ main И tmp, НО НЕТ state В БД. " + + "blockchainName=" + blockchainName + + ", mainSize=" + mainSize + + ", tmpSize=" + tmpSize + + ". СЕРВЕР ОСТАНОВЛЕН. " + + "ПОДОЗРЕНИЕ: файлы могли быть изменены вне сервера.", + null + ); + throw new IllegalStateException("State missing but both main and tmp exist for " + blockchainName); + } + + // ============================================================ + // CASE A) state ЕСТЬ + // ============================================================ + long stateSize = st.getFileSizeBytes(); + + // 1) stateSize == mainSize => tmp мусор + if (mainExists && mainSize == stateSize) { + log.info("🟢 BlockchainTmpRecovery: stateSize совпадает с main => tmp удаляем. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}", + blockchainName, stateSize, mainSize, tmpSize); + safeDelete(tmpPath); + continue; + } + + // 2) stateSize == tmpSize => tmp это актуальная версия, ставим на место main + if (tmpSize == stateSize) { + log.warn("🟡 BlockchainTmpRecovery: stateSize совпадает с tmp => восстанавливаем main из tmp. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}", + blockchainName, stateSize, mainSize, tmpSize); + + try { + // метод уже есть и делает move tmp->main с попыткой ATOMIC_MOVE + fs.atomicReplaceBlockchainFile(blockchainName); + + // после move tmp должен исчезнуть сам (перемещён) + log.info("✅ BlockchainTmpRecovery: восстановление выполнено. blockchainName={}, newMainSize={}", + blockchainName, safeSize(mainPath)); + + } catch (Exception e) { + BlockchainAdminNotifier.critical( + "НЕ УДАЛОСЬ ВОССТАНОВИТЬ main ИЗ tmp (move failed). " + + "blockchainName=" + blockchainName + + ", stateSize=" + stateSize + + ", mainSize=" + mainSize + + ", tmpSize=" + tmpSize + + ". СЕРВЕР ОСТАНОВЛЕН.", + e + ); + throw new IllegalStateException("Cannot replace main from tmp for " + blockchainName, e); + } + continue; + } + + // 3) НИЧЕГО НЕ СОВПАЛО => критическая ситуация + BlockchainAdminNotifier.critical( + "ФАТАЛЬНАЯ НЕСОГЛАСОВАННОСТЬ BLOCKCHAIN ФАЙЛОВ. " + + "blockchainName=" + blockchainName + + ", stateSize=" + stateSize + + ", mainExists=" + mainExists + + ", mainSize=" + mainSize + + ", tmpSize=" + tmpSize + + ". СЕРВЕР ОСТАНОВЛЕН. " + + "ТУТ НУЖНО УВЕДОМЛЕНИЕ АДМИНИСТРАТОРУ: возможно файлы изменены вручную/другой программой.", + null + ); + throw new IllegalStateException("Blockchain files mismatch for " + blockchainName); + } + + log.info("✅ BlockchainTmpRecovery: обработка tmp-файлов завершена."); + } + + /* ===================================================================== */ + /* =============================== Helpers ============================== */ + /* ===================================================================== */ + + private static void ensureDirExists(Path dir) { + try { + if (!Files.exists(dir)) { + Files.createDirectories(dir); + } + } catch (IOException e) { + throw new IllegalStateException("Cannot create data dir: " + dir, e); + } + } + + private static List listTmpFiles(Path dataDir) { + List out = new ArrayList<>(); + try (DirectoryStream ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) { + for (Path p : ds) { + if (Files.isRegularFile(p)) out.add(p); + } + } catch (IOException e) { + throw new IllegalStateException("Cannot list tmp files in: " + dataDir, e); + } + return out; + } + + /** + * Из "anya0001.tmp_bch" -> "anya0001" + */ + private static String extractBlockchainNameFromTmp(String tmpFileName) { + if (tmpFileName == null) return null; + if (!tmpFileName.endsWith(FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) return null; + + String base = tmpFileName.substring(0, tmpFileName.length() - FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION.length()); + + // базовая защита: не допускаем слэши/.. даже если кто-то подложил файл + if (base.isBlank()) return null; + if (base.contains("/") || base.contains("\\") || base.contains("..")) return null; + + return base; + } + + private static long safeSize(Path p) { + try { + return Files.size(p); + } catch (IOException e) { + throw new IllegalStateException("Cannot read file size: " + p, e); + } + } + + private static void safeDelete(Path p) { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + throw new IllegalStateException("Cannot delete file: " + p, e); + } + } +} +package server.ws; + +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.WriteCallback; +import org.eclipse.jetty.websocket.api.annotations.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.InboundMessageProcessor; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.JsonInboundProcessor; + +import java.nio.ByteBuffer; +import java.util.concurrent.CompletableFuture; + +@WebSocket +public class BlockchainWsEndpoint { + private static final Logger log = LoggerFactory.getLogger(BlockchainWsEndpoint.class); + + private Session session; + + /** Контекст для текущего WebSocket-соединения. */ + private final ConnectionContext connectionContext = new ConnectionContext(); + + @OnWebSocketConnect + public void onConnect(Session session) { + this.session = session; + // Привязываем WebSocket-сессию к ConnectionContext + connectionContext.setWsSession(session); + log.info("WS connected: {}", session.getRemoteAddress()); + } + + @OnWebSocketMessage + public void onBinary(byte[] payload, int offset, int length) { + byte[] msg = new byte[length]; + System.arraycopy(payload, offset, msg, 0, length); + + // Асинхронно обрабатываем входящее бинарное сообщение + CompletableFuture + .supplyAsync(() -> InboundMessageProcessor.process(msg)) + .thenAccept(resp -> { + if (resp != null && session != null && session.isOpen()) { + session.getRemote().sendBytes(ByteBuffer.wrap(resp), new WriteCallback() { + @Override + public void writeFailed(Throwable x) { + log.warn("Failed to send response", x); + } + + @Override + public void writeSuccess() { + log.debug("Response sent successfully"); + } + }); + } + }) + .exceptionally(ex -> { + log.error("Processing failed", ex); + trySendCode(500); + return null; + }); + } + + private void trySendCode(int code) { + if (session != null && session.isOpen()) { + byte[] resp = InboundMessageProcessor.intTo4Bytes(code); + session.getRemote().sendBytes(ByteBuffer.wrap(resp), new WriteCallback() { + @Override + public void writeFailed(Throwable x) { + log.warn("Failed to send error code", x); + } + + @Override + public void writeSuccess() { + log.debug("Error code {} sent", code); + } + }); + } + } + + @OnWebSocketClose + public void onClose(int statusCode, String reason) { + log.info("WS closed: {} {}", statusCode, reason); + // Удаляем это подключение из реестра активных соединений + ActiveConnectionsRegistry.getInstance().remove(connectionContext); + // На всякий случай очищаем контекст + connectionContext.reset(); + } + + @OnWebSocketError + public void onError(Throwable cause) { + log.error("WS error", cause); + } + + // Обработка текстовых JSON-запросов + @OnWebSocketMessage + public void onText(String message) { + log.info("📥 Получено TEXT-сообщение от клиента: {}", message); + + CompletableFuture + .supplyAsync(() -> JsonInboundProcessor.processJson(message, connectionContext)) + .thenAccept(respJson -> { + if (respJson != null && session != null && session.isOpen()) { + + log.info("📤 Отправляем ответ клиенту: {}", respJson); + + session.getRemote().sendString(respJson, new WriteCallback() { + @Override + public void writeFailed(Throwable x) { + log.warn("⚠️ Не удалось отправить JSON-ответ клиенту: {}", x.toString()); + } + + @Override + public void writeSuccess() { + log.debug("✔ JSON-ответ успешно отправлен"); + } + }); + } + }) + .exceptionally(ex -> { + log.error("❌ Ошибка при обработке JSON-сообщения", ex); + trySendJsonError(); + return null; + }); + } + + private void trySendJsonError() { + if (session != null && session.isOpen()) { + String resp = "{\"op\":null,\"requestId\":null,\"status\":500," + + "\"payload\":{\"code\":\"INTERNAL_ERROR\",\"message\":\"Ошибка сервера\"}}"; + + log.info("📤 Отправляем клиенту ошибку JSON: {}", resp); + + session.getRemote().sendString(resp, new WriteCallback() { + @Override + public void writeFailed(Throwable x) { + log.warn("⚠️ Не удалось отправить JSON-ответ клиенту: {}", x.toString()); + } + + @Override + public void writeSuccess() { + log.debug("✔ JSON-ошибка успешно отправлена"); + } + }); + } + } +} + +package server.ws; + +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import utils.config.AppConfig; + +import java.time.Duration; + +/** + * WsServer — поднимает Jetty WS на /ws. + * + * ВАЖНО: + * - перед стартом сервера выполняем recovery tmp-блокчейнов. + * - если обнаружена несогласованность, которую сервер сам чинить не может — + * recovery бросает исключение и сервер не стартует. + */ +public final class WsServer { + + private static final Logger log = LoggerFactory.getLogger(WsServer.class); + + public static void main(String[] args) throws Exception { + + // ============================================================ + // 0) Восстановление консистентности blockchain файлов + // ============================================================ + try { + BlockchainTmpRecoveryOnStartup.runRecoveryOrThrow(); + } catch (Exception e) { + // Уже должно быть “большое” уведомление через BlockchainAdminNotifier, + // но на всякий случай логируем ещё раз. + log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain tmp-файлов.", e); + throw e; // останавливаем запуск + } + + // ============================================================ + // 1) Настройки порта + // ============================================================ + AppConfig config = AppConfig.getInstance(); + int port = 7070; + try { + String portStr = config.getParam("server.port"); + if (portStr != null && !portStr.isBlank()) { + port = Integer.parseInt(portStr.trim()); + } + } catch (Exception e) { + log.info("Не удалось прочитать параметр server.port, используем порт по умолчанию {}", port); + } + + // ============================================================ + // 2) Запуск Jetty WS + // ============================================================ + Server server = new Server(port); + + ServletContextHandler context = new ServletContextHandler(); + context.setContextPath("/"); + server.setHandler(context); + + // Инициализация контейнера WebSocket + JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> { + // Таймаут простоя соединения (Jetty 11 синтаксис) + wsContainer.setIdleTimeout(Duration.ofMinutes(5)); + + // Маппинг эндпоинта + wsContainer.addMapping("/ws", (req, resp) -> new BlockchainWsEndpoint()); + }); + + server.start(); + log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); + server.join(); + } +} diff --git a/src/main/concat_to_file.sh b/src/main/concat_to_file.sh new file mode 100755 index 0000000..f6db1f1 --- /dev/null +++ b/src/main/concat_to_file.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUTFILE="all_files.txt" + +# очищаем или создаём файл +: > "$OUTFILE" + +# собрать только *.java файлы и вывести их содержимое в файл +find . -type f -name "*.java" | sort | while read -r f; do + cat "$f" >> "$OUTFILE" + echo >> "$OUTFILE" # пустая строка-разделитель +done + +# скопировать весь файл в буфер обмена (Wayland) +wl-copy < "$OUTFILE" + +echo "Готово!" +echo "Все .java файлы собраны в $OUTFILE" +echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/src/main/concat_to_file2.sh b/src/main/concat_to_file2.sh new file mode 100755 index 0000000..dc5f5d1 --- /dev/null +++ b/src/main/concat_to_file2.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUTFILE="all_files.txt" +SKIPFILE="skip.txt" + +# очищаем или создаём файл +: > "$OUTFILE" + +# читаем список исключённых имён (без расширения) в массив +if [[ -f "$SKIPFILE" ]]; then + mapfile -t SKIP_LIST < "$SKIPFILE" +else + SKIP_LIST=() +fi + +find . -type f -name "*.java" | sort | while read -r f; do + fname=$(basename "$f" .java) # имя файла без расширения + + # проверяем, есть ли имя в списке исключений + skip=false + for skipf in "${SKIP_LIST[@]}"; do + if [[ "$fname" == "$skipf" ]]; then + skip=true + break + fi + done + + if [[ "$skip" == true ]]; then + echo "Пропускаем $f" + continue + fi + + cat "$f" >> "$OUTFILE" + echo >> "$OUTFILE" # пустая строка-разделитель +done + +echo "Готово! Все .java файлы собраны в $OUTFILE (кроме исключённых из $SKIPFILE)" diff --git a/src/main/doc_to_client_writer.txt b/src/main/doc_to_client_writer.txt new file mode 100644 index 0000000..c1705f1 --- /dev/null +++ b/src/main/doc_to_client_writer.txt @@ -0,0 +1,172 @@ +Сервер SHiNE Blockchain представляет собой бинарный WebSocket-сервис, обрабатывающий запросы клиентов в виде бинарных пакетов и возвращающий также бинарные ответы. Сервер управляет пользовательскими цепочками блоков (файлы формата .bch), проверяет подписи Ed25519 и хэши SHA-256, сохраняет данные на диск и позволяет получать информацию о состоянии цепочек и искать пользователей. Все цепочки и метаданные хранятся локально, без базы данных. Цепочки записываются в файлы data/{id}.bch, а сводная информация о пользователях хранится в JSON-файле data/blockchain_info.json. + +Подключение клиента происходит по WebSocket на адрес ws://localhost:8080/ws или wss://shineup.me/ws. Протокол полностью бинарный, без текстовых или JSON-данных. Каждое сообщение клиента — отдельный бинарный пакет, сервер отвечает отдельным бинарным пакетом. Можно отправлять несколько запросов подряд, они обрабатываются независимо и асинхронно. + +Любое сообщение клиента начинается с 4 байт, которые определяют тип операции (opCode). Данные кодируются в big-endian. После первых четырёх байт следует полезная нагрузка, структура которой зависит от конкретной операции. Ответ сервера также начинается с 4 байт — это код состояния (statusCode), после которого могут следовать дополнительные данные. + +Основные операции протокола имеют следующие коды: 0 — PING, 1 — ADD_BLOCK, 2 — GET_BLOCKCHAIN, 30 — SEARCH_USERS. Код 0 используется для проверки соединения, сервер отвечает кодом 100 (PONG). Код 1 добавляет блок в цепочку. Код 2 возвращает полный бинарный файл блокчейна. Код 30 выполняет поиск пользователей по подстроке логина. + +Коды ответов статусов: 100 (PONG) — ответ на пинг, 200 (OK) — операция успешна, 400 (BAD_REQUEST) — ошибка формата или данных, 404 (NOT_FOUND) — цепочка не найдена, 409 (ALREADY_EXISTS) — блок с таким номером уже существует, 412 (NON_SEQUENTIAL) — получен блок с номером больше ожидаемого, 422 (UNVERIFIED) — не совпал хэш или подпись, 500 (INTERNAL_ERROR) — исключение или внутренняя ошибка сервера. Все коды возвращаются в виде четырёх байт BigEndian. + +Операция PING проста: клиент отправляет 4 байта со значением 0, сервер отвечает 4 байтами со значением 100. Это нужно для проверки активности соединения. + +Главная операция — ADD_BLOCK (код 1). Она используется для добавления нового блока в существующую или новую цепочку. Формат запроса: первые 4 байта — число 1 (код операции), следующие 8 байт — идентификатор цепочки blockchainId в формате long BigEndian. После этого следует бинарный блок формата .bch, который полностью включает данные, подпись и хэш. + +Формат файла .bch состоит из последовательности блоков без промежутков. Каждый блок имеет вид RAW + подпись (64 байта) + хэш (32 байта). RAW-часть состоит из заголовка размером 20 байт и тела произвольной длины. В заголовке содержатся поля: recordSize (4 байта, общий размер RAW), recordNumber (4 байта, порядковый номер блока), timestamp (8 байт, UNIX-время), recordType (2 байта, тип тела, 0=Header, 1=Text), recordTypeVersion (2 байта, версия структуры данного типа). После этого идёт само тело блока (body). + +Тело блока определяется по типу. Тип 0 — HeaderBody, заголовок цепочки, создающий новую цепочку. Тип 1 — TextBody, простой текстовый блок. Для новых цепочек допускается только блок типа 0 с номером 0. + +Когда сервер получает ADD_BLOCK, он сначала извлекает blockchainId и пытается найти информацию о цепочке в BchInfoManager. Если цепочки нет, то сервер допускает только блок-заголовок (type=0, num=0). Он парсится как HeaderBody, проверяется корректность логина, совпадение blockchainId и валидность подписи. Предыдущий хэш для первого блока считается нулевым (32 байта нулей). Если всё совпадает, создаётся новая запись о цепочке, создаётся файл data/{id}.bch, туда записывается бинарный блок, и в blockchain_info.json добавляется запись с логином, публичным ключом и текущим номером блока. Если в этом сценарии блок не является HeaderBody, или подпись невалидна, или blockchainId в теле не совпадает с заголовком — сервер возвращает код ошибки (обычно 400 или 422). + +Если цепочка существует, сервер проверяет, что номер нового блока ровно на единицу больше последнего (lastBlockNumber + 1). Если номер меньше, возвращается 409 (блок уже есть), если больше — 412 (пропуск в номерах). После этого сервер берёт хэш последнего блока, собирает канонический preimage (логин UTF-8 + blockchainId + prevHash32 + rawBytes), вычисляет SHA-256, сравнивает с переданным hash32 и проверяет подпись Ed25519. Если оба совпадают, тело блока парсится (TextBody или другой тип), выполняется его логическая проверка (check). После успешной проверки блок дописывается в файл .bch, обновляется информация о состоянии цепочки (последний номер, новый хэш, общий размер). Если подпись или хэш не совпали, сервер возвращает 422. + +Валидация подписи и хэша выполняется в классе BchCryptoVerifier. Preimage собирается из последовательности байт: сначала логин UTF-8 (без длины), затем 8 байт blockchainId BigEndian, затем 32 байта предыдущего хэша, затем сырые байты блока (RAW без подписи и хэша). Далее вычисляется SHA-256(preimage) и проверяется, что hash32 совпадает с ним. Подпись проверяется как Ed25519(preimage, publicKey32). Если хотя бы одна проверка не пройдена — блок отклоняется. + +Операция GET_BLOCKCHAIN (код 2) используется для получения всего бинарного содержимого цепочки. Формат запроса: первые 4 байта — код 2, следующие 8 байт — blockchainId (BigEndian). Ответ сервера начинается с 4 байт (200 при успехе), затем 4 байта длины данных, затем идут байты содержимого файла целиком. Если цепочка не найдена, возвращается 404. Если произошла ошибка чтения — 500. Таким образом клиент может загрузить весь блокчейн-файл и при необходимости сам распарсить блоки. + +Операция SEARCH_USERS (код 30) выполняет поиск логинов по подстроке. Формат запроса: первые 4 байта — код 30, следующие 4 байта — длина строки поиска N, затем N байт UTF-8 текста. Сервер выполняет поиск без учёта регистра по всем логинам, известным в blockchain_info.json, и возвращает максимум 5 совпадений. Ответ состоит из 4 байт (200 при успехе), затем 4 байт числа найденных пар, затем для каждой пары: 8 байт blockchainId BigEndian, 1 байт длины логина L, и L байт логина UTF-8 в оригинальном регистре. Если ничего не найдено, сервер возвращает 200 и число 0. Если запрос некорректен, возвращается 400. + +Файловая система сервера устроена просто: в каталоге data хранятся все файлы. Каждый блокчейн имеет свой файл с именем {id}.bch. В этом файле последовательно записаны блоки в двоичном виде. Параллельно существует файл blockchain_info.json — JSON-объект, где ключ — blockchainId, а значение — структура с логином, публичным ключом в Base64, последним номером блока, хэшем последнего блока и размером цепочки. При добавлении нового блока сервер обновляет запись и сохраняет JSON на диск. + +Сервер не требует аутентификации — безопасность обеспечивается криптографией: только владелец приватного ключа может подписывать блоки своей цепочки. Публичный ключ хранится в первом блоке HeaderBody и проверяется при добавлении. + +Таким образом, чтобы написать клиент, нужно: + +Уметь подключаться по WebSocket (ws или wss). + +Формировать бинарные пакеты в формате BigEndian. + +Для создания новой цепочки сгенерировать пару ключей Ed25519, собрать HeaderBody (с полями blockchainId, логин, публичный ключ), сериализовать его в байты, упаковать в блок типа 0 с номером 0, подписать preimage, добавить подпись и хэш, и отправить через ADD_BLOCK. + +Для добавления обычного блока (тип 1) использовать тот же алгоритм: собрать preimage из логина, id, предыдущего хэша и RAW-байтов, подписать, прикрепить подпись и хэш, отправить. + +Для чтения — вызвать GET_BLOCKCHAIN и распарсить ответ. + +Для поиска пользователей — отправить SEARCH_USERS и распарсить пары. + +Для проверки соединения — PING. + +Все числовые значения всегда в BigEndian. Все строки в UTF-8. Все подписи — Ed25519 длиной 64 байта. Все хэши — SHA-256 длиной 32 байта. + +Типы тел блоков: +HeaderBody (тип 0) содержит фиксированные поля: ASCII тег "SHiNE", blockchainId (8 байт), длину логина, логин UTF-8, четыре зарезервированных числа (type, number, version, prevId), и 32 байта публичного ключа. +TextBody (тип 1) содержит просто UTF-8-строку без дополнительных полей. + +Клиент, который реализует этот протокол, должен уметь: открывать WebSocket-соединение, отправлять бинарные пакеты по описанным форматам, вычислять SHA-256 и подписи Ed25519, и интерпретировать ответы сервера по первым четырём байтам статуса. Вся логика взаимодействия основана на простых числовых кодах и фиксированных структурах без сложных заголовков. + +Главное правило — сервер никогда не принимает блок, если не совпадает подпись или хэш. Поэтому клиент обязан правильно формировать preimage и использовать правильный публичный/приватный ключ. Также сервер строго следит за порядком номеров блоков. Валидация тела блока проверяет только базовую корректность (непустой логин, корректный UTF-8). Все остальные проверки лежат на клиенте. + +В результате взаимодействия через этот протокол клиент может создавать собственные цепочки блоков, добавлять в них данные, загружать свои или чужие цепочки и искать пользователей по логину. Формат прост, двоичный и полностью детерминирован. + + +Формат блока .bch является центральным элементом протокола. Каждый блок хранится последовательно, без промежутков. Цепочка — это просто последовательность таких блоков, записанных подряд в бинарный файл. Каждый блок состоит из трёх частей: RAW-часть (все данные без подписи и хэша), подпись длиной 64 байта и хэш длиной 32 байта. Общая длина блока равна длине RAW + 96 байт. Сервер при чтении файла просто двигается от начала к концу, разбирая один блок за другим, опираясь на поле recordSize, которое всегда указывает длину RAW-части (тело без подписи и хэша). + +RAW-часть имеет фиксированный формат и начинается с 20 байт заголовка. Эти 20 байт — это общая «шапка» для любого типа блока. В ней хранятся: + +recordSize — 4 байта, целое число BigEndian, равное длине всей RAW-части (20 + длина тела). + +recordNumber — 4 байта, номер блока в цепочке, начиная с нуля. + +timestamp — 8 байт, время создания блока в секундах Unix Time. + +recordType — 2 байта, короткое число, тип тела (0 = HeaderBody, 1 = TextBody, в будущем могут быть другие). + +recordTypeVersion — 2 байта, версия структуры тела (для HeaderBody всегда 1, для TextBody тоже 1). +После этих 20 байт сразу идёт тело блока (body), которое имеет разный формат в зависимости от типа. + +Тип 0 — HeaderBody, заголовочный блок, всегда имеет номер 0 и используется для создания новой цепочки. Его структура тела строго определена и содержит: +• 8 байт ASCII-строки “SHiNE” — это сигнатура формата. +• 8 байт long BigEndian — blockchainId, уникальный идентификатор цепочки. +• 1 байт — длина логина пользователя N. +• N байт — логин пользователя в UTF-8 (без завершающего нуля). +• 4 байта int BigEndian — blockchainType (пока всегда 0). +• 4 байта int BigEndian — blockchainNumber (пока всегда 0). +• 2 байта short BigEndian — версия формата пользователя (всегда 1). +• 8 байт long BigEndian — prevUserBchId (всегда 0). +• 32 байта — публичный ключ пользователя Ed25519 (publicKey32). + +После этих данных никакого дополнительного контента нет. Суммарная длина тела зависит от длины логина. Таким образом, для HeaderBody: длина RAW = 20 + (8 + 8 + 1 + N + 4 + 4 + 2 + 8 + 32). Этот блок полностью описывает владельца цепочки и его публичный ключ. При создании нового блокчейна сервер разрешает только один такой блок с номером 0 и типом 0. + +Тип 1 — TextBody, обычный текстовый блок. Его тело состоит только из текста UTF-8, без дополнительных метаданных. Это могут быть сообщения, комментарии, данные или команды. Любые последующие блоки (номер 1, 2, 3 и т.д.) обычно имеют тип 1. При сериализации тело просто представляет собой байты строки в кодировке UTF-8. + +После RAW-части добавляются две секции: подпись (signature64) и хэш (hash32). Подпись — это 64 байта, результат Ed25519.sign(preimage, privateKey). Хэш — это 32 байта, результат SHA-256(preimage). + +Ключевое понятие — preimage. Это каноническая бинарная последовательность, из которой формируется хэш и подпись. Она состоит из следующих частей, строго в указанном порядке: + +байты логина пользователя в UTF-8 (без длины, просто сами байты); + +8 байт идентификатора цепочки blockchainId в формате long BigEndian; + +32 байта предыдущего хэша prevHash32 (для самого первого блока — это 32 нуля); + +байты RAW-части (включая заголовок и тело, но без подписи и хэша). + +Из этого preimage берутся два результата: +• hash32 = SHA-256(preimage) +• signature64 = Ed25519.sign(preimage, privateKey32) + +Хэш хранится в блоке в конце и используется для проверки целостности при добавлении следующего блока. Следующий блок при вычислении своего preimage уже использует этот хэш как prevHash32. Таким образом, создаётся цепочка зависимых хэшей, образующих полную криптографическую связанность всех блоков. Подпись подтверждает, что именно владелец приватного ключа подписал блок. Сервер проверяет это через Ed25519.verify(preimage, signature64, publicKey32). + +Итоговая структура полного блока (FULL) выглядит так: +• 4 байта recordSize +• 4 байта recordNumber +• 8 байт timestamp +• 2 байта recordType +• 2 байта recordTypeVersion +• M байт body +• 64 байта signature64 +• 32 байта hash32 + +Общая длина блока равна recordSize + 96. При чтении из файла сервер сначала берёт recordSize, по нему знает, где заканчивается тело, а после этого считывает ещё 64 байта подписи и 32 байта хэша. + +Важно понимать: сервер не принимает блок, если хотя бы одно из условий нарушено — несоответствие длины, некорректный UTF-8 в теле, неправильная подпись, неверный хэш или сбитая последовательность номеров. Сервер также не принимает новый блок, если его номер не совпадает с ожидаемым (т.е. если цепочка уже имеет последний номер N, следующий блок должен иметь номер N+1). + +При генерации нового блока клиент должен выполнить последовательность шагов: + +Собрать тело блока (body). Для HeaderBody — с нужным логином и публичным ключом. Для TextBody — просто текст. + +Сформировать RAW-часть: заполнить 20 байт заголовка, указать длину тела, номер блока, время, тип и версию, затем добавить тело. + +Собрать preimage (логин UTF-8 + blockchainId + prevHash32 + rawBytes). Для первого блока prevHash32 — 32 нуля. + +Посчитать SHA-256(preimage) и сохранить как hash32. + +Подписать preimage своим приватным ключом Ed25519 и получить signature64. + +Объединить rawBytes + signature64 + hash32 в один массив. + +Отправить этот массив серверу в запросе ADD_BLOCK (после поля blockchainId). + +Если всё сделано правильно, сервер проверит подпись и хэш, убедится, что цепочка корректна, и добавит блок. В случае успеха сервер вернёт 200. Если подпись или хэш неверные — 422. Если номер блока не совпадает с ожидаемым — 409 или 412. + +Таким образом, клиент может последовательно создавать цепочку блоков, где каждый следующий блок зависит от предыдущего через хэш. Формат абсолютно детерминирован и не допускает вариаций. Все целые числа записываются в формате BigEndian. Все строки кодируются в UTF-8 без завершающего нуля. Все подписи Ed25519 имеют длину 64 байта, все хэши SHA-256 имеют длину 32 байта. Цепочка считается валидной, если каждый блок проходит проверку по своей подписи и хэшу, а номера блоков непрерывны от 0 и далее. + +Эта структура едина как для клиентской стороны, так и для сервера. Клиент, следуя этому формату, способен полностью создавать, подписывать и проверять блоки офлайн, а затем синхронизировать их с сервером, просто отправляя бинарные блоки в ADD_BLOCK. Сервер при получении блока повторяет ту же логику вычисления preimage и сверяет подпись и хэш, гарантируя, что ни один байт блока не был изменён. + +Таким образом, формат блока .bch является криптографически связанной последовательностью структур, каждая из которых включает заголовок, тело, подпись и хэш, и все они формируют надёжную цепочку данных, проверяемую без участия центральной базы данных. + + + + +Рекомендации для клиента и описание операции GET_LAST_BLOCK_INFO. + +Клиент хранит у себя приватный ключ пользователя, который никогда не передаётся серверу. Из этого приватного ключа вычисляется публичный ключ Ed25519, который используется для подписи блоков и проверки на сервере. Публичный ключ передаётся только один раз — внутри первого блока HeaderBody, создающего новую цепочку. После этого сервер хранит у себя только публичный ключ и логин пользователя, и проверяет подписи всех следующих блоков именно по нему. Приватный ключ остаётся исключительно у клиента. + +Клиент может создавать приватный ключ двумя способами. Первый — случайная генерация 32 байт (seed) через криптографический генератор случайных чисел. Второй, более удобный — детерминированная генерация из пароля пользователя: берётся строка пароля UTF-8, вычисляется SHA-256 от неё, и результат (32 байта) используется как приватный ключ. Это позволяет восстановить тот же ключ из одного и того же пароля без хранения seed в явном виде. Из приватного ключа вычисляется публичный через Ed25519. Таким образом, клиент может всегда получить ту же пару (private/public), просто имея пароль. + +На стороне клиента рекомендуется хранить три параметра для каждой цепочки: приватный ключ (или пароль, из которого он создаётся), публичный ключ и идентификатор цепочки blockchainId. Идентификатор цепочки — это уникальное 8-байтное число (long), которое выбирается клиентом при создании новой цепочки. Он может быть сгенерирован случайно, взят из системного счётчика или рассчитан как часть хэша от логина, но сервер не навязывает конкретный способ — важно только, чтобы значение было уникально. + +Для синхронизации с сервером клиент должен отслеживать состояние последнего блока: его номер и хэш. Эти значения необходимы при формировании следующего блока, чтобы корректно подставить prevHash32 в preimage. Сервер предоставляет отдельную операцию для получения этой информации. + +Операция GET_LAST_BLOCK_INFO имеет код 31 и используется для запроса состояния выбранной цепочки. Клиент отправляет запрос, состоящий из 12 байт: первые 4 байта — код операции (int 31 в BigEndian), следующие 8 байт — идентификатор цепочки blockchainId (long BigEndian). Сервер проверяет наличие цепочки и возвращает 40 байт данных. + +Ответ от сервера имеет следующую структуру: +• 4 байта — код статуса (200 при успехе, 404 если цепочка не найдена, 500 при ошибке); +• 4 байта — номер последнего блока (int BigEndian, если цепочка пуста — 0); +• 32 байта — хэш последнего блока SHA-256 (если цепочка пуста, все нули). + +Таким образом, клиент может в любой момент узнать, до какого блока сервер синхронизирован, чтобы не отправлять повторно уже существующие данные. Если сервер вернул 404, это значит, что цепочка с указанным blockchainId ещё не существует, и клиент может начать новую, отправив HeaderBody через ADD_BLOCK. Если сервер вернул 200, клиент использует полученный номер и хэш для формирования следующего блока: новый recordNumber должен быть на единицу больше полученного, а prevHash32 при вычислении preimage — это хэш, возвращённый сервером. + +Рекомендуется, чтобы клиент при каждом запуске сначала вызывал GET_LAST_BLOCK_INFO для своей цепочки, сверял локальное состояние с сервером, и только после этого создавал или отправлял новые блоки. Это обеспечивает целостность цепочки и правильную последовательность номеров. + +Таким образом, клиент хранит у себя всё необходимое для подписи и создания блоков — приватный ключ, логин, blockchainId и последнее состояние. Сервер же хранит только публичный ключ, логин, текущий номер блока и последний хэш. Все вычисления подписи и хэша выполняются клиентом локально. Это гарантирует, что сервер не может подделать или изменить ни один блок, а клиент при необходимости может полностью восстановить всю цепочку, просто имея свой пароль или приватный ключ. diff --git a/src/test/all_files.txt b/src/test/all_files.txt new file mode 100644 index 0000000..2131597 --- /dev/null +++ b/src/test/all_files.txt @@ -0,0 +1,2951 @@ +package test.it.blockchain; + +import blockchain.BchBlockEntry; +import blockchain.body.*; +import test.it.utils.TestConfig; +import test.it.utils.TestIds; +import test.it.utils.json.JsonParsers; +import test.it.utils.log.TestLog; +import test.it.utils.ws.WsSession; + +import java.time.Duration; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * AddBlockSender — под новый формат BchBlockEntry (Frame v0): + * - blockBytes = preimage + sigMarker(2) + signature64 + * - preimage начинается с frameCode(2) = 0x0000 + * - hash32 вычисляется как sha256(preimage) + * - signature = Ed25519.sign(hash32) + * + * ВАЖНО: + * - Линии по ТЗ ведём на стороне сервера/БД (триггеры), а в тестах считаем локально. + */ +public final class AddBlockSender { + + private static final String ZERO64 = "0".repeat(64); + + private final WsSession ws; + private final ChainState state; + + private final String login; + private final String blockchainName; + private final byte[] loginPrivKey; + + public AddBlockSender(WsSession ws, ChainState state, String login, String blockchainName, byte[] loginPrivKey) { + this.ws = ws; + this.state = state; + this.login = login; + this.blockchainName = blockchainName; + this.loginPrivKey = (loginPrivKey == null ? null : loginPrivKey.clone()); + if (this.ws == null) throw new IllegalArgumentException("ws == null"); + if (this.state == null) throw new IllegalArgumentException("state == null"); + if (this.loginPrivKey == null) throw new IllegalArgumentException("loginPrivKey == null"); + } + + public ChainState state() { return state; } + + public void send(BodyRecord body, Duration timeout) { + if (body == null) throw new IllegalArgumentException("body == null"); + + body.check(); + + boolean isHeader = (body instanceof HeaderBody); + + if (isHeader) { + if (state.lastBlockNumber() != -1) { + throw new IllegalStateException("HEADER должен быть первым: lastBlockNumber уже " + state.lastBlockNumber()); + } + } else { + if (!state.hasHeader()) { + throw new IllegalStateException("Нельзя слать блоки до HEADER (нет headerHash32)"); + } + } + + int blockNumber = state.nextBlockNumber(); + byte[] prevHash32 = state.prevHash32ForNext(); + long tsSec = System.currentTimeMillis() / 1000L; + + short type = typeOf(body); + short subType = subTypeOf(body); + short version = versionOf(body); + + byte[] bodyBytes = body.toBytes(); + + // ВАЖНО: preimage должен быть БАЙТ-В-БАЙТ таким же, как в BchBlockEntry + byte[] preimage = buildPreimage(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes); + + byte[] hash32 = blockchain.BchCryptoVerifier.sha256(preimage); + byte[] signature64 = utils.crypto.Ed25519Util.sign(hash32, loginPrivKey); + + BchBlockEntry entry = new BchBlockEntry( + prevHash32, + blockNumber, + tsSec, + type, + subType, + version, + bodyBytes, + signature64 + ); + + String prevHashHexForReq = (blockNumber == 0) ? ZERO64 : state.lastBlockHashHex(); + + String reqJson = buildAddBlockJson(blockchainName, blockNumber, prevHashHexForReq, base64(entry.toBytes())); + String op = "AddBlock(user=" + login + ", block=" + blockNumber + ", type=" + (type & 0xFFFF) + ", sub=" + (subType & 0xFFFF) + ")"; + + String resp = ws.call(op, reqJson, timeout); + + assert200(op, resp); + + String serverLastHash = JsonMini.extractPayloadString(resp, "serverLastBlockHash"); + if (serverLastHash == null) { + serverLastHash = JsonMini.extractPayloadString(resp, "serverLastGlobalHash"); + } + + assertNotNull(serverLastHash, op + ": payload.serverLastBlockHash must not be null"); + assertEquals(64, serverLastHash.trim().length(), op + ": serverLastBlockHash must be 64 hex chars"); + + String localHashHex = bytesToHex64(entry.getHash32()); + + if (TestConfig.DEBUG()) { + TestLog.info(op + ": localHash=" + localHashHex); + TestLog.info(op + ": serverLastBlockHash=" + serverLastHash); + } + + assertEquals(localHashHex, serverLastHash, op + ": serverLastBlockHash must match local hash"); + + state.applyAppendedBlock(blockNumber, entry.getHash32(), isHeader, type, body); + + if (TestConfig.DEBUG()) TestLog.info(op + ": state updated"); + } + + // ---------- request JSON ---------- + + private static String buildAddBlockJson(String blockchainName, int blockNumber, String prevBlockHashHex, String blockBytesB64) { + String requestId = TestIds.next("addblock"); + return """ + { + "op": "AddBlock", + "requestId": "%s", + "payload": { + "blockchainName": "%s", + "blockNumber": %d, + "prevBlockHash": "%s", + "blockBytesB64": "%s" + } + } + """.formatted(requestId, blockchainName, blockNumber, prevBlockHashHex, blockBytesB64); + } + + private static void assert200(String op, String resp) { + int st = JsonParsers.status(resp); + assertEquals(200, st, op + ": expected status=200, but got=" + st + ", resp=" + resp); + TestLog.ok(op + ": status=200"); + } + + private static String base64(byte[] bytes) { + return Base64.getEncoder().encodeToString(bytes); + } + + private static String bytesToHex64(byte[] b32) { + char[] out = new char[64]; + final char[] HEX = "0123456789abcdef".toCharArray(); + for (int i = 0; i < 32; i++) { + int v = b32[i] & 0xFF; + out[i * 2] = HEX[v >>> 4]; + out[i * 2 + 1] = HEX[v & 0x0F]; + } + return new String(out); + } + + // ---------- header extraction from body ---------- + + private static short typeOf(BodyRecord body) { + if (body instanceof HeaderBody) return HeaderBody.TYPE; + if (body instanceof CreateChannelBody) return CreateChannelBody.TYPE; + if (body instanceof TextBody) return TextBody.TYPE; + if (body instanceof ReactionBody) return ReactionBody.TYPE; + if (body instanceof ConnectionBody) return ConnectionBody.TYPE; + if (body instanceof UserParamBody) return UserParamBody.TYPE; + throw new IllegalArgumentException("Unknown body class: " + body.getClass()); + } + + private static short subTypeOf(BodyRecord body) { + if (body instanceof HeaderBody hb) return hb.subType; + if (body instanceof CreateChannelBody cb) return cb.subType; + if (body instanceof TextBody tb) return tb.subType; + if (body instanceof ReactionBody rb) return rb.subType; + if (body instanceof ConnectionBody cb) return cb.subType; + if (body instanceof UserParamBody ub) return ub.subType; + throw new IllegalArgumentException("Unknown body class: " + body.getClass()); + } + + private static short versionOf(BodyRecord body) { + if (body instanceof HeaderBody hb) return hb.version; + if (body instanceof CreateChannelBody cb) return cb.version; + if (body instanceof TextBody tb) return tb.version; + if (body instanceof ReactionBody rb) return rb.version; + if (body instanceof ConnectionBody cb) return cb.version; + if (body instanceof UserParamBody ub) return ub.version; + throw new IllegalArgumentException("Unknown body class: " + body.getClass()); + } + + // ---------- preimage builder (строго по BchBlockEntry Frame v0) ---------- + + private static byte[] buildPreimage(byte[] prevHash32, + int blockNumber, + long tsSec, + short type, + short subType, + short version, + byte[] bodyBytes) { + + if (prevHash32 == null || prevHash32.length != 32) { + throw new IllegalArgumentException("prevHash32 must be 32 bytes"); + } + + int bodyLen = (bodyBytes == null ? 0 : bodyBytes.length); + int blockSize = BchBlockEntry.PREIMAGE_HEADER_SIZE + bodyLen; + + java.nio.ByteBuffer bb = java.nio.ByteBuffer.allocate(blockSize).order(java.nio.ByteOrder.BIG_ENDIAN); + + // [2] frameCode (v0) + bb.putShort((short) (BchBlockEntry.FRAME_CODE_V0 & 0xFFFF)); + + // [32] prevHash32 + bb.put(prevHash32); + + // [4] blockSize (preimage size) + bb.putInt(blockSize); + + // [4] blockNumber + bb.putInt(blockNumber); + + // [8] timestamp + bb.putLong(tsSec); + + // [2][2][2] type/subType/version + bb.putShort(type); + bb.putShort(subType); + bb.putShort(version); + + // [N] bodyBytes + if (bodyBytes != null) bb.put(bodyBytes); + + return bb.array(); + } +} +package test.it.blockchain; + +import blockchain.body.BodyRecord; +import blockchain.body.BodyHasLine; +import blockchain.body.CreateChannelBody; +import blockchain.body.TextBody; + +import java.util.HashMap; +import java.util.Map; + +/** + * ChainState — состояние глобальной цепочки + состояние линий. + * + * Глобальная цепочка: + * - lastBlockNumber / lastBlockHashHex + * - map blockNumber -> hash32 + * + * Линии: + * - TECH (type=0): только CREATE_CHANNEL (hasLine), root = HEADER + * - TEXT (type=1): линии каналов, root = HEADER (канал "0") или CREATE_CHANNEL (канал "X") + * - CONNECTION (type=3): одна линия + * - USER_PARAM (type=4): одна линия + * + * ВАЖНО: + * - prevLineNumber — это GLOBAL blockNumber предыдущего блока линии. + * - thisLineNumber — внутренний номер линии (для постов: 0,1,2...; для тех-линии: 1,2,3...) + * - lineCode — код линии: + * * 0 для канала "0" и для "простых" линий (connection/user_param/tech) + * * для каналов !=0: lineCode = blockNumber "заглавия" канала (CREATE_CHANNEL) + */ +public final class ChainState { + + public static final short TYPE_TECH = 0; // header/create_channel + public static final short TYPE_TEXT = 1; + public static final short TYPE_REACTION = 2; + public static final short TYPE_CONNECTION = 3; + public static final short TYPE_USER_PARAM = 4; + + private static final byte[] ZERO32 = new byte[32]; + private static final String ZERO64 = "0".repeat(64); + + // global chain + private int lastBlockNumber = -1; + private String lastBlockHashHex = ZERO64; + + // header (block#0) + private byte[] headerHash32 = null; + + private final Map hash32ByNumber = new HashMap<>(); + + // ---------- TECH line state ---------- + private static final class TechLineState { + int lastGlobalNumber = -1; // последний TECH-блок (HEADER или CREATE_CHANNEL) + String lastHashHex = ""; + int lastThisLineNumber = 0; // 0 у HEADER (логически), дальше 1,2,3... + + void reset() { + lastGlobalNumber = -1; + lastHashHex = ""; + lastThisLineNumber = 0; + } + } + + private final TechLineState techLine = new TechLineState(); + + // ---------- CONNECTION/USER_PARAM line state ---------- + private static final class SimpleLineState { + int lastGlobalNumber = -1; + String lastHashHex = ""; + int lastThisLineNumber = 0; + + void reset() { + lastGlobalNumber = -1; + lastHashHex = ""; + lastThisLineNumber = 0; + } + } + + private final SimpleLineState connectionLine = new SimpleLineState(); + private final SimpleLineState userParamLine = new SimpleLineState(); + + // ---------- TEXT channels ---------- + public static final class ChannelLineState { + final int lineCode; // для каналов: = rootBlockNumber; для канала 0: 0 + final int rootBlockNumber; // 0 для канала 0, иначе blockNumber CREATE_CHANNEL + final String rootHashHex; + + int lastGlobalNumber; + String lastHashHex; + int lastThisLineNumber; // перед первым постом = -1, чтобы первый был 0 + + ChannelLineState(int lineCode, int rootBlockNumber, String rootHashHex) { + this.lineCode = lineCode; + this.rootBlockNumber = rootBlockNumber; + this.rootHashHex = rootHashHex; + this.lastGlobalNumber = rootBlockNumber; + this.lastHashHex = rootHashHex; + this.lastThisLineNumber = -1; + } + } + + // lineCode -> state (для канала 0 lineCode=0) + private final Map textChannels = new HashMap<>(); + + public ChainState() { + techLine.reset(); + connectionLine.reset(); + userParamLine.reset(); + } + + // -------------------- global getters -------------------- + + public int lastBlockNumber() { return lastBlockNumber; } + public String lastBlockHashHex() { return lastBlockHashHex; } + + public boolean hasHeader() { + return headerHash32 != null && headerHash32.length == 32 && lastBlockNumber >= 0; + } + + public int nextBlockNumber() { + return lastBlockNumber + 1; + } + + public byte[] prevHash32ForNext() { + if (lastBlockNumber < 0) return ZERO32; + return hexToBytes32(lastBlockHashHex); + } + + public byte[] headerHash32() { + return headerHash32 == null ? null : headerHash32.clone(); + } + + public byte[] getHash32(int blockNumber) { + byte[] h = hash32ByNumber.get(blockNumber); + return h == null ? null : h.clone(); + } + + // -------------------- line helpers -------------------- + + public static final class NextLine { + public final int lineCode; + public final int prevLineNumber; // GLOBAL blockNumber + public final byte[] prevLineHash32; // 32 bytes + public final int thisLineNumber; // внутр. номер линии + + public NextLine(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber) { + this.lineCode = lineCode; + this.prevLineNumber = prevLineNumber; + this.prevLineHash32 = (prevLineHash32 == null ? null : prevLineHash32.clone()); + this.thisLineNumber = thisLineNumber; + } + } + + /** Следующие line-поля для TECH/CONNECTION/USER_PARAM. lineCode=0. */ + public NextLine nextLineByType(short type) { + if (!hasHeader()) { + throw new IllegalStateException("Нельзя формировать line-поля до HEADER (нет headerHash32)"); + } + + int t = type & 0xFFFF; + + if (t == TYPE_TECH) { + if (techLine.lastGlobalNumber == -1) { + throw new IllegalStateException("TECH line is not initialized yet"); + } + return new NextLine( + 0, + techLine.lastGlobalNumber, + hexToBytes32(techLine.lastHashHex), + techLine.lastThisLineNumber + 1 + ); + } + + if (t == TYPE_CONNECTION) { + return nextSimpleLine(connectionLine); + } + if (t == TYPE_USER_PARAM) { + return nextSimpleLine(userParamLine); + } + + throw new IllegalArgumentException("Type " + t + " не поддерживает nextLineByType()"); + } + + private NextLine nextSimpleLine(SimpleLineState ls) { + if (ls.lastGlobalNumber == -1) { + // первый блок линии ссылается на HEADER (block#0) + return new NextLine(0, 0, headerHash32.clone(), 1); + } + if (ls.lastHashHex == null || ls.lastHashHex.isBlank()) { + throw new IllegalStateException("LineState.lastHashHex пуст, но lastGlobalNumber!=-1"); + } + return new NextLine(0, ls.lastGlobalNumber, hexToBytes32(ls.lastHashHex), ls.lastThisLineNumber + 1); + } + + /** + * Следующие line-поля для TEXT-канала по lineCode. + * Для канала 0: lineCode=0. + * Для других каналов: lineCode = rootBlockNumber (CREATE_CHANNEL blockNumber). + */ + public NextLine nextTextLineByCode(int lineCode) { + if (!hasHeader()) throw new IllegalStateException("No HEADER"); + ChannelLineState cs = textChannels.get(lineCode); + if (cs == null) throw new IllegalStateException("Unknown TEXT channel lineCode=" + lineCode); + + return new NextLine( + lineCode, + cs.lastGlobalNumber, + hexToBytes32(cs.lastHashHex), + cs.lastThisLineNumber + 1 + ); + } + + /** Старое имя — оставил для удобства: rootBlockNumber == lineCode для каналов. */ + public NextLine nextTextLineByRoot(int rootBlockNumber) { + return nextTextLineByCode(rootBlockNumber); + } + + /** + * Зарегистрировать новый канал TEXT: + * - lineCode = rootBlockNumber (blockNumber CREATE_CHANNEL) + * ИДЕМПОТЕНТНО: если уже зарегистрирован — ничего не делаем. + */ + public void registerTextChannelRoot(int rootBlockNumber, byte[] rootHash32) { + if (rootBlockNumber < 0) throw new IllegalArgumentException("rootBlockNumber must be >= 0"); + if (rootHash32 == null || rootHash32.length != 32) throw new IllegalArgumentException("rootHash32 invalid"); + + if (textChannels.containsKey(rootBlockNumber)) { + return; // уже есть — не трогаем, чтобы не сбросить lastThisLineNumber и т.д. + } + + int lineCode = rootBlockNumber; + textChannels.put(lineCode, new ChannelLineState(lineCode, rootBlockNumber, bytesToHex64(rootHash32))); + } + + /** root/lineCode канала "0" (по умолчанию) — это HEADER block#0, lineCode=0. */ + public int rootChannel0() { + return 0; + } + + // -------------------- apply -------------------- + + public void applyAppendedBlock(int blockNumber, byte[] hash32, boolean isHeader, short type, BodyRecord body) { + if (hash32 == null || hash32.length != 32) { + throw new IllegalArgumentException("hash32 must be 32 bytes"); + } + if (blockNumber != lastBlockNumber + 1) { + throw new IllegalStateException("blockNumber sequence broken: expected=" + (lastBlockNumber + 1) + " got=" + blockNumber); + } + + if (isHeader) { + if (blockNumber != 0) throw new IllegalStateException("HEADER must be blockNumber=0"); + headerHash32 = hash32.clone(); + } else { + if (blockNumber == 0) throw new IllegalStateException("Non-header block can't be blockNumber=0"); + if (headerHash32 == null) throw new IllegalStateException("Header must be sent before non-header blocks"); + } + + String hex64 = bytesToHex64(hash32); + + lastBlockNumber = blockNumber; + lastBlockHashHex = hex64; + + hash32ByNumber.put(blockNumber, hash32.clone()); + + // ---- init after HEADER ---- + if (isHeader) { + // TECH line root = HEADER + techLine.lastGlobalNumber = 0; + techLine.lastHashHex = hex64; + techLine.lastThisLineNumber = 0; + + // TEXT channel "0" root = HEADER, lineCode=0 + registerTextChannelRoot(0, hash32); + + return; + } + + int t = type & 0xFFFF; + + // ---- TECH (CREATE_CHANNEL) ---- + if (t == TYPE_TECH && body instanceof CreateChannelBody ccb) { + techLine.lastGlobalNumber = blockNumber; + techLine.lastHashHex = hex64; + techLine.lastThisLineNumber = ccb.thisLineNumber; + + // ВАЖНО: CREATE_CHANNEL — это root нового текстового канала: + // lineCode для этого канала = blockNumber CREATE_CHANNEL + registerTextChannelRoot(blockNumber, hash32); + + return; + } + + // ---- CONNECTION / USER_PARAM ---- + if (t == TYPE_CONNECTION && body instanceof BodyHasLine hlc) { + connectionLine.lastGlobalNumber = blockNumber; + connectionLine.lastHashHex = hex64; + connectionLine.lastThisLineNumber = hlc.lineSeq(); + return; + } + if (t == TYPE_USER_PARAM && body instanceof BodyHasLine hlu) { + userParamLine.lastGlobalNumber = blockNumber; + userParamLine.lastHashHex = hex64; + userParamLine.lastThisLineNumber = hlu.lineSeq(); + return; + } + + // ---- TEXT channels (POST/EDIT_POST) ---- + if (t == TYPE_TEXT && body instanceof TextBody tb) { + if (tb.isLineMessage()) { + int lineCode = tb.lineCode; + + ChannelLineState channel = textChannels.get(lineCode); + if (channel == null) { + throw new IllegalStateException( + "TEXT line message has unknown lineCode=" + lineCode + + " (канал не зарегистрирован; ждали CREATE_CHANNEL или HEADER)" + ); + } + + channel.lastGlobalNumber = blockNumber; + channel.lastHashHex = hex64; + channel.lastThisLineNumber = tb.thisLineNumber; + } + } + } + + // -------------------- utils -------------------- + + private static byte[] hexToBytes32(String hex) { + if (hex == null) throw new IllegalArgumentException("hex is null"); + String s = hex.trim(); + if (s.length() != 64) throw new IllegalArgumentException("hex must be 64 chars, got " + s.length()); + byte[] out = new byte[32]; + for (int i = 0; i < 32; i++) { + int hi = Character.digit(s.charAt(i * 2), 16); + int lo = Character.digit(s.charAt(i * 2 + 1), 16); + if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex at pos " + (i * 2)); + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + + private static String bytesToHex64(byte[] b32) { + char[] out = new char[64]; + final char[] HEX = "0123456789abcdef".toCharArray(); + for (int i = 0; i < 32; i++) { + int v = b32[i] & 0xFF; + out[i * 2] = HEX[v >>> 4]; + out[i * 2 + 1] = HEX[v & 0x0F]; + } + return new String(out); + } +} +package test.it.blockchain; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * JsonMini — маленькие утилиты, чтобы не раздувать зависимости. + */ +final class JsonMini { + private static final ObjectMapper M = new ObjectMapper(); + private JsonMini() {} + + static String extractPayloadString(String json, String field) { + try { + JsonNode root = M.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has(field)) { + JsonNode v = payload.get(field); + return (v == null || v.isNull()) ? null : v.asText(); + } + } catch (Exception ignore) {} + return null; + } +} +package test.it.cases; + +import test.it.utils.TestConfig; +import test.it.utils.json.JsonBuilders; +import test.it.utils.json.JsonParsers; +import test.it.utils.log.TestResult; +import test.it.utils.ws.WsSession; + +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.fail; + +/** + * IT_01_AddUser + * Создаёт 3 пользователей: TestUser1/2/3 (200 OK или 409 USER_ALREADY_EXISTS). + * + * Обновление: + * - теперь AddUser может вернуть 409 не только USER_ALREADY_EXISTS, + * но и BLOCKCHAIN_ALREADY_EXISTS / BLOCKCHAIN_STATE_ALREADY_EXISTS. + * - дополнительно проверяем GetUser (status=200 всегда). + * - добавлен SearchUsers: поиск по префиксу (первые 3 символа). + */ +public class IT_01_AddUser { + + public static void main(String[] args) { + String summary = run(); + System.out.println(summary); + } + + public static String run() { + TestResult r = new TestResult("IT_01_AddUser"); + + Duration t = Duration.ofSeconds(5); + + try (WsSession ws = WsSession.open()) { + + r.ok("AddUser USER1: " + TestConfig.LOGIN()); + String resp1 = ws.call("AddUser#USER1", JsonBuilders.addUser(TestConfig.LOGIN()), t); + checkAddUser200or409(r, resp1); + checkGetUserMustExist(r, ws, TestConfig.LOGIN(), t); + + r.ok("AddUser USER2: " + TestConfig.LOGIN2()); + String resp2 = ws.call("AddUser#USER2", JsonBuilders.addUser(TestConfig.LOGIN2()), t); + checkAddUser200or409(r, resp2); + checkGetUserMustExist(r, ws, TestConfig.LOGIN2(), t); + + r.ok("AddUser USER3: " + TestConfig.LOGIN3()); + String resp3 = ws.call("AddUser#USER3", JsonBuilders.addUser(TestConfig.LOGIN3()), t); + checkAddUser200or409(r, resp3); + checkGetUserMustExist(r, ws, TestConfig.LOGIN3(), t); + + // Доп: проверяем case-insensitive поиск в GetUser + String mixed = mixCase(TestConfig.LOGIN()); + r.ok("GetUser case-insensitive: запрос=" + mixed + " (должен найти " + TestConfig.LOGIN() + ")"); + checkGetUserMustExist(r, ws, mixed, t); + + // Доп: проверяем "не существует" (но status=200) + String missing = "NoSuchUser_987654321"; + r.ok("GetUser missing: " + missing); + checkGetUserMustNotExist(r, ws, missing, t); + + // SearchUsers: один раз ищем по первым трём символам логина USER1 + String prefix3 = first3(TestConfig.LOGIN()); + String prefix3Mixed = mixCase(prefix3); + r.ok("SearchUsers: prefix(3)='" + prefix3Mixed + "' (должен вернуть список и содержать " + TestConfig.LOGIN() + ")"); + checkSearchUsersMustContain(r, ws, prefix3Mixed, TestConfig.LOGIN(), t); + + } catch (Throwable e) { + r.fail("IT_01_AddUser упал: " + e.getMessage()); + } + + return r.summaryLine(); + } + + private static void checkAddUser200or409(TestResult r, String resp) { + int st = JsonParsers.status(resp); + if (st == 200) { + r.ok("AddUser: status=200 (создан)"); + return; + } + if (st == 409) { + String code = JsonParsers.errorCode(resp); + + // раньше был только USER_ALREADY_EXISTS, теперь добавились ещё варианты + if ("USER_ALREADY_EXISTS".equals(code)) { + r.ok("AddUser: status=409 USER_ALREADY_EXISTS (уже был)"); + return; + } + if ("BLOCKCHAIN_ALREADY_EXISTS".equals(code)) { + r.ok("AddUser: status=409 BLOCKCHAIN_ALREADY_EXISTS (blockchainName уже занят)"); + return; + } + if ("BLOCKCHAIN_STATE_ALREADY_EXISTS".equals(code)) { + r.ok("AddUser: status=409 BLOCKCHAIN_STATE_ALREADY_EXISTS (blockchain_state уже есть)"); + return; + } + + r.fail("AddUser: status=409 но code=" + code + ", resp=" + resp); + fail("AddUser unexpected 409 code=" + code); + } + r.fail("AddUser: неожиданный status=" + st + ", resp=" + resp); + fail("AddUser unexpected status=" + st); + } + + private static void checkGetUserMustExist(TestResult r, WsSession ws, String loginQuery, Duration t) { + String resp = ws.call("GetUser#" + loginQuery, JsonBuilders.getUser(loginQuery), t); + + int st = JsonParsers.status(resp); + if (st != 200) { + r.fail("GetUser: ожидали status=200, получили " + st + ", resp=" + resp); + fail("GetUser unexpected status=" + st); + } + + Boolean exists = JsonParsers.exists(resp); + if (exists == null || !exists) { + r.fail("GetUser: ожидали exists=true, resp=" + resp); + fail("GetUser expected exists=true"); + } + + // Проверяем, что сервер возвращает данные + String login = JsonParsers.userLogin(resp); + String blockchainName = JsonParsers.userBlockchainName(resp); + String solanaKey = JsonParsers.userSolanaKey(resp); + String blockchainKey = JsonParsers.userBlockchainKey(resp); + String deviceKey = JsonParsers.userDeviceKey(resp); + + if (isBlank(login) || isBlank(blockchainName) || isBlank(solanaKey) || isBlank(blockchainKey) || isBlank(deviceKey)) { + r.fail("GetUser: exists=true, но поля пустые/неполные, resp=" + resp); + fail("GetUser returned incomplete user data"); + } + + // ВАЖНО: + // Поиск делается без учета регистра, но login/blockchainName должны вернуться как в БД. + // Для тех логинов, которые мы создаем в тесте, это ровно TestConfig.LOGIN*(). + // Поэтому если запрос был смешанный регистр — сравниваем не с loginQuery, а с "каноничным" логином из конфига. + String canonical = canonicalLogin(loginQuery); + if (canonical != null) { + if (!login.equals(canonical)) { + r.fail("GetUser: login должен вернуться как в БД. expected=" + canonical + ", got=" + login + ", resp=" + resp); + fail("GetUser wrong login case"); + } + + String expectedBch = TestConfig.getBlockchainName(canonical); + if (!blockchainName.equals(expectedBch)) { + r.fail("GetUser: blockchainName должен вернуться как в БД. expected=" + expectedBch + ", got=" + blockchainName + ", resp=" + resp); + fail("GetUser wrong blockchainName"); + } + + // ключи должны совпадать с теми, что AddUser использует при регистрации + String expSol = TestConfig.solanaPublicKeyB64(canonical); + String expBchKey = TestConfig.blockchainPublicKeyB64(canonical); + String expDev = TestConfig.devicePublicKeyB64(canonical); + + if (!solanaKey.equals(expSol)) { + r.fail("GetUser: solanaKey mismatch, resp=" + resp); + fail("GetUser solanaKey mismatch"); + } + if (!blockchainKey.equals(expBchKey)) { + r.fail("GetUser: blockchainKey mismatch, resp=" + resp); + fail("GetUser blockchainKey mismatch"); + } + if (!deviceKey.equals(expDev)) { + r.fail("GetUser: deviceKey mismatch, resp=" + resp); + fail("GetUser deviceKey mismatch"); + } + } + + r.ok("GetUser: exists=true, login=" + login + ", blockchainName=" + blockchainName); + } + + private static void checkGetUserMustNotExist(TestResult r, WsSession ws, String loginQuery, Duration t) { + String resp = ws.call("GetUser#" + loginQuery, JsonBuilders.getUser(loginQuery), t); + + int st = JsonParsers.status(resp); + if (st != 200) { + r.fail("GetUser(not exist): ожидали status=200, получили " + st + ", resp=" + resp); + fail("GetUser(not exist) unexpected status=" + st); + } + + Boolean exists = JsonParsers.exists(resp); + if (exists == null) { + r.fail("GetUser(not exist): payload.exists отсутствует, resp=" + resp); + fail("GetUser(not exist) missing exists"); + } + if (exists) { + r.fail("GetUser(not exist): ожидали exists=false, resp=" + resp); + fail("GetUser(not exist) expected exists=false"); + } + + r.ok("GetUser: exists=false (ok)"); + } + + private static void checkSearchUsersMustContain(TestResult r, WsSession ws, String prefix, String expectedLogin, Duration t) { + String resp = ws.call("SearchUsers#" + prefix, JsonBuilders.searchUsers(prefix), t); + + int st = JsonParsers.status(resp); + if (st != 200) { + r.fail("SearchUsers: ожидали status=200, получили " + st + ", resp=" + resp); + fail("SearchUsers unexpected status=" + st); + } + + List logins = JsonParsers.searchLogins(resp); + if (logins == null || logins.isEmpty()) { + r.fail("SearchUsers: ожидали непустой список, resp=" + resp); + fail("SearchUsers expected non-empty list"); + } + + // ВАЖНО: ожидаемый логин должен быть в ответе в регистре БД (каноничный expectedLogin) + boolean found = false; + for (String s : logins) { + if (expectedLogin.equals(s)) { + found = true; + break; + } + } + if (!found) { + r.fail("SearchUsers: ожидаемый логин не найден. expected=" + expectedLogin + ", got=" + logins + ", resp=" + resp); + fail("SearchUsers expected login not found"); + } + + r.ok("SearchUsers: ok, prefix=" + prefix + ", results=" + logins.size() + ", contains=" + expectedLogin); + } + + private static String canonicalLogin(String anyCaseLogin) { + if (anyCaseLogin == null) return null; + String x = anyCaseLogin.trim(); + if (x.isEmpty()) return null; + + // Привязка только к нашим тестовым логинам, чтобы не гадать. + if (x.equalsIgnoreCase(TestConfig.LOGIN())) return TestConfig.LOGIN(); + if (x.equalsIgnoreCase(TestConfig.LOGIN2())) return TestConfig.LOGIN2(); + if (x.equalsIgnoreCase(TestConfig.LOGIN3())) return TestConfig.LOGIN3(); + + return null; + } + + private static String mixCase(String s) { + if (s == null) return null; + String x = s.trim(); + if (x.length() < 2) return x; + // простой "микс" без рандома, чтобы тест был детерминированный + return Character.toUpperCase(x.charAt(0)) + x.substring(1).toLowerCase(); + } + + private static String first3(String s) { + if (s == null) return ""; + String x = s.trim(); + if (x.length() <= 3) return x; + return x.substring(0, 3); + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } +} +package test.it.cases; + +import test.it.utils.TestConfig; +import test.it.utils.json.JsonBuilders; +import test.it.utils.json.JsonParsers; +import test.it.utils.log.TestLog; +import test.it.utils.log.TestResult; +import test.it.utils.ws.WsSession; + +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IT_02_Sessions (v2) + * + * Цель: + * - проверить создание/листинг/вход-в-сессию(2 шага)/close + * - и после завершения оставить в БД 3 активных сессии (S1,S2,S3) + * + * Протокол v2: + * - создание сессии: AuthChallenge -> CreateAuthSession (deviceKey подпись, + sessionPubKey) + * - вход в сессию: SessionChallenge(sessionId) -> nonce, затем SessionLogin(sessionId,time,signature(sessionKey)) + * - ListSessions и CloseActiveSession доступны только в AUTH_STATUS_USER (после SessionLogin) + */ +public class IT_02_Sessions { + + private static final String LOGIN = TestConfig.LOGIN(); + + public static void main(String[] args) { + TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> сначала запускаю IT_01_AddUser"); + System.out.println(IT_01_AddUser.run()); + String summary = run(); + System.out.println(summary); + } + + public static String run() { + TestResult r = new TestResult("IT_02_Sessions(v2)"); + + Duration t = Duration.ofSeconds(5); + + Session s1, s2, s3; + + try { + // 1) Создаём 3 сессии (каждая — отдельным соединением) + s1 = createSession(LOGIN, t, r, "S1"); + s2 = createSession(LOGIN, t, r, "S2"); + s3 = createSession(LOGIN, t, r, "S3"); + + // 2) Входим в S1 (2 шага) и делаем ListSessions (AUTH_STATUS_USER) — должны быть S1,S2,S3 + try (WsSession ws = WsSession.open()) { + sessionLogin2Steps(ws, s1, t, "Login(S1)", r); + + String listResp = ws.call("ListSessions(AUTH_STATUS_USER)", JsonBuilders.listSessions(0L, ""), t); + assertEquals(200, JsonParsers.status(listResp), "ListSessions(AUTH_STATUS_USER) must be 200"); + + List ids = JsonParsers.sessionIds(listResp); + r.ok("ListSessions(AUTH_STATUS_USER): " + ids); + + assertTrue(ids.contains(s1.sessionId), "Must contain S1"); + assertTrue(ids.contains(s2.sessionId), "Must contain S2"); + assertTrue(ids.contains(s3.sessionId), "Must contain S3"); + r.ok("Проверка OK: список содержит S1,S2,S3"); + } + + // 3) Проверяем CloseActiveSession так, чтобы итогом всё равно осталось 3 сессии: + // создаём TEMP, логинимся в S1, закрываем TEMP, убеждаемся что S1,S2,S3 остались. + Session temp = createSession(LOGIN, t, r, "TEMP"); + + try (WsSession ws = WsSession.open()) { + sessionLogin2Steps(ws, s1, t, "Login(S1) for close", r); + + String closeResp = ws.call("CloseActiveSession(TEMP)", JsonBuilders.closeActiveSession(temp.sessionId, 0L, ""), t); + assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession(TEMP) must be 200"); + r.ok("CloseActiveSession(TEMP): OK"); + } + + // 4) Финальная проверка: снова логинимся в S1 и ListSessions => S1,S2,S3 должны остаться, TEMP нет + try (WsSession ws = WsSession.open()) { + sessionLogin2Steps(ws, s1, t, "Final Login(S1)", r); + + String listResp = ws.call("ListSessions(final)", JsonBuilders.listSessions(0L, ""), t); + assertEquals(200, JsonParsers.status(listResp)); + + List ids = JsonParsers.sessionIds(listResp); + r.ok("Final ListSessions: " + ids); + + assertTrue(ids.contains(s1.sessionId)); + assertTrue(ids.contains(s2.sessionId)); + assertTrue(ids.contains(s3.sessionId)); + assertFalse(ids.contains(temp.sessionId)); + r.ok("ИТОГ OK: после теста в БД остались 3 активные сессии (S1,S2,S3)"); + } + + } catch (Throwable e) { + r.fail("IT_02_Sessions(v2) упал: " + e.getMessage()); + } + + return r.summaryLine(); + } + + private static Session createSession(String login, Duration t, TestResult r, String label) { + try (WsSession ws = WsSession.open()) { + + // шаг 1: AuthChallenge + String nonceResp = ws.call("AuthChallenge(" + label + ")", JsonBuilders.authChallenge(login), t); + assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(" + label + ") must be 200"); + String authNonce = JsonParsers.authNonce(nonceResp); + assertNotNull(authNonce, "authNonce must not be null for " + label); + + // для тестов: sessionKey = deviceKey (в реале будет отдельный keypair) + String sessionPubKeyB64 = TestConfig.devicePublicKeyB64(login); + + // storagePwd на клиенте (сохраняем, чтобы потом проверить, что сервер вернул именно его) + String storagePwd = TestConfig.fakeStoragePwd(); + + // шаг 2: CreateAuthSession (device подпись + sessionPubKey) + String createResp = ws.call( + "CreateAuthSession(" + label + ")", + JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionPubKeyB64), + t + ); + assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession(" + label + ") must be 200"); + + String sid = JsonParsers.sessionId(createResp); + assertNotNull(sid, "sessionId must not be null"); + + r.ok("Создана сессия " + label + ": sessionId=" + sid); + + // для тестов используем devicePriv как sessionPriv + byte[] sessionPrivKey = TestConfig.getDevicePrivatKey(login); + + return new Session(sid, sessionPrivKey, storagePwd); + } + } + + private static void sessionLogin2Steps(WsSession ws, Session s, Duration t, String label, TestResult r) { + // шаг 1: SessionChallenge(sessionId) + String chResp = ws.call("SessionChallenge " + label, JsonBuilders.sessionChallenge(s.sessionId), t); + assertEquals(200, JsonParsers.status(chResp), "SessionChallenge must be 200"); + String nonce = JsonParsers.sessionNonce(chResp); + assertNotNull(nonce, "SessionChallenge nonce must not be null"); + + // шаг 2: SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...)) + String loginResp = ws.call("SessionLogin " + label, JsonBuilders.sessionLogin(s.sessionId, nonce, s.sessionPrivKey), t); + assertEquals(200, JsonParsers.status(loginResp), "SessionLogin must be 200"); + + String storagePwd = JsonParsers.storagePwd(loginResp); + assertNotNull(storagePwd, "storagePwd must not be null after SessionLogin"); + assertEquals(s.storagePwd, storagePwd, "storagePwd must match what client provided on CreateAuthSession"); + + r.ok(label + ": SessionLogin OK, storagePwd verified"); + } + + private record Session(String sessionId, byte[] sessionPrivKey, String storagePwd) {} +} +package test.it.cases; + +import blockchain.MsgSubType; +import blockchain.body.ConnectionBody; +import blockchain.body.CreateChannelBody; +import blockchain.body.HeaderBody; +import blockchain.body.TextBody; +import test.it.blockchain.AddBlockSender; +import test.it.blockchain.ChainState; +import test.it.utils.TestConfig; +import test.it.utils.log.TestLog; +import test.it.utils.log.TestResult; +import test.it.utils.ws.WsSession; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IT_03_AddBlock_NoAuth — сценарий блоков (новый формат + каналы + связи). + * + * CONNECTION (type=3): + * - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber) + * - всегда имеет target: + * toBlockchainName + toBlockGlobalNumber + toBlockHash32 + * + * Правило target для связей/подписок: + * - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0) + * - FOLLOW пользователя -> target = HEADER цели (blockNumber=0) + * - FOLLOW канала -> target = ROOT канала: + * канал "0" -> HEADER (0) + * канал "X" -> CREATE_CHANNEL (blockNumber create_channel) + */ +public class IT_03_AddBlock_NoAuth { + + public static void main(String[] args) { + TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> запускаю IT_01_AddUser"); + System.out.println(IT_01_AddUser.run()); + String summary = run(); + System.out.println(summary); + } + + public static String run() { + TestResult r = new TestResult("IT_03_AddBlock_NoAuth"); + + String u1 = TestConfig.LOGIN(); + String u2 = TestConfig.LOGIN2(); + String u3 = TestConfig.LOGIN3(); + + String bch1 = TestConfig.getBlockchainName(u1); + String bch2 = TestConfig.getBlockchainName(u2); + String bch3 = TestConfig.getBlockchainName(u3); + + Duration t = Duration.ofSeconds(1); + + try (WsSession ws = WsSession.open()) { + + if (TestConfig.DEBUG()) { + TestLog.titleBlock( + "IT_03:\n" + + " USER1=" + u1 + " bch=" + bch1 + "\n" + + " USER2=" + u2 + " bch=" + bch2 + "\n" + + " USER3=" + u3 + " bch=" + bch3 + "\n" + + "\nСценарий: каналы + кросс-чейн reply + connections (follow/friend/contact/uncontact)." + ); + } + + // ========================= + // USER1 + // ========================= + ChainState st1 = new ChainState(); + AddBlockSender sender1 = new AddBlockSender(ws, st1, u1, bch1, TestConfig.getBlockchainPrivatKey(u1)); + + sender1.send(new HeaderBody(u1), t); + assertTrue(st1.hasHeader()); + + int u1HeaderBlock = 0; + byte[] u1HeaderHash = st1.getHash32(u1HeaderBlock); + assertNotNull(u1HeaderHash); + + // канал "0" root = HEADER (0) + int root0 = st1.rootChannel0(); + + // POST в канал "0" + { + var ln = st1.nextTextLineByRoot(root0); + sender1.send(new TextBody( + MsgSubType.TEXT_POST, + root0, + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "U1: story/post in channel 0", + null, null, null + ), t); + } + + int post0Block = st1.lastBlockNumber(); + byte[] post0Hash = st1.getHash32(post0Block); + assertNotNull(post0Hash); + + // CREATE_CHANNEL "News" (TECH line) + int newsRootBlock; + byte[] newsRootHash; + { + var ln = st1.nextLineByType(ChainState.TYPE_TECH); + sender1.send(new CreateChannelBody( + 0, // lineCode TECH + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "News" + ), t); + + newsRootBlock = st1.lastBlockNumber(); // root канала = blockNumber этого CREATE_CHANNEL + newsRootHash = st1.getHash32(newsRootBlock); + assertNotNull(newsRootHash); + + st1.registerTextChannelRoot(newsRootBlock, newsRootHash); + } + + // POST #0 в канал "News" + int newsPost0Block; + byte[] newsPost0Hash; + { + var ln = st1.nextTextLineByRoot(newsRootBlock); + sender1.send(new TextBody( + MsgSubType.TEXT_POST, + newsRootBlock, + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "U1: News post #0", + null, null, null + ), t); + + newsPost0Block = st1.lastBlockNumber(); + newsPost0Hash = st1.getHash32(newsPost0Block); + assertNotNull(newsPost0Hash); + } + + // POST #1 в канал "News" + { + var ln = st1.nextTextLineByRoot(newsRootBlock); + sender1.send(new TextBody( + MsgSubType.TEXT_POST, + newsRootBlock, + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "U1: News post #1", + null, null, null + ), t); + } + + // EDIT_POST (в линии канала) -> target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName) + { + var ln = st1.nextTextLineByRoot(newsRootBlock); + sender1.send(new TextBody( + MsgSubType.TEXT_EDIT_POST, + newsRootBlock, + ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, + "U1: News post #0 (EDIT)", + null, + newsPost0Block, + newsPost0Hash + ), t); + } + + // ========================= + // USER2 + // ========================= + ChainState st2 = new ChainState(); + AddBlockSender sender2 = new AddBlockSender(ws, st2, u2, bch2, TestConfig.getBlockchainPrivatKey(u2)); + + sender2.send(new HeaderBody(u2), t); + assertTrue(st2.hasHeader()); + + int u2HeaderBlock = 0; + byte[] u2HeaderHash = st2.getHash32(u2HeaderBlock); + assertNotNull(u2HeaderHash); + + // ========================= + // СВЯЗИ (CONNECTION) + // ========================= + + // 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2) + sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW, + bch2, u2HeaderBlock, u2HeaderHash, + "U1 follows U2 (target=U2 HEADER)", t); + + // 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1) + sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW, + bch1, newsRootBlock, newsRootHash, + "U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t); + + // 3) FRIEND взаимно (на HEADER) + sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND, + bch2, u2HeaderBlock, u2HeaderHash, + "U1 -> U2: FRIEND", t); + + sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND, + bch1, u1HeaderBlock, u1HeaderHash, + "U2 -> U1: FRIEND", t); + + // 4) CONTACT несколько + sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, + bch2, u2HeaderBlock, u2HeaderHash, + "U1 -> U2: CONTACT", t); + + sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT, + bch1, u1HeaderBlock, u1HeaderHash, + "U2 -> U1: CONTACT", t); + + // ========================= + // USER2 REPLY (ответ в чужой канал) + // ========================= + { + sender2.send(TextBody.newReply( + bch1, + newsPost0Block, + newsPost0Hash, + "U2: reply to U1 News post #0 (cross-chain)" + ), t); + } + + // ========================= + // USER3 + доп. контакт + // ========================= + ChainState st3 = new ChainState(); + AddBlockSender sender3 = new AddBlockSender(ws, st3, u3, bch3, TestConfig.getBlockchainPrivatKey(u3)); + + sender3.send(new HeaderBody(u3), t); + assertTrue(st3.hasHeader()); + + int u3HeaderBlock = 0; + byte[] u3HeaderHash = st3.getHash32(u3HeaderBlock); + assertNotNull(u3HeaderHash); + + // U1 -> U3: CONTACT + sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, + bch3, u3HeaderBlock, u3HeaderHash, + "U1 -> U3: CONTACT", t); + + // 5) U1 убирает U2 из контактов (UNCONTACT) + sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT, + bch2, u2HeaderBlock, u2HeaderHash, + "U1 -> U2: UNCONTACT", t); + + r.ok("IT_03 сценарий блоков + connections выполнен"); + + } catch (Throwable e) { + r.fail("IT_03 упал: " + e.getMessage()); + } + + return r.summaryLine(); + } + + /** + * Отправка 1 блока CONNECTION. + * + * ВАЖНО: ConnectionBody НЕ содержит note в байтах. + * Если нужно “описание” — логируем отдельно. + */ + private static void sendConnection(AddBlockSender sender, + ChainState st, + short subType, + String toBlockchainName, + int toBlockNumber, + byte[] toBlockHash32, + String logNote, + Duration timeout) { + + if (TestConfig.DEBUG()) { + TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF) + + " to=" + toBlockchainName + + " targetBlock=" + toBlockNumber + + " note=" + logNote); + } + + var ln = st.nextLineByType(ChainState.TYPE_CONNECTION); + + // КОНСТРУКТОР ИЗ ТВОЕГО КОДА: + // ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, + // short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) + sender.send(new ConnectionBody( + 0, // lineCode для connection линии + ln.prevLineNumber, + ln.prevLineHash32, + ln.thisLineNumber, + subType, + toBlockchainName, + toBlockNumber, + toBlockHash32 + ), timeout); + } +} +package test.it.cases; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import test.it.utils.*; +import test.it.utils.TestConfig; +import test.it.utils.json.JsonParsers; +import test.it.utils.log.TestLog; +import test.it.utils.log.TestResult; +import test.it.utils.ws.WsSession; +import utils.config.ShineSignatureConstants; +import utils.crypto.Ed25519Util; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IT_04_UserParams_NoAuth + * + * ВАЖНО: + * - пользователей НЕ создаём (их создаёт IT_01) + */ +public class IT_04_UserParams_NoAuth { + + private static final ObjectMapper M = new ObjectMapper(); + + public static void main(String[] args) { + TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> сначала запускаю IT_01_AddUser"); + System.out.println(IT_01_AddUser.run()); + String summary = run(); + System.out.println(summary); + } + + public static String run() { + TestResult r = new TestResult("IT_04_UserParams_NoAuth"); + + Duration timeout = Duration.ofSeconds(5); + + final String login = TestConfig.LOGIN(); + final String deviceKeyB64 = TestConfig.devicePublicKeyB64(login); + final byte[] devicePrivKey = TestConfig.getDevicePrivatKey(login); + + try { + // 1) сохранить param1 + final String p1 = "profile:name"; + final String v1 = "Anna"; + final long t1 = System.currentTimeMillis(); + upsertUserParam_OK(r, login, p1, t1, v1, deviceKeyB64, devicePrivKey, timeout); + + // 2) получить param1 и проверить + NetParam got1 = getUserParam_200(r, login, p1, timeout); + assertEquals(login, got1.login); + assertEquals(p1, got1.param); + assertEquals(t1, got1.timeMs); + assertEquals(v1, got1.value); + assertEquals(deviceKeyB64, got1.deviceKeyB64); + assertNotNull(got1.signatureB64); + assertFalse(got1.signatureB64.isBlank()); + r.ok("GetUserParam(param1) OK"); + + // 3) сохранить param2 + final String p2 = "profile:city"; + final String v2 = "Amsterdam"; + final long t2 = t1 + 10; + upsertUserParam_OK(r, login, p2, t2, v2, deviceKeyB64, devicePrivKey, timeout); + + // 4) обновить param1 + final String v1b = "Anna Updated"; + final long t1b = t2 + 10; + upsertUserParam_OK(r, login, p1, t1b, v1b, deviceKeyB64, devicePrivKey, timeout); + + NetParam got1b = getUserParam_200(r, login, p1, timeout); + assertEquals(t1b, got1b.timeMs); + assertEquals(v1b, got1b.value); + r.ok("GetUserParam(updated param1) OK"); + + // 5) list всех параметров + NetParamList list = listUserParams_200(r, login, timeout); + + NetParam lp1 = list.find(p1); + NetParam lp2 = list.find(p2); + + assertNotNull(lp1, "ListUserParams должен содержать param1=" + p1); + assertNotNull(lp2, "ListUserParams должен содержать param2=" + p2); + + assertEquals(t1b, lp1.timeMs); + assertEquals(v1b, lp1.value); + + assertEquals(t2, lp2.timeMs); + assertEquals(v2, lp2.value); + + assertEquals(deviceKeyB64, lp1.deviceKeyB64); + assertEquals(deviceKeyB64, lp2.deviceKeyB64); + assertNotNull(lp1.signatureB64); + assertNotNull(lp2.signatureB64); + + r.ok("ListUserParams OK"); + + } catch (Throwable e) { + r.fail("IT_04 упал: " + e.getMessage()); + } + + return r.summaryLine(); + } + + // ================================================================================= + // WS helpers: Upsert/Get/List + // ================================================================================= + + private static void upsertUserParam_OK(TestResult r, String login, String param, long timeMs, String value, String deviceKeyB64, byte[] devicePrivKey, Duration timeout) { + String signatureB64 = signUserParam(devicePrivKey, login, param, timeMs, value); + + String reqJson = """ + { + "op": "UpsertUserParam", + "requestId": "%s", + "payload": { + "login": "%s", + "param": "%s", + "time_ms": %d, + "value": "%s", + "device_key": "%s", + "signature": "%s" + } + } + """.formatted(TestIds.next("upsert"), login, param, timeMs, jsonEscape(value), deviceKeyB64, signatureB64); + + try (WsSession ws = WsSession.open()) { + String resp = ws.call("UpsertUserParam(" + param + ")", reqJson, timeout); + assertEquals(200, JsonParsers.status(resp), "UpsertUserParam expected 200, resp=" + resp); + r.ok("UpsertUserParam(" + param + "): OK"); + } + } + + private static NetParam getUserParam_200(TestResult r, String login, String param, Duration timeout) { + String reqJson = """ + { + "op": "GetUserParam", + "requestId": "%s", + "payload": { + "login": "%s", + "param": "%s" + } + } + """.formatted(TestIds.next("getparam"), login, param); + + try (WsSession ws = WsSession.open()) { + String resp = ws.call("GetUserParam(" + param + ")", reqJson, timeout); + assertEquals(200, JsonParsers.status(resp), "GetUserParam expected 200, resp=" + resp); + r.ok("GetUserParam(" + param + "): OK"); + return parseParamFromResponsePayload(resp); + } + } + + private static NetParamList listUserParams_200(TestResult r, String login, Duration timeout) { + String reqJson = """ + { + "op": "ListUserParams", + "requestId": "%s", + "payload": { "login": "%s" } + } + """.formatted(TestIds.next("listparams"), login); + + try (WsSession ws = WsSession.open()) { + String resp = ws.call("ListUserParams", reqJson, timeout); + assertEquals(200, JsonParsers.status(resp), "ListUserParams expected 200, resp=" + resp); + r.ok("ListUserParams: OK"); + return parseParamListFromResponsePayload(resp); + } + } + + // ================================================================================= + // Parsing helpers + // ================================================================================= + + private static NetParam parseParamFromResponsePayload(String respJson) { + try { + JsonNode root = M.readTree(respJson); + JsonNode payload = root.get("payload"); + assertNotNull(payload, "payload is null: " + respJson); + + NetParam p = new NetParam(); + p.login = text(payload, "login"); + p.param = text(payload, "param"); + p.timeMs = longVal(payload, "time_ms"); + p.value = text(payload, "value"); + p.deviceKeyB64 = text(payload, "device_key"); + p.signatureB64 = text(payload, "signature"); + return p; + } catch (Exception e) { + throw new RuntimeException("Failed to parse GetUserParam response: " + respJson, e); + } + } + + private static NetParamList parseParamListFromResponsePayload(String respJson) { + try { + JsonNode root = M.readTree(respJson); + JsonNode payload = root.get("payload"); + assertNotNull(payload, "payload is null: " + respJson); + + NetParamList out = new NetParamList(); + out.login = text(payload, "login"); + + JsonNode arr = payload.get("params"); + assertNotNull(arr, "payload.params is null: " + respJson); + assertTrue(arr.isArray(), "payload.params must be array: " + respJson); + + for (JsonNode it : arr) { + NetParam p = new NetParam(); + p.login = text(it, "login"); + p.param = text(it, "param"); + p.timeMs = longVal(it, "time_ms"); + p.value = text(it, "value"); + p.deviceKeyB64 = text(it, "device_key"); + p.signatureB64 = text(it, "signature"); + out.items = out.itemsAppend(p); + } + return out; + } catch (Exception e) { + throw new RuntimeException("Failed to parse ListUserParams response: " + respJson, e); + } + } + + private static String text(JsonNode obj, String field) { + JsonNode v = obj.get(field); + return (v == null || v.isNull()) ? null : v.asText(); + } + + private static long longVal(JsonNode obj, String field) { + JsonNode v = obj.get(field); + if (v == null || v.isNull()) return 0; + return v.asLong(); + } + + // ================================================================================= + // Signature + JSON helpers + // ================================================================================= + + private static String signUserParam(byte[] devicePrivKey, String login, String param, long timeMs, String value) { + String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX + login + param + timeMs + value; + byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8); + byte[] sig64 = Ed25519Util.sign(signBytes, devicePrivKey); + return Base64.getEncoder().encodeToString(sig64); + } + + private static String jsonEscape(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + // ================================================================================= + // DTOs + // ================================================================================= + + private static final class NetParam { + String login; + String param; + long timeMs; + String value; + String deviceKeyB64; + String signatureB64; + } + + private static final class NetParamList { + String login; + NetParam[] items = new NetParam[0]; + + NetParam[] itemsAppend(NetParam p) { + NetParam[] n = new NetParam[items.length + 1]; + System.arraycopy(items, 0, n, 0, items.length); + n[items.length] = p; + items = n; + return items; + } + + NetParam find(String param) { + for (NetParam p : items) { + if (p != null && param.equals(p.param)) return p; + } + return null; + } + } +} +package test.it.cases; + +import test.it.utils.TestConfig; +import test.it.utils.json.JsonBuilders; +import test.it.utils.json.JsonParsers; +import test.it.utils.log.TestResult; +import test.it.utils.ws.WsSession; + +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.fail; + +/** + * IT_05_UserConnections + * + * Делает пару запросов GetFriendsLists (без проверок существования юзеров — это уже в IT_01). + * + * Ожидаемый формат ответа: + * { + * "op":"GetFriendsLists", + * "requestId":"...", + * "status":200, + * "payload":{ + * "login":"TestUser1", // канонический регистр из БД + * "out_friends":[...], // кому login поставил FRIEND + * "in_friends":[...] // кто поставил FRIEND login + * } + * } + * + * ВАЖНО: + * - login в запросе может быть в любом регистре, + * - но в ответе payload.login должен быть канонический (как в БД). + */ +public class IT_05_UserConnections { + + public static void main(String[] args) { + String summary = run(); + System.out.println(summary); + } + + public static String run() { + TestResult r = new TestResult("IT_05_UserConnections"); + Duration t = Duration.ofSeconds(5); + + final String u1 = TestConfig.LOGIN(); + final String u2 = TestConfig.LOGIN2(); + + try (WsSession ws = WsSession.open()) { + + // 1) Запрос списков связей для u1 (канонический регистр) + r.ok("GetFriendsLists USER1: " + u1); + String resp1 = ws.call("GetFriendsLists#U1", JsonBuilders.getFriendsLists(u1), t); + check200(r, resp1); + checkCanonicalLogin(r, resp1, u1); + checkTwoListsPresent(r, resp1); + + // 2) Запрос списков связей для u1 (смешанный регистр) + String u1mixed = mixCase(u1); + r.ok("GetFriendsLists USER1 mixed-case request: " + u1mixed + " (expect login=" + u1 + ")"); + String resp2 = ws.call("GetFriendsLists#U1_MIX", JsonBuilders.getFriendsLists(u1mixed), t); + check200(r, resp2); + checkCanonicalLogin(r, resp2, u1); + checkTwoListsPresent(r, resp2); + + // 3) Ещё один запрос — для u2 (просто чтобы "пару запросов") + r.ok("GetFriendsLists USER2: " + u2); + String resp3 = ws.call("GetFriendsLists#U2", JsonBuilders.getFriendsLists(u2), t); + check200(r, resp3); + checkCanonicalLogin(r, resp3, u2); + checkTwoListsPresent(r, resp3); + + // лог для наглядности (могут быть пустые, это ок) + List out1 = JsonParsers.friendsOut(resp1); + List in1 = JsonParsers.friendsIn(resp1); + + r.ok("Friends lists USER1: out=" + out1.size() + ", in=" + in1.size()); + + } catch (Throwable e) { + r.fail("IT_05_UserConnections упал: " + e.getMessage()); + } + + return r.summaryLine(); + } + + // ================= checks ================= + + private static void check200(TestResult r, String resp) { + int st = JsonParsers.status(resp); + if (st != 200) { + r.fail("ожидали status=200, получили " + st + ", resp=" + resp); + fail("unexpected status=" + st); + } + } + + private static void checkCanonicalLogin(TestResult r, String resp, String expectedCanonicalLogin) { + String got = JsonParsers.friendsLogin(resp); + if (got == null) { + r.fail("GetFriendsLists: payload.login отсутствует, resp=" + resp); + fail("GetFriendsLists missing payload.login"); + } + if (!expectedCanonicalLogin.equals(got)) { + r.fail("GetFriendsLists: login должен вернуться канонический. expected=" + expectedCanonicalLogin + ", got=" + got + ", resp=" + resp); + fail("GetFriendsLists wrong login case"); + } + } + + private static void checkTwoListsPresent(TestResult r, String resp) { + // В JsonParsers.getPayloadStringArray сейчас возвращает пустой список, даже если поле отсутствует/не массив. + // Поэтому дополнительно проверяем, что парсер вернул НЕ null (он и не должен возвращать null). + List out = JsonParsers.friendsOut(resp); + List in = JsonParsers.friendsIn(resp); + + if (out == null || in == null) { + r.fail("GetFriendsLists: out_friends/in_friends не должны быть null, resp=" + resp); + fail("GetFriendsLists lists are null"); + } + + // Просто отмечаем, что поля читаются, даже если пустые. + r.ok("GetFriendsLists lists present: out=" + out.size() + ", in=" + in.size()); + } + + private static String mixCase(String s) { + if (s == null) return null; + String x = s.trim(); + if (x.length() < 2) return x; + return Character.toUpperCase(x.charAt(0)) + x.substring(1).toLowerCase(); + } +} +package test.it; + +import test.it.runner.IT_RunAllMain; + +import java.util.Objects; + +public class IT_DeployRestartAndRunRemoteMain { + + // ====== НАСТРОЙКИ (можно переопределять systemProperty) ====== + private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "10.147.20.7"); + private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user"); + + private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server"); + private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar"; + private static final String REMOTE_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data"); + + private static final String SERVICE_NAME = System.getProperty("it.service", "shine-server"); + + private static final String LOCAL_JAR = System.getProperty("it.localJar", "build/libs/shine-server.jar"); + + // URI для IT-тестов (переключаем на сервер) + private static final String WS_URI_SERVER = System.getProperty("it.wsUri", "wss://shineup.me/ws"); + + public static void main(String[] args) { + + // 0) Build shadowJar локально +// shStrict("./gradlew -q shadowJar"); + + // 1) stop service на сервере + sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true"); + + // 2) upload jar -> .new + scpStrict(LOCAL_JAR, REMOTE_JAR + ".new"); + + // 3) заменить jar атомарно + sshStrict("mv -f " + q(REMOTE_JAR + ".new") + " " + q(REMOTE_JAR)); + + // 4) удалить data/* + // (на всякий случай: если папки нет — создать) + sshStrict("mkdir -p " + q(REMOTE_DATA) + " && rm -rf " + q(REMOTE_DATA) + "/*"); + + // 5) start service + sshStrict("sudo systemctl start " + SERVICE_NAME); + + // 6) дождаться поднятия (простая проверка: порт слушается) + waitRemotePort7070(); + + // 7) переключаем IT на серверный WS URI (без правок исходников) + System.setProperty("it.wsUri", WS_URI_SERVER); + + // 8) прогон тестов + int failed = IT_RunAllMain.runAll(); + System.exit(failed); + } + + private static void waitRemotePort7070() { + for (int i = 0; i < 50; i++) { + int code = ssh("ss -ltnp | grep -q ':7070'"); // 0 если найдено + if (code == 0) return; + sleepMs(200); + } + throw new RuntimeException("Remote port 7070 did not start in time on " + REMOTE_HOST); + } + + // ---------- helpers ---------- + private static void shStrict(String cmd) { + int code = sh(cmd); + if (code != 0) throw new RuntimeException("Command failed (" + code + "): " + cmd); + } + + private static void sshStrict(String remoteCmd) { + int code = ssh(remoteCmd); + if (code != 0) throw new RuntimeException("SSH command failed (" + code + "): " + remoteCmd); + } + + private static int ssh(String remoteCmd) { + String cmd = "ssh " + REMOTE_USER + "@" + REMOTE_HOST + " " + q("bash -lc " + q(remoteCmd)); + return sh(cmd); + } + + private static void scpStrict(String local, String remote) { + Objects.requireNonNull(local); + Objects.requireNonNull(remote); + int code = sh("scp -p " + q(local) + " " + REMOTE_USER + "@" + REMOTE_HOST + ":" + q(remote)); + if (code != 0) throw new RuntimeException("SCP failed (" + code + ")"); + } + + private static int sh(String cmd) { + try { + Process p = new ProcessBuilder("bash", "-lc", cmd).inheritIO().start(); + return p.waitFor(); + } catch (Exception e) { + throw new RuntimeException("Command error: " + cmd, e); + } + } + + private static String q(String s) { + // простая одинарная кавычка для bash + return "'" + s.replace("'", "'\"'\"'") + "'"; + } + + private static void sleepMs(long ms) { + try { Thread.sleep(ms); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } +} +package test.it; + +import server.ws.WsServer; +import test.it.runner.IT_CleanAllDate; +import test.it.runner.IT_RunAllMain; + +public class IT_RunAllCleanStartWsMain { + + public static void main(String[] args) { + runBash("kill -9 $(lsof -t -i:7070) 2>/dev/null || true"); + + IT_CleanAllDate.main(new String[0]); + + Thread wsThread = new Thread(() -> { + try { + WsServer.main(new String[0]); + } catch (Throwable t) { + t.printStackTrace(System.out); + } + }, "wsServer-thread"); + wsThread.setDaemon(true); + wsThread.start(); + + sleepMs(1000); + + int failed = IT_RunAllMain.runAll(); + System.exit(failed); + } + + private static void runBash(String cmd) { + try { + Process p = new ProcessBuilder("bash", "-lc", cmd).inheritIO().start(); + p.waitFor(); + } catch (Exception e) { + System.out.println("WARN: bash command failed: " + e); + } + } + + private static void sleepMs(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } +} +package test.it.runner; + +import test.it.utils.TestConfig; +import test.it.utils.log.TestLog; + +import java.io.IOException; +import java.nio.file.*; +import java.util.Comparator; + +/** + * + * Делает: + * 1) чистит папку data/ + */ +public class IT_CleanAllDate { + + private static final String DATA_DIR = "data"; + + public static void main(String[] args) { +// ItRunContext.initIfNeeded(); + + TestLog.title("IT RUN CLEAN: очистка data/ + запуск всех тестов"); + + try { + cleanupDataDir(DATA_DIR); + } catch (Throwable t) { + TestLog.boom("Не смог очистить data/. Причина: " + t.getMessage()); + if (TestConfig.DEBUG()) t.printStackTrace(System.out); + System.exit(1); + } + + } + + private static void cleanupDataDir(String dirName) throws IOException { + Path dir = Paths.get(dirName); + + if (!Files.exists(dir)) { + TestLog.warn("data dir not found: " + dir.toAbsolutePath() + " (создаю)"); + Files.createDirectories(dir); + return; + } + + // удаляем ВСЁ внутри папки, но саму папку оставляем + Files.walk(dir) + .sorted(Comparator.reverseOrder()) + .filter(p -> !p.equals(dir)) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + throw new RuntimeException("Не смог удалить: " + p.toAbsolutePath(), e); + } + }); + + TestLog.ok("data очищена: " + dir.toAbsolutePath()); + } +} +package test.it.runner; + +import test.it.cases.IT_01_AddUser; +import test.it.cases.IT_02_Sessions; +import test.it.cases.IT_03_AddBlock_NoAuth; +import test.it.cases.IT_04_UserParams_NoAuth; +import test.it.cases.IT_05_UserConnections; +import test.it.utils.log.TestLog; + +import java.util.ArrayList; +import java.util.List; + +/** + * Ручной запуск всех IT тестов БЕЗ JUnit. + * Печатает итоги по каждому тесту отдельной строкой. + */ +public class IT_RunAllMain { + + /** + * Настройка поведения прогона: + * - true : остановить запуск сразу после первого упавшего теста + * - false : прогнать все тесты до конца, даже если некоторые упали + */ + private static final boolean STOP_ON_FIRST_FAIL = true; + + public static void main(String[] args) { + int failed = runAll(); + // при желании можно вернуть код выхода ОС: + // System.exit(failed == 0 ? 0 : 1); + } + + public static int runAll() { + + List summaries = new ArrayList<>(); + int failed = 0; + + TestLog.title("IT RUN: запуск всех тестов подряд" + + (STOP_ON_FIRST_FAIL ? " (STOP_ON_FIRST_FAIL=ON)" : " (STOP_ON_FIRST_FAIL=OFF)")); + + String s1 = IT_01_AddUser.run(); summaries.add(s1); + if (s1.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + + String s2 = IT_02_Sessions.run(); summaries.add(s2); + if (s2.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + + String s3 = IT_03_AddBlock_NoAuth.run(); summaries.add(s3); + if (s3.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + + String s4 = IT_04_UserParams_NoAuth.run(); summaries.add(s4); + if (s4.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + + String s5 = IT_05_UserConnections.run(); summaries.add(s5); + if (s5.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + + return finish(summaries, failed); + } + + private static int finishEarly(List summaries, int failed) { + TestLog.boom("⛔ Остановка прогона: найден FAIL, STOP_ON_FIRST_FAIL=ON"); + return finish(summaries, failed); + } + + private static int finish(List summaries, int failed) { + TestLog.title("IT RUN RESULT (per test)"); + for (String s : summaries) System.out.println(s); + + if (failed == 0) TestLog.ok("\n ВСЕ IT ТЕСТЫ УСПЕШНО ЗАВЕРШЕНЫ"); + else TestLog.boom("❌ IT ПРОГОН УПАЛ: failed=" + failed + " из " + summaries.size()); + + return failed; + } +} +package test.it.suite; + +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; +import test.it.cases.IT_01_AddUser; +import test.it.cases.IT_02_Sessions; +import test.it.cases.IT_03_AddBlock_NoAuth; + +/** + * Сьют, который запускает IT тесты строго в заданном порядке. + * + * Запуск: + * ./gradlew test --tests test.it.suite.IT_00_Suite + */ +@Suite +@SelectClasses({ + IT_01_AddUser.class, + IT_02_Sessions.class, + IT_03_AddBlock_NoAuth.class +}) +public class IT_00_Suite { + // пусто +} +package test.it.utils.json; + +import test.it.utils.TestIds; +import test.it.utils.TestConfig; +import utils.crypto.Ed25519Util; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** Builder'ы JSON запросов. Внутри автоматически генерим requestId. */ +public final class JsonBuilders { + private JsonBuilders() {} + + // ---------------- AddUser ---------------- + + public static String addUser(String login) { + String requestId = TestIds.next("adduser"); + String blockchainName = TestConfig.getBlockchainName(login); + + String solanaKeyB64 = TestConfig.solanaPublicKeyB64(login); + String blockchainKeyB64 = TestConfig.blockchainPublicKeyB64(login); + String deviceKeyB64 = TestConfig.devicePublicKeyB64(login); + + return """ + { + "op": "AddUser", + "requestId": "%s", + "payload": { + "login": "%s", + "blockchainName": "%s", + "solanaKey": "%s", + "blockchainKey": "%s", + "deviceKey": "%s", + "bchLimit": %d + } + } + """.formatted( + requestId, + login, + blockchainName, + solanaKeyB64, + blockchainKeyB64, + deviceKeyB64, + TestConfig.TEST_BCH_LIMIT + ); + } + + // ---------------- GetUser ---------------- + + public static String getUser(String login) { + String requestId = TestIds.next("getuser"); + return """ + { + "op": "GetUser", + "requestId": "%s", + "payload": { + "login": "%s" + } + } + """.formatted(requestId, login); + } + + // ---------------- SearchUsers ---------------- + + public static String searchUsers(String prefix) { + String requestId = TestIds.next("searchusers"); + return """ + { + "op": "SearchUsers", + "requestId": "%s", + "payload": { + "prefix": "%s" + } + } + """.formatted(requestId, prefix); + } + + // ---------------- GetFriendsLists ---------------- + + public static String getFriendsLists(String login) { + String requestId = TestIds.next("friends"); + return """ + { + "op": "GetFriendsLists", + "requestId": "%s", + "payload": { + "login": "%s" + } + } + """.formatted(requestId, login); + } + + // ---------------- AuthChallenge ---------------- + + public static String authChallenge(String login) { + String requestId = TestIds.next("auth"); + return """ + { + "op": "AuthChallenge", + "requestId": "%s", + "payload": { "login": "%s" } + } + """.formatted(requestId, login); + } + + // ---------------- CreateAuthSession (v2) ---------------- + // v2: sessionKey генерируется/хранится на клиенте, на сервер отправляем sessionPubKeyB64 (base64). + // + // ВАЖНО (новое правило): + // Подпись CreateAuthSession делается ТОЛЬКО deviceKey над строкой: + // preimage = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce + // + // storagePwd и sessionPubKeyB64 НЕ входят в preimage. + + public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionPubKeyB64) { + long timeMs = System.currentTimeMillis(); + + // подпись делаем devicePrivKey + byte[] devicePriv = TestConfig.getDevicePrivatKey(login); + String sigB64 = signAuthCreateSession(login, timeMs, authNonce, devicePriv); + + String requestId = TestIds.next("create"); + return """ + { + "op": "CreateAuthSession", + "requestId": "%s", + "payload": { + "storagePwd": "%s", + "sessionPubKeyB64": "%s", + "timeMs": %d, + "signatureB64": "%s", + "clientInfo": "%s" + } + } + """.formatted( + requestId, + storagePwd, + sessionPubKeyB64, + timeMs, + sigB64, + TestConfig.TEST_CLIENT_INFO + ); + } + + // ---------------- SessionChallenge (v2) ---------------- + + public static String sessionChallenge(String sessionId) { + String requestId = TestIds.next("sch"); + return """ + { + "op": "SessionChallenge", + "requestId": "%s", + "payload": { + "sessionId": "%s" + } + } + """.formatted(requestId, sessionId); + } + + // ---------------- SessionLogin (v2) ---------------- + // Подпись SessionLogin по-прежнему делается sessionPrivKey: + // preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce + + public static String sessionLogin(String sessionId, String nonce, byte[] sessionPrivKey) { + long timeMs = System.currentTimeMillis(); + String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey); + + String requestId = TestIds.next("slogin"); + return """ + { + "op": "SessionLogin", + "requestId": "%s", + "payload": { + "sessionId": "%s", + "timeMs": %d, + "signatureB64": "%s", + "clientInfo": "%s" + } + } + """.formatted(requestId, sessionId, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); + } + + // ---------------- ListSessions ---------------- + + public static String listSessions(long timeMs, String signatureB64) { + String requestId = TestIds.next("list"); + if (signatureB64 == null) signatureB64 = ""; + return """ + { + "op": "ListSessions", + "requestId": "%s", + "payload": { + } + } + """.formatted(requestId, timeMs, signatureB64); + } + + // ---------------- CloseActiveSession ---------------- + + public static String closeActiveSession(String sessionId, long timeMs, String signatureB64) { + String requestId = TestIds.next("close"); + if (signatureB64 == null) signatureB64 = ""; + return """ + { + "op": "CloseActiveSession", + "requestId": "%s", + "payload": { + "sessionId": "%s" + } + } + """.formatted(requestId, sessionId, timeMs, signatureB64); + } + + // ---------------- ListSubscribedChannels ---------------- + + public static String listSubscribedChannels(String login) { + String requestId = TestIds.next("subs"); + return """ + { + "op": "ListSubscribedChannels", + "requestId": "%s", + "payload": { "login": "%s" } + } + """.formatted(requestId, login); + } + + /** + * Подпись CreateAuthSession(v2): + * preimage = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce + * подписываем devicePrivKey. + */ + public static String signAuthCreateSession(String login, long timeMs, String authNonce, byte[] devicePrivKey) { + String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; + byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); + byte[] sig = Ed25519Util.sign(preimage, devicePrivKey); + return Base64.getEncoder().encodeToString(sig); + } + + /** + * Подпись для SessionLogin(v2): + * preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce + * подписываем sessionPrivKey. + */ + public static String signSessionLogin(String sessionId, long timeMs, String nonce, byte[] sessionPrivKey) { + String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; + byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); + byte[] sig = Ed25519Util.sign(preimage, sessionPrivKey); + return Base64.getEncoder().encodeToString(sig); + } +} +package test.it.utils.json; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.List; + +public final class JsonParsers { + private JsonParsers(){} + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public static int status(String json) { + try { + JsonNode root = MAPPER.readTree(json); + return root.has("status") ? root.get("status").asInt() : -1; + } catch (Exception e) { + return -1; + } + } + + public static String authNonce(String json) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has("authNonce")) return payload.get("authNonce").asText(); + return null; + } catch (Exception e) { + return null; + } + } + + /** nonce из SessionChallenge(v2) */ + public static String sessionNonce(String json) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has("nonce")) return payload.get("nonce").asText(); + return null; + } catch (Exception e) { + return null; + } + } + + public static String sessionId(String json) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has("sessionId")) return payload.get("sessionId").asText(); + return null; + } catch (Exception e) { + return null; + } + } + + // оставляю для совместимости с другими тестами, но в IT_02(v2) больше не используется + public static String sessionPwd(String json) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has("sessionPwd")) return payload.get("sessionPwd").asText(); + return null; + } catch (Exception e) { + return null; + } + } + + public static String storagePwd(String json) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has("storagePwd")) return payload.get("storagePwd").asText(); + return null; + } catch (Exception e) { + return null; + } + } + + public static List sessionIds(String json) { + List res = new ArrayList<>(); + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload == null) return res; + JsonNode arr = payload.get("sessions"); + if (arr == null || !arr.isArray()) return res; + + for (JsonNode s : arr) { + JsonNode id = s.get("sessionId"); + if (id != null && !id.isNull()) res.add(id.asText()); + } + } catch (Exception ignored) {} + return res; + } + + public static String errorCode(String json) { + try { + JsonNode root = MAPPER.readTree(json); + + // поддержка старого формата (верхний уровень) + if (root.has("errorCode")) return root.get("errorCode").asText(); + // поддержка нового формата (верхний уровень) + if (root.has("code")) return root.get("code").asText(); + + JsonNode payload = root.get("payload"); + if (payload != null) { + // поддержка старого формата (внутри payload) + if (payload.has("errorCode")) return payload.get("errorCode").asText(); + // поддержка нового формата (внутри payload) + if (payload.has("code")) return payload.get("code").asText(); + } + } catch (Exception ignored) {} + + return null; + } + + // ---------------- GetUser helpers ---------------- + + public static Boolean exists(String json) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has("exists")) return payload.get("exists").asBoolean(); + return null; + } catch (Exception e) { + return null; + } + } + + public static String userLogin(String json) { + return getPayloadText(json, "login"); + } + + public static String userBlockchainName(String json) { + return getPayloadText(json, "blockchainName"); + } + + public static String userSolanaKey(String json) { + return getPayloadText(json, "solanaKey"); + } + + public static String userBlockchainKey(String json) { + return getPayloadText(json, "blockchainKey"); + } + + public static String userDeviceKey(String json) { + return getPayloadText(json, "deviceKey"); + } + + // ---------------- SearchUsers helpers ---------------- + + public static List searchLogins(String json) { + List res = new ArrayList<>(); + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload == null) return res; + + JsonNode arr = payload.get("logins"); + if (arr == null || !arr.isArray()) return res; + + for (JsonNode x : arr) { + if (x != null && !x.isNull()) res.add(x.asText()); + } + } catch (Exception ignored) {} + return res; + } + + // ---------------- Friends helpers ---------------- + + /** payload.login (канонический) */ + public static String friendsLogin(String json) { + return getPayloadText(json, "login"); + } + + public static List friendsOut(String json) { + return getPayloadStringArray(json, "out_friends"); + } + + public static List friendsIn(String json) { + return getPayloadStringArray(json, "in_friends"); + } + + public static List friendsMutual(String json) { + return getPayloadStringArray(json, "mutual_friends"); + } + + private static List getPayloadStringArray(String json, String field) { + List res = new ArrayList<>(); + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload == null) return res; + + JsonNode arr = payload.get(field); + if (arr == null || !arr.isArray()) return res; + + for (JsonNode x : arr) { + if (x != null && !x.isNull()) res.add(x.asText()); + } + } catch (Exception ignored) {} + return res; + } + + private static String getPayloadText(String json, String field) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has(field) && !payload.get(field).isNull()) { + return payload.get(field).asText(); + } + return null; + } catch (Exception e) { + return null; + } + } +} +package test.it.utils.log; + +import test.it.utils.TestConfig; + +/** + * TestLog — единое место для: + * - ANSI цветов + * - стандартных сообщений (title/step/send/recv) + * - PASS/FAIL строк и окраски + * + * Режим: + * - it.debug=false: печатаем минимум (без JSON) + * - it.debug=true: печатаем JSON отправка/ответ + заголовки шагов + */ +public final class TestLog { + private TestLog() {} + + public static final boolean DEBUG = TestConfig.DEBUG(); + + // ANSI COLORS (ТОЛЬКО ТУТ) + public static final String R = "\u001B[0m"; + public static final String G = "\u001B[32m"; + public static final String Y = "\u001B[33m"; + public static final String RED = "\u001B[31m"; + public static final String C = "\u001B[36m"; + + public static String green(String s) { return G + s + R; } + public static String red(String s) { return RED + s + R; } + public static String cyan(String s) { return C + s + R; } + + /** Инфо (печатается только при DEBUG=true). */ + public static void info(String s) { + if (DEBUG) System.out.println(s); + } + + public static void line() { + if (!DEBUG) return; + System.out.println(C + "------------------------------------------------------------" + R); + } + + public static void title(String s) { + if (!DEBUG) return; + System.out.println(C + "\n============================================================" + R); + System.out.println(C + s + R); + System.out.println(C + "============================================================\n" + R); + } + + public static void titleBlock(String multiLineText) { + if (!DEBUG) return; + System.out.println(C + "\n============================================================" + R); + System.out.println(C + multiLineText + R); + System.out.println(C + "============================================================\n" + R); + } + + public static void stepTitle(String s) { + if (!DEBUG) return; + System.out.println(C + "\n-------------------- " + s + " --------------------" + R); + } + + /** OK (печатаем ВСЕГДА, чтобы было видно зелёное прохождение шагов). */ + public static void ok(String s) { + System.out.println(G + "✅ " + s + R); + } + + /** WARN (только DEBUG). */ + public static void warn(String s) { + if (!DEBUG) return; + System.out.println(Y + "⚠️ " + s + R); + } + + /** FAIL (печатаем ВСЕГДА). */ + public static void boom(String s) { + System.out.println(RED + "****************************************************************" + R); + System.out.println(RED + "❌ " + s + R); + System.out.println(RED + "****************************************************************" + R); + } + + public static void send(String op, String json) { + if (!DEBUG) return; + System.out.println("📤 [" + op + "] Request JSON:"); + System.out.println(json); + line(); + } + + public static void recv(String op, String json) { + if (!DEBUG) return; + System.out.println("📥 [" + op + "] Response JSON:"); + System.out.println(json); + line(); + } +} +package test.it.utils.log; + +import java.util.ArrayList; +import java.util.List; + +/** + * TestResult — накопитель результатов внутри одного теста: + * - ok(...) печатает зелёным + * - fail(...) печатает красным и добавляет в итоговую строку + * - summaryLine() возвращает одну строку: PASS/FAIL + детали + */ +public final class TestResult { + + private final String testName; + private final List errors = new ArrayList<>(); + + public TestResult(String testName) { + this.testName = testName; + } + + public void ok(String msg) { + TestLog.ok(msg); + } + + public void fail(String msg) { + errors.add(msg); + TestLog.boom(msg); + } + + public boolean isOk() { + return errors.isEmpty(); + } + + public String summaryLine() { + if (errors.isEmpty()) { + return TestLog.green("PASS: " + testName + " — OK"); + } + StringBuilder sb = new StringBuilder(); + sb.append(TestLog.red("FAIL: ")).append(testName).append(" — ").append(errors.size()).append(" ошибок: "); + for (int i = 0; i < errors.size(); i++) { + if (i > 0) sb.append(" | "); + sb.append(errors.get(i)); + } + return sb.toString(); + } +} +package test.it.utils; + +import utils.crypto.Ed25519Util; + +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * TestConfig — конфиг IT тестов: + * - 3 пользователя (TestUser1/2/3) + * - ключи по login через map (device/solana/blockchain/session) + * - blockchainName = login + "-" + "001" + * + * Важно: + * - privateKey = Ed25519Util.generatePrivateKeyFromString(seed) (sha256(seed) => 32 bytes) + * - publicKey = Ed25519Util.derivePublicKey(privateKey) + * - device/solana/blockchain ключи пока одинаковые (seed = login) + * - session ключ отдельный (seed = "session:" + login) — чтобы SessionLogin был честнее. + */ +public final class TestConfig { + + private TestConfig() {} + + public static final String WS_URI_LOCAL = "ws://localhost:7070/ws"; + public static final String WS_URI_Server = "wss://shineup.me/ws"; + + // по умолчанию LOCAL, но можно переопределить: -Dit.wsUri=... + public static final String WS_URI = System.getProperty("it.wsUri", WS_URI_LOCAL); + + public static final long TEST_BCH_LIMIT = 50_000_000L; + public static final String TEST_CLIENT_INFO = "it-tests"; + + public static boolean DEBUG() { + return Boolean.parseBoolean(System.getProperty("it.debug", "true")); + } + + // 3 users + public static final String DEFAULT_LOGIN1 = "TestUser1"; + public static final String DEFAULT_LOGIN2 = "TestUser2"; + public static final String DEFAULT_LOGIN3 = "TestUser3"; + public static final String DEFAULT_BCH_SUFFIX_3 = "001"; + + public static String LOGIN() { return System.getProperty("it.login1", DEFAULT_LOGIN1); } + public static String LOGIN2() { return System.getProperty("it.login2", DEFAULT_LOGIN2); } + public static String LOGIN3() { return System.getProperty("it.login3", DEFAULT_LOGIN3); } + + public static String BCH_SUFFIX_3() { + return System.getProperty("it.bchSuffix", DEFAULT_BCH_SUFFIX_3); + } + + public static String getBlockchainName(String login) { + if (login == null) throw new IllegalArgumentException("login is null"); + return login + "-" + BCH_SUFFIX_3(); + } + + // ============ key maps ============ + private static final Map devicePriv = new ConcurrentHashMap<>(); + private static final Map devicePub = new ConcurrentHashMap<>(); + + private static final Map solanaPriv = new ConcurrentHashMap<>(); + private static final Map solanaPub = new ConcurrentHashMap<>(); + + private static final Map bchPriv = new ConcurrentHashMap<>(); + private static final Map bchPub = new ConcurrentHashMap<>(); + + // NEW: session keys (для SessionLogin v2) + private static final Map sessionPriv = new ConcurrentHashMap<>(); + private static final Map sessionPub = new ConcurrentHashMap<>(); + + static { + initUserKeys(LOGIN()); + initUserKeys(LOGIN2()); + initUserKeys(LOGIN3()); + } + + private static void initUserKeys(String login) { + // seed = login + byte[] priv = Ed25519Util.generatePrivateKeyFromString(login); // sha256(login) => 32 bytes + byte[] pub = Ed25519Util.derivePublicKey(priv); + + // пока одинаковые + devicePriv.put(login, priv); + devicePub.put(login, pub); + + solanaPriv.put(login, priv); + solanaPub.put(login, pub); + + bchPriv.put(login, priv); + bchPub.put(login, pub); + + // session seed = "session:" + login (отдельно!) + byte[] sPriv = Ed25519Util.generatePrivateKeyFromString("session:" + login); + byte[] sPub = Ed25519Util.derivePublicKey(sPriv); + + sessionPriv.put(login, sPriv); + sessionPub.put(login, sPub); + } + + // ============ requested getters (with your names) ============ + + public static byte[] getDevicePrivatKey(String login) { return cloneOrThrow(devicePriv.get(login), "devicePriv", login); } + public static byte[] getDevicePublicKey(String login) { return cloneOrThrow(devicePub.get(login), "devicePub", login); } + + public static byte[] getSolanaPrivatKey(String login) { return cloneOrThrow(solanaPriv.get(login), "solanaPriv", login); } + public static byte[] getSolanaPublicKey(String login) { return cloneOrThrow(solanaPub.get(login), "solanaPub", login); } + + public static byte[] getBlockchainPrivatKey(String login) { return cloneOrThrow(bchPriv.get(login), "bchPriv", login); } + public static byte[] getBlockchainPublicKey(String login) { return cloneOrThrow(bchPub.get(login), "bchPub", login); } + + // NEW: session getters + public static byte[] getSessionPrivatKey(String login) { return cloneOrThrow(sessionPriv.get(login), "sessionPriv", login); } + public static byte[] getSessionPublicKey(String login) { return cloneOrThrow(sessionPub.get(login), "sessionPub", login); } + + // ============ base64 helpers ============ + public static String devicePublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getDevicePublicKey(login)); } + public static String solanaPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getSolanaPublicKey(login)); } + public static String blockchainPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getBlockchainPublicKey(login)); } + + // NEW: session pub b64 helper + public static String sessionPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getSessionPublicKey(login)); } + + // ============ backward-compatible helpers for "user1" ============ + public static String BCH_NAME() { return getBlockchainName(LOGIN()); } + public static String BCH_NAME2() { return getBlockchainName(LOGIN2()); } + public static String BCH_NAME3() { return getBlockchainName(LOGIN3()); } + + /** solanaKey для AddUser: публичный ключ Solana-пользователя */ + public static String SOLANA_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN()); } + public static String SOLANA2_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN2()); } + public static String SOLANA3_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN3()); } + + /** blockchainKey для AddUser: публичный ключ блокчейна */ + public static String BLOCKCHAIN_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN()); } + public static String BLOCKCHAIN2_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN2()); } + public static String BLOCKCHAIN3_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN3()); } + + public static String DEVICE_PUBKEY_B64() { return devicePublicKeyB64(LOGIN()); } + public static String DEVICE2_PUBKEY_B64() { return devicePublicKeyB64(LOGIN2()); } + public static String DEVICE3_PUBKEY_B64() { return devicePublicKeyB64(LOGIN3()); } + + // NEW: session pub b64 compat + public static String SESSION_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN()); } + public static String SESSION2_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN2()); } + public static String SESSION3_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN3()); } + + // ============ misc ============ + public static String fakeStoragePwd() { + return "pwd-" + System.nanoTime(); + } + + private static byte[] cloneOrThrow(byte[] v, String mapName, String login) { + if (login == null) throw new IllegalArgumentException("login is null"); + if (v == null) throw new IllegalStateException("No key in " + mapName + " for login=" + login); + return v.clone(); + } +} +package test.it.utils; + +import java.util.concurrent.atomic.AtomicLong; + +/** Генератор уникальных requestId для IT тестов (в пределах одной JVM). */ +public final class TestIds { + private static final AtomicLong SEQ = new AtomicLong(0); + + private TestIds() {} + + public static String next(String prefix) { + long n = SEQ.incrementAndGet(); + return "it-" + (prefix == null ? "req" : prefix) + "-" + n; + } +} +package test.it.utils.ws; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import test.it.utils.TestConfig; +import test.it.utils.log.TestLog; + +import java.time.Duration; + +/** + * WsSession — одно WS соединение на много запросов. + * + * Использование в тесте: + * try (WsSession ws = WsSession.open()) { + * String resp = ws.call("AuthChallenge", JsonBuilders.authChallenge(login), t); + * } + */ +public final class WsSession implements AutoCloseable { + + private static final ObjectMapper M = new ObjectMapper(); + + private final WsTestClient client; + + private WsSession(WsTestClient client) { + this.client = client; + } + + public static WsSession open() { + return new WsSession(new WsTestClient(TestConfig.WS_URI)); + } + + /** Отправить JSON (в котором уже есть requestId) и получить JSON ответ строкой. */ + public String call(String op, String requestJson, Duration timeout) { + String requestId = extractRequestId(requestJson); + if (requestId == null || requestId.isBlank()) throw new IllegalArgumentException("requestJson must contain requestId: " + requestJson); + + if (TestConfig.DEBUG()) TestLog.send(op, requestJson); + + String resp = client.request(requestId, requestJson, timeout); + + if (TestConfig.DEBUG()) TestLog.recv(op, resp); + + return resp; + } + + private static String extractRequestId(String json) { + try { + JsonNode root = M.readTree(json); + JsonNode id = root.get("requestId"); + return (id == null || id.isNull()) ? null : id.asText(); + } catch (Exception e) { + return null; + } + } + + @Override + public void close() { + client.close(); + } +} +package test.it.utils.ws; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.*; + +public final class WsTestClient implements AutoCloseable { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final WebSocket ws; + private final Map> pending = new ConcurrentHashMap<>(); + + public WsTestClient(String wsUri) { + HttpClient client = HttpClient.newHttpClient(); + this.ws = client.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .buildAsync(URI.create(wsUri), new WebSocket.Listener() { + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + String msg = data.toString(); + String requestId = extractRequestId(msg); + if (requestId != null) { + CompletableFuture f = pending.remove(requestId); + if (f != null) f.complete(msg); + } + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + // Завалим все ожидания, чтобы тест корректно упал + pending.forEach((k, f) -> f.completeExceptionally(error)); + pending.clear(); + } + }).join(); + + this.ws.request(1); + } + + public String request(String requestId, String json, Duration timeout) { + CompletableFuture fut = new CompletableFuture<>(); + pending.put(requestId, fut); + ws.sendText(json, true); + try { + return fut.get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (Exception e) { + pending.remove(requestId); + throw new RuntimeException("Timeout/Fail waiting response requestId=" + requestId, e); + } + } + + private static String extractRequestId(String json) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode id = root.get("requestId"); + return id != null && !id.isNull() ? id.asText() : null; + } catch (Exception ignored) { + return null; + } + } + + @Override + public void close() { + try { + ws.sendClose(WebSocket.NORMAL_CLOSURE, "bye").join(); + } catch (Exception ignored) {} + } +}