diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 2d5c9f7..0faa797 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -2,7 +2,5 @@
-
-
-
+
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 8bb574b..f67ac8f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,14 +83,26 @@
## Deploy
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
-- Базовый целевой хост для деплоя по умолчанию: `player@93.170.12.154` (`shineup.me`).
+- Production-хост SHiNE: `player@shineup.me` (`185.229.109.118`).
+- Основной test-хост SHiNE: `player@193.8.215.70` (`test2.shineup.me`).
+- Резервный test-хост SHiNE: `player@93.170.12.154` (`test.shineup.me`).
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
-- Для серверного деплоя использовать один gradle task: `./gradlew deployServer`.
-- Для UI деплоя использовать один gradle task: `./gradlew deployUI`.
+- Любые изменения и любой деплой на production `shineup.me` выполнять только после отдельного явного подтверждения пользователя.
+- Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `test2.shineup.me`.
+- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`.
+- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`.
+- Production server deploy: `./gradlew deployServerProduction`.
+- Production UI deploy: `./gradlew deployUIProduction`.
+- Резервный test deploy на `test.shineup.me`: `./gradlew deployServerTest` и `./gradlew deployUITest`, но пока их не использовать без отдельной причины.
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`).
- Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя.
+- Для временной бесплатной загрузки аватаров в Arweave секретный JWK нельзя хранить в git и нельзя прописывать в репозиторный `application.properties`.
+- Для продовой настройки тестового Arweave-кошелька JWK-файл нужно хранить только на сервере, например: `/home/player/SHiNE/secrets/test-free-avatar-wallet.json`.
+- Для этой временной фичи на проде должны быть заданы параметры `test.freeAvatar.walletJwkPath` и `test.freeAvatar.walletAddress` через серверный override-конфиг/секреты на хосте.
+- После изменения продовых значений `test.freeAvatar.*` нужно заново выполнить серверный деплой или перезапуск сервера, чтобы настройки были перечитаны приложением.
+- При таких изменениях в git допускается коммитить только документацию и код чтения настроек, но не сам JWK, не содержимое секрета и не реальные приватные ключи.
## Логи звонков (установка соединения)
- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер).
diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md
index 95fa4d7..0b4e044 100644
--- a/Dev_Docs/API/09_Operations_Index.md
+++ b/Dev_Docs/API/09_Operations_Index.md
@@ -15,6 +15,8 @@
| `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) |
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна |
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
+| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара |
+| `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave |
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
diff --git a/Dev_Docs/API/14_Test_Free_Avatar_Upload_API.md b/Dev_Docs/API/14_Test_Free_Avatar_Upload_API.md
new file mode 100644
index 0000000..29438ff
--- /dev/null
+++ b/Dev_Docs/API/14_Test_Free_Avatar_Upload_API.md
@@ -0,0 +1,176 @@
+# Временное Test API для бесплатной загрузки аватаров в Arweave
+
+> Статус: **временное тестовое решение**.
+> Все операции из этого файла начинаются с `Test...`, чтобы это было видно сразу и в коде, и в UI.
+
+## Назначение
+
+Этот временный API даёт пользователю ограниченную бесплатную загрузку маленьких аватаров в Arweave:
+
+- загрузка идёт через **серверный Arweave-кошелёк**;
+- лимит на пользователя: по умолчанию `3` загрузки за всё время;
+- лимит хранится в SQLite-таблице `test_free_avatar_uploads`;
+- если лимит исчерпан, сервер возвращает понятную ошибку;
+- загружать можно только маленький итоговый файл аватара, по умолчанию до `128 KB`.
+
+## Настройки сервера
+
+В `application.properties`:
+
+```properties
+test.freeAvatar.enabled=true
+test.freeAvatar.gateway=https://arweave.net
+test.freeAvatar.limitPerUser=3
+test.freeAvatar.maxBytes=131072
+test.freeAvatar.walletAddress=
+test.freeAvatar.walletJwkPath=
+```
+
+Пояснения:
+
+- `test.freeAvatar.enabled` - включить или выключить временный API;
+- `test.freeAvatar.gateway` - Arweave gateway для `price/tx/wallet`;
+- `test.freeAvatar.limitPerUser` - пожизненный бесплатный лимит на пользователя;
+- `test.freeAvatar.maxBytes` - максимальный размер итогового файла;
+- `test.freeAvatar.walletAddress` - публичный адрес серверного Arweave-кошелька;
+- `test.freeAvatar.walletJwkPath` - путь к приватному JWK-файлу серверного кошелька.
+
+Важно:
+
+- приватный JWK хранится вне кода;
+- если `walletAddress` указан и не совпадает с адресом, вычисленным из JWK, сервер вернёт ошибку настройки.
+
+## `TestGetFreeAvatarQuota`
+
+Возвращает остаток бесплатных загрузок для текущего авторизованного пользователя.
+
+### Запрос
+
+```json
+{
+ "op": "TestGetFreeAvatarQuota",
+ "requestId": "req-test-avatar-quota-1",
+ "payload": {}
+}
+```
+
+### Успешный ответ
+
+```json
+{
+ "op": "TestGetFreeAvatarQuota",
+ "requestId": "req-test-avatar-quota-1",
+ "status": 200,
+ "ok": true,
+ "payload": {
+ "enabled": true,
+ "limit": 3,
+ "usedCount": 1,
+ "remainingCount": 2,
+ "maxBytes": 131072
+ }
+}
+```
+
+### Поля ответа
+
+- `enabled` - временный API сейчас включён на сервере или нет;
+- `limit` - полный лимит бесплатных загрузок;
+- `usedCount` - сколько уже израсходовано;
+- `remainingCount` - сколько ещё осталось;
+- `maxBytes` - максимальный размер итогового файла.
+
+### Ошибки
+
+- `422 NOT_AUTHENTICATED` - требуется авторизация.
+
+## `TestUploadFreeAvatar`
+
+Временная бесплатная загрузка маленькой аватарки в Arweave через серверный кошелёк.
+
+### Правила
+
+- операция требует авторизованную сессию;
+- сервер использует текущий login из сессии;
+- сервер принимает только:
+ - `image/jpeg`
+ - `image/png`
+ - `image/webp`
+- размер итогового файла должен быть не больше `maxBytes` из квоты;
+- если пользователь уже сделал `limit` бесплатных загрузок, операция запрещена.
+
+### Запрос
+
+`fileBytesBase64` - это обычный Base64 байт итогового подготовленного файла.
+
+```json
+{
+ "op": "TestUploadFreeAvatar",
+ "requestId": "req-test-avatar-upload-1",
+ "payload": {
+ "contentType": "image/webp",
+ "fileBytesBase64": "UklGRiQAAABXRUJQVlA4WAoAAAAQAAAA...",
+ "sha256Hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ }
+}
+```
+
+### Успешный ответ
+
+```json
+{
+ "op": "TestUploadFreeAvatar",
+ "requestId": "req-test-avatar-upload-1",
+ "status": 200,
+ "ok": true,
+ "payload": {
+ "txId": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "sha256Hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ "usedCount": 2,
+ "remainingCount": 1,
+ "limit": 3,
+ "gateway": "https://arweave.net"
+ }
+}
+```
+
+### Поля ответа
+
+- `txId` - Arweave Transaction ID загруженного файла;
+- `sha256Hex` - SHA-256 загруженного файла;
+- `usedCount` - сколько бесплатных загрузок уже израсходовано после этой операции;
+- `remainingCount` - сколько бесплатных загрузок осталось;
+- `limit` - общий лимит;
+- `gateway` - gateway, через который сервер отправлял транзакцию.
+
+### Ошибки
+
+- `422 NOT_AUTHENTICATED` - требуется авторизация;
+- `400 BAD_FIELDS` - не переданы `contentType` или `fileBytesBase64`;
+- `400 BAD_BASE64` - `fileBytesBase64` не декодируется;
+- `400 BAD_AVATAR_FILE` - файл не проходит ограничения сервера;
+- `400 FREE_AVATAR_LIMIT_EXHAUSTED` - бесплатный лимит аватарок исчерпан;
+- `501 FREE_AVATAR_TEMP_DISABLED` - временная функция выключена или сервер не настроен;
+- `500 INTERNAL_ERROR` - внутренняя ошибка сервера.
+
+## Как это используется в UI
+
+На экране редактирования профиля в мастере смены аватара есть временный сценарий:
+
+- `Залить аватар бесплатно`
+
+UI:
+
+1. вызывает `TestGetFreeAvatarQuota`;
+2. показывает остаток лимита;
+3. локально подготавливает уменьшенный файл аватара;
+4. проверяет, что итоговый файл не превышает `maxBytes`;
+5. вызывает `TestUploadFreeAvatar`;
+6. после получения `txId` обычным путём записывает `avatar.ar` в профиль через `AddBlock`.
+
+## Почему решение временное
+
+- используется общий серверный Arweave-кошелёк;
+- лимит хранится отдельной технической таблицей;
+- операции имеют префикс `Test...`;
+- сценарий нужен как переходный бесплатный путь для маленьких аватаров.
diff --git a/Dev_Docs/Future_Features/README.md b/Dev_Docs/Future_Features/README.md
index f841e89..68bccfd 100644
--- a/Dev_Docs/Future_Features/README.md
+++ b/Dev_Docs/Future_Features/README.md
@@ -45,4 +45,4 @@
### Дальнее будущее
-- Сейчас задач нет.
+- `far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через SHiNE/WebRTC DataChannel и обмен файлами по чанкам с адресацией по `SHA-256`.
diff --git a/Dev_Docs/Future_Features/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md b/Dev_Docs/Future_Features/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md
new file mode 100644
index 0000000..1fa0c5e
--- /dev/null
+++ b/Dev_Docs/Future_Features/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md
@@ -0,0 +1,114 @@
+# Homeserver: технические команды и передача файлов через SHiNE/WebRTC
+
+## Зачем нужна фича
+
+Идея на дальнее будущее: дать возможность обращаться к homeserver не только как к участнику сети SHiNE, но и как к удалённой технической точке управления.
+
+Цели:
+- отправлять на homeserver технические команды в текстовом виде;
+- получать текстовый ответ на команду;
+- при наличии WebRTC DataChannel передавать части файлов в обе стороны;
+- хранить полученные файлы на SD-карте homeserver;
+- использовать единый механизм доставки как через сервер SHiNE, так и напрямую через DataChannel.
+
+## Горизонт
+
+`far` - идея без ближайшего срока реализации. Сейчас приоритет ниже, чем запуск и стабилизация основного проекта.
+
+## Что именно имеется в виду
+
+### 1. Единая модель технической команды
+
+Техническая команда должна иметь единый смысл независимо от транспорта доставки:
+- через любой доступный сервер SHiNE;
+- через уже установленный WebRTC DataChannel.
+
+Если конкретный транспорт недоступен, ответ по нему может не прийти. Это считается нормальным поведением протокола.
+
+### 2. Команда как короткоживущий подписанный сигнал
+
+У команды должны быть:
+- `commandId`;
+- временная метка;
+- TTL около 10 секунд;
+- криптографическая подпись.
+
+Смысл такой:
+- если команда быстро дошла, homeserver подтверждает принятие;
+- если не дошла вовремя, команда считается протухшей;
+- отправитель может безопасно послать повтор;
+- при повторе homeserver отвечает либо `команда принята`, либо `уже выполнено ранее`.
+
+Это даёт дедупликацию и безопасный resend без повторного выполнения действия.
+
+### 3. Текстовые технические команды
+
+Базовый сценарий похож на короткий удалённый shell-протокол, но на уровне строго ограниченных команд:
+- отправил строку-команду;
+- получил строку-ответ.
+
+Команды не обязаны исполнять произвольный shell. Предпочтительная модель - белый список операций с контролируемым форматом аргументов и ответа.
+
+### 4. Передача файлов только при наличии DataChannel
+
+Если между устройствами есть WebRTC DataChannel, через него можно передавать технические сообщения для файлового обмена.
+
+Предварительная модель:
+- имя файла = `SHA-256` содержимого;
+- можно запросить диапазон байт `from..to`;
+- можно отправить диапазон байт `from..to`;
+- homeserver хранит полученные данные на SD-карте;
+- если DataChannel нет, на запрос файловой передачи возвращается ответ в духе `не могу передать, нет data channel`.
+
+Фактически файл-обмен должен быть частным случаем общего протокола технических команд.
+
+### 5. Установка data-соединения по явной команде
+
+Нужна техническая команда уровня:
+- `установить data-соединение`.
+
+Ответ:
+- либо `да`, после чего запускается обычная процедура `offer/answer/ICE`;
+- либо `нет` и причина отказа.
+
+### 6. Доставка на пользовательские сессии
+
+Логика должна быть совместима с общей моделью SHiNE, где технические сигналы можно отправлять на конкретные активные сессии пользователя.
+
+Идея:
+- на любую активную сессию пользователя можно посылать техническую команду;
+- контакт пользователя может инициировать такую техническую коммуникацию так же, как он уже инициирует звонок или другой служебный сигнал.
+
+## Что нужно будет сделать при возврате к задаче
+
+- Спроектировать отдельный формат технических команд и ack-ответов.
+- Решить, будет ли это новый тип служебных сообщений в существующем протоколе блокчейн/сигналинга или отдельная ветка поверх уже имеющихся transport-операций.
+- Отдельно продумать авторизацию: кто именно из контактов и какие команды имеет право слать.
+- Ограничить набор допустимых команд, чтобы не превратить механизм в небезопасный удалённый shell.
+- Спроектировать протокол чанков файлов: размер чанка, нумерация, повторная отправка, контроль целостности, дозагрузка, завершение файла.
+- Продумать хранение на SD-карте: временные файлы, сборка чанков, проверка итогового `SHA-256`, очистка мусора.
+- Продумать поведение при отсутствии DataChannel, таймаутах и дублирующихся командах.
+- Проверить, как это лучше встраивать в текущие клиентские сессии, звонки и homeserver-логику.
+
+## Вопросы для будущего уточнения
+
+- Это должен быть строго служебный протокол или пользователь сможет вызывать его и вручную из UI.
+- Нужен ли доступ только к заранее разрешённым каталогам/файлам.
+- Нужна ли двусторонняя синхронизация файлов или достаточно ручных команд `запросить кусок` / `отправить кусок`.
+- Нужно ли разрешать передачу файлов через сервер SHiNE как fallback, или файл-обмен должен идти только через DataChannel.
+- Какой максимальный размер файлов и допустимый объём хранения на SD-карте.
+
+## Что уже сделано
+
+Пока только зафиксирована идея и базовая концепция. Реализация не начиналась.
+
+## Какие документы нужно будет обновить при реализации
+
+- `Dev_Docs/Blockchain/README.md` и связанные файлы, если изменятся типы служебных сообщений или форматы блокчейн-команд.
+- `Dev_Docs/API/` если изменится публичный серверный API или появятся новые операции.
+- `Dev_Docs/Personal_Messages/README.md` если часть маршрутизации или подтверждений будет встроена в существующую логику доставки/сессий.
+- Документацию по homeserver/ESP32, если появится пользовательская или сервисная файловая логика на устройстве.
+
+## С какого места продолжать позже
+
+Возвращаться к задаче только после стабилизации запуска проекта и базовых текущих функций. Начинать с проектирования протокола команд и матрицы прав доступа, а уже потом переходить к DataChannel-файлообмену.
diff --git a/Dev_Docs/Future_Features/far/README.md b/Dev_Docs/Future_Features/far/README.md
index 5dc5d98..fa0cb78 100644
--- a/Dev_Docs/Future_Features/far/README.md
+++ b/Dev_Docs/Future_Features/far/README.md
@@ -1,5 +1,7 @@
# Дальнее будущее
-Сейчас в этом горизонте нет активных идей.
-
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
+
+## Идеи
+
+- `2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через сервер SHiNE или WebRTC DataChannel, а также файловый обмен чанками с хранением на SD-карте.
diff --git a/Dev_Docs/Pending_Features/2026-06-20_1835_test_deploy_contour.md b/Dev_Docs/Pending_Features/2026-06-20_1835_test_deploy_contour.md
new file mode 100644
index 0000000..43ff8d0
--- /dev/null
+++ b/Dev_Docs/Pending_Features/2026-06-20_1835_test_deploy_contour.md
@@ -0,0 +1,22 @@
+# Тестовые deploy-контуры `test2.shineup.me` и `test.shineup.me`
+
+- краткое описание:
+ - default deploy-задачи `deployServer` и `deployUI` переведены на основной тестовый сервер `test2.shineup.me`;
+ - production-задачи вынесены в `deployServerProduction` и `deployUIProduction`;
+ - `test.shineup.me` оставлен как резервный тестовый сервер без обычного deploy по умолчанию.
+
+- что проверять:
+ - `./gradlew deployServer` и `./gradlew deployUI` действительно направлены на `test2.shineup.me`;
+ - `./gradlew deployServerProduction` и `./gradlew deployUIProduction` больше не используются как default;
+ - `https://test2.shineup.me` открывает UI;
+ - `wss://test2.shineup.me/ws` отвечает;
+ - на `test2.shineup.me` после deploy есть копия продовой `shine.sqlite` и `.bch`.
+
+- ожидаемый результат:
+ - default deploy идёт только на `test2.shineup.me`;
+ - production `shineup.me` меняется только после отдельного подтверждения;
+ - `test.shineup.me` остаётся резервным тестовым сервером;
+ - тестовый deploy не гоняет удалённые тесты и не создаёт пустую БД.
+
+- статус:
+ - pending
diff --git a/Dev_Docs/Pending_Features/2026-06-20_1839_registration_faq_and_12_words.md b/Dev_Docs/Pending_Features/2026-06-20_1839_registration_faq_and_12_words.md
new file mode 100644
index 0000000..64ade24
--- /dev/null
+++ b/Dev_Docs/Pending_Features/2026-06-20_1839_registration_faq_and_12_words.md
@@ -0,0 +1,25 @@
+# Регистрация: FAQ и режим пароля из 12 слов
+
+- краткое описание:
+ - на экране регистрации добавлен блок частых вопросов с переходом на отдельный экран справки;
+ - добавлен альтернативный режим ввода пароля через 12 полей-слов в кошелёчном формате, которые склеиваются в одну строку без изменения API;
+ - такой же режим добавлен и на экран входа по логину и паролю.
+
+- что проверять:
+ - на стартовом экране открыть `Зарегистрироваться`;
+ - убедиться, что внизу экрана есть кнопки FAQ;
+ - открыть несколько вопросов и проверить возврат обратно на регистрацию;
+ - включить галочку `Представить пароль в виде 12 слов`;
+ - убедиться, что появляется сетка с нумерованными полями в 3 колонки;
+ - ввести часть слов, перейти дальше и проверить, что шаг подтверждения и генерация ключей работают;
+ - выключить галочку и проверить, что пароль остаётся собранным в одном поле;
+ - открыть экран входа по паролю и повторить те же проверки для режима `12 слов`;
+ - пройти регистрацию до шага оплаты без ошибок интерфейса.
+
+- ожидаемый результат:
+ - FAQ открывается отдельным экраном и содержит понятные ответы;
+ - режим `12 слов` не ломает регистрацию и вход и даёт тот же поток, что и обычный пароль;
+ - пароль не отправляется в новом формате, а продолжает использоваться как одна строка.
+
+- статус:
+ - pending
diff --git a/Dev_Docs/Pending_Features/2026-06-20_2350_test_free_avatar_upload.md b/Dev_Docs/Pending_Features/2026-06-20_2350_test_free_avatar_upload.md
new file mode 100644
index 0000000..39646d4
--- /dev/null
+++ b/Dev_Docs/Pending_Features/2026-06-20_2350_test_free_avatar_upload.md
@@ -0,0 +1,25 @@
+# Временная бесплатная загрузка аватара в Arweave
+
+- краткое описание фичи:
+ Добавлены два временных `Test...` API для бесплатной загрузки маленьких аватаров в Arweave через серверный кошелёк с лимитом `3` загрузки на пользователя. В UI мастера смены аватара добавлен пункт `Залить аватар бесплатно`.
+
+- что именно проверять:
+ 1. Пользователь с активной сессией открывает редактирование профиля.
+ 2. По нажатию на аватар открывается мастер `Сменить аватар`.
+ 3. В мастере есть пункт `Залить аватар бесплатно`.
+ 4. До первой загрузки UI показывает остаток `3 из 3`.
+ 5. Маленький JPEG/PNG/WebP после уменьшения до файла <= `128 KB` успешно уходит через `TestUploadFreeAvatar`.
+ 6. После загрузки приходит `txId`, и аватар сохраняется в профиль как `avatar.ar`.
+ 7. Остаток уменьшается: `2`, `1`, `0`.
+ 8. На четвёртой попытке сервер отвечает понятной ошибкой про исчерпанный бесплатный лимит.
+ 9. Если итоговый уменьшенный файл всё ещё > `128 KB`, UI не отправляет его и показывает понятную ошибку.
+ 10. Если серверный Arweave JWK/path не настроен, UI получает понятную ошибку временной функции.
+
+- ожидаемый результат:
+ - первые 3 маленькие аватарки загружаются через серверный Arweave-кошелёк;
+ - после каждой успешной загрузки `ava` в профиле указывает на новый `txId`;
+ - после исчерпания лимита дальнейшая бесплатная загрузка блокируется без записи в профиль;
+ - обычная загрузка через свой Arweave-кошелёк продолжает работать отдельно.
+
+- статус:
+ pending
diff --git a/Dev_Docs/Pending_Features/2026-06-21_1226_fix_dm_chatid_lowercase.md b/Dev_Docs/Pending_Features/2026-06-21_1226_fix_dm_chatid_lowercase.md
new file mode 100644
index 0000000..ce8b296
--- /dev/null
+++ b/Dev_Docs/Pending_Features/2026-06-21_1226_fix_dm_chatid_lowercase.md
@@ -0,0 +1,19 @@
+# Исправление chatId личных сообщений через lowercase
+
+- краткое описание фичи:
+ - В клиентском UI SHiNE для личных сообщений технический `chatId` теперь канонизируется через `trim().toLowerCase()` при приёме DM, открытии чата и восстановлении сообщений из IndexedDB.
+ - Цель: исключить рассинхрон, когда unread-индикатор есть, а входящие сообщения конкретного собеседника не видны из-за разного регистра логина.
+
+- что именно проверять:
+ - Отправить личные сообщения между двумя пользователями, у одного из которых логин отображается с заглавными буквами.
+ - Убедиться, что входящие сообщения показываются внутри открытого чата, а не только в общем unread-индикаторе.
+ - Перезагрузить страницу и проверить, что история чата после гидрации из IndexedDB остаётся в одном диалоге.
+ - Проверить, что переход в чат из списка диалогов и из графа связей открывает тот же диалог без дублирования.
+
+- ожидаемый результат:
+ - Все сообщения одного собеседника попадают в один и тот же DM-чат независимо от регистра логина.
+ - Общий unread, список диалогов и содержимое открытого чата совпадают между собой.
+ - После перезагрузки UI не появляется отдельный дубль диалога с тем же логином в другом регистре.
+
+- статус:
+ - pending
diff --git a/Dev_Docs/deploy/README.md b/Dev_Docs/deploy/README.md
index 165d1ef..0459063 100644
--- a/Dev_Docs/deploy/README.md
+++ b/Dev_Docs/deploy/README.md
@@ -13,12 +13,56 @@
- актуальный IP должен браться через DNS-резолв на момент подключения;
- ручное дублирование IP в документации и deploy-скриптах не поддерживать.
+## Контуры деплоя
+
+- Production:
+ - SSH: `player@shineup.me`
+ - Домен: `shineup.me`
+ - IP: `185.229.109.118`
+- Main test:
+ - SSH: `player@193.8.215.70`
+ - Домен: `test2.shineup.me`
+ - IP: `193.8.215.70`
+- Reserve test:
+ - SSH: `player@93.170.12.154`
+ - Домен: `test.shineup.me`
+ - IP: `93.170.12.154`
+
## Локальные команды
-- Деплой сервера: `./gradlew deployServer`
-- Деплой UI: `./gradlew deployUI`
+- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`
+- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`
+- Production server deploy: `./gradlew deployServerProduction`
+- Production UI deploy: `./gradlew deployUIProduction`
+- Reserve test server deploy: `./gradlew deployServerTest`
+- Reserve test UI deploy: `./gradlew deployUITest`
- Локальный запуск: `./gradlew startLocal`
+## Политика подтверждений
+
+- `shineup.me` — production.
+- Любые изменения на `shineup.me`, включая deploy сервера, deploy UI, конфиги, перезапуски и миграции, делать только после отдельного явного подтверждения пользователя.
+- Если пользователь пишет просто `задеплой` без уточнения, по умолчанию это означает deploy на `test2.shineup.me`.
+
+## Main test deploy (`test2.shineup.me`)
+
+- Это основной сервер для тестов.
+- `deployServer` и `deployUI` по умолчанию направлены именно сюда.
+- Серверный deploy не запускает JUnit/IT-тесты на удалённом сервере.
+- `deployServer` / `deployServerTest2` делают:
+ - сборку fat-jar локально;
+ - синхронизацию `data/` и `shine.sqlite` с production `shineup.me`;
+ - перенос `application.properties` с production с поправкой `server.ui.indexPath` на `/home/player/SHiNE/shine-ui/index.html`;
+ - установку `systemd` unit на `193.8.215.70`;
+ - перезапуск `shine-server.service`;
+ - установку/проверку Caddy для `test2.shineup.me`.
+- `deployUI` / `deployUITest2` публикуют UI в `/home/player/SHiNE/shine-ui` на `193.8.215.70`.
+
+## Reserve test deploy (`test.shineup.me`)
+
+- `test.shineup.me` пока не использовать для обычного deploy.
+- Задачи `deployServerTest` и `deployUITest` считаются резервными и требуют отдельной причины.
+
## UI-деплой и Caddy (обязательно)
- Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`.
diff --git a/Dev_Docs/deploy/servers/193.8.215.70_test2_main.md b/Dev_Docs/deploy/servers/193.8.215.70_test2_main.md
new file mode 100644
index 0000000..fbc36d5
--- /dev/null
+++ b/Dev_Docs/deploy/servers/193.8.215.70_test2_main.md
@@ -0,0 +1,42 @@
+# Сервер `193.8.215.70` — основной test (`test2.shineup.me`)
+
+- Пользователь: `player`
+- Домен: `test2.shineup.me`
+- Каталог SHiNE: `/home/player/SHiNE`
+- UI публикация для Caddy: `/home/player/SHiNE/shine-ui`
+- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
+- Данные: `/home/player/SHiNE/shine-server/data/`
+ - `shine.sqlite`
+ - `*.bch`
+- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
+
+## Сервисы
+
+- `shine-server.service` (systemd)
+- `caddy.service` (systemd)
+
+## Статус
+
+- Это основной сервер для тестов SHiNE.
+- Default deploy по умолчанию должен идти сюда.
+- Источник данных для тестовой БД: production `shineup.me`.
+
+## Caddy
+
+- Конфиг: `/etc/caddy/Caddyfile`
+- Сайты:
+ - `test2.shineup.me`
+ - `agent.shiningpeople.ru`
+- Для `test2.shineup.me`:
+ - `root * /home/player/SHiNE/shine-ui`
+ - `try_files {path} /index.html`
+ - `reverse_proxy /ws* -> 127.0.0.1:7070`
+
+## Deploy
+
+- Default server deploy:
+ - `./gradlew deployServer`
+ - `./gradlew deployServerTest2`
+- Default UI deploy:
+ - `./gradlew deployUI`
+ - `./gradlew deployUITest2`
diff --git a/Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md b/Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md
index cf424ce..9c0e4df 100644
--- a/Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md
+++ b/Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md
@@ -1,14 +1,14 @@
-# Сервер `93.170.12.154` — резервный
+# Сервер `93.170.12.154` — test.shineup.me
- Пользователь: `player`
- Каталог SHiNE: `/home/player/SHiNE`
-- UI исходник (после rsync): `/home/player/SHiNE/SHiNE-UI`
-- UI публикация для Caddy: `/var/www/shine-ui`
-- Сервер: `/home/player/SHiNE/SHiNE-server/shine-server.jar`
-- Данные: `/home/player/SHiNE/SHiNE-server/data/`
+- Домен: `test.shineup.me`
+- UI публикация для Caddy: `/home/player/SHiNE/shine-ui`
+- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
+- Данные: `/home/player/SHiNE/shine-server/data/`
- `shine.sqlite`
- `*.bch`
-- Логи сервера: `/home/player/SHiNE/SHiNE-server/logs/app.log`
+- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
## Сервисы
@@ -17,8 +17,10 @@
## Статус
-- Резервный сервер для SHiNE.
-- Основной прод-сервер: `shineup.me` (подключение через `player@shineup.me`, IP определяется через DNS).
+- Резервный тестовый сервер для SHiNE.
+- Источник данных для тестовой БД: production `shineup.me`.
+- Пока не использовать для обычного deploy.
+- Основной прод-сервер: `shineup.me` (`185.229.109.118`).
## Caddy
@@ -26,4 +28,12 @@
- Настройки:
- `no-store/no-cache` заголовки;
- `try_files {path} /index.html` (SPA fallback);
- - `reverse_proxy /ws* -> 127.0.0.1:7070`.
+ - `reverse_proxy /ws* -> 127.0.0.1:7070`;
+ - целевой сайт: `test.shineup.me`.
+
+## Deploy
+
+- Резервные задачи:
+ - `./gradlew deployServerTest`
+ - `./gradlew deployUITest`
+- Эти задачи пока не использовать без отдельной причины.
diff --git a/Dev_Docs/deploy/servers/shineup.me_main.md b/Dev_Docs/deploy/servers/shineup.me_main.md
index 891929c..66700ba 100644
--- a/Dev_Docs/deploy/servers/shineup.me_main.md
+++ b/Dev_Docs/deploy/servers/shineup.me_main.md
@@ -33,3 +33,15 @@
- `https://test-solana-tickets.shineup.me`
- `https://test-solana-tickets.shiningpeople.ru`
- Для всех deploy-скриптов и инструкций использовать именно `player@shineup.me`, без жёсткой фиксации IP.
+
+## Правило изменений
+
+- `shineup.me` — production.
+- Любые изменения на этом сервере делать только после отдельного явного подтверждения пользователя.
+
+## Deploy
+
+- Production deploy-задачи:
+ - `./gradlew deployServerProduction`
+ - `./gradlew deployUIProduction`
+- Default deploy-задачи `./gradlew deployServer` и `./gradlew deployUI` сюда больше не относятся.
diff --git a/SHiNE-server/AGENTS.md b/SHiNE-server/AGENTS.md
index 5d319b9..4734e4f 100644
--- a/SHiNE-server/AGENTS.md
+++ b/SHiNE-server/AGENTS.md
@@ -56,9 +56,28 @@ shine-UI/server-ui.html
```
./gradlew deployServer
+./gradlew deployUI
```
-Хост по умолчанию: `player@93.170.12.154` (shineup.me).
+Default deploy по умолчанию идёт на `test2.shineup.me` (`player@193.8.215.70`).
+
+Production deploy:
+
+```
+./gradlew deployServerProduction
+./gradlew deployUIProduction
+```
+
+Любые изменения на `shineup.me` делать только после отдельного явного подтверждения пользователя.
+
+Резервный test-контур:
+
+```
+./gradlew deployServerTest
+./gradlew deployUITest
+```
+
+`test.shineup.me` пока не использовать для обычного deploy.
Логи на проде:
- `/home/player/SHiNE/shine-server/logs/app.log`
diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
index 8cdd221..2a8ef89 100644
--- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
+++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
@@ -264,6 +264,21 @@ public final class DatabaseInitializer {
ON ip_geo_cache (updated_at_ms);
""");
+ st.executeUpdate("""
+ CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
+ login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
+ used_count INTEGER NOT NULL DEFAULT 0,
+ updated_at_ms INTEGER NOT NULL,
+ last_tx_id TEXT NOT NULL DEFAULT '',
+ FOREIGN KEY (login) REFERENCES solana_users(login)
+ );
+ """);
+
+ st.executeUpdate("""
+ CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
+ ON test_free_avatar_uploads (updated_at_ms);
+ """);
+
// 5. blockchain_state
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS blockchain_state (
diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java
index 91e8d26..16c3635 100644
--- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java
+++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java
@@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController {
private static volatile SqliteDbController instance;
- private static final int LATEST_SCHEMA_VERSION = 7;
+ private static final int LATEST_SCHEMA_VERSION = 8;
private final String jdbcUrl;
@@ -90,6 +90,7 @@ public final class SqliteDbController {
case 5 -> migrateToV5();
case 6 -> migrateToV6();
case 7 -> migrateToV7();
+ case 8 -> migrateToV8();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
}
}
@@ -249,6 +250,25 @@ public final class SqliteDbController {
}
}
+ private void migrateToV8() {
+ try (Connection c = DriverManager.getConnection(jdbcUrl);
+ Statement st = c.createStatement()) {
+ c.setAutoCommit(false);
+ try {
+ ensureTestFreeAvatarUploadsTable(st);
+ setSchemaVersion(c, 8);
+ c.commit();
+ } catch (Exception e) {
+ try { c.rollback(); } catch (Exception ignored) {}
+ throw new RuntimeException("DB migration to v8 failed", e);
+ } finally {
+ try { c.setAutoCommit(true); } catch (Exception ignored) {}
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("DB migration to v8 failed", e);
+ }
+ }
+
private static void ensureChat200StateTables(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_state (
@@ -432,6 +452,22 @@ public final class SqliteDbController {
""");
}
+ private static void ensureTestFreeAvatarUploadsTable(Statement st) throws SQLException {
+ st.executeUpdate("""
+ CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
+ login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
+ used_count INTEGER NOT NULL DEFAULT 0,
+ updated_at_ms INTEGER NOT NULL,
+ last_tx_id TEXT NOT NULL DEFAULT '',
+ FOREIGN KEY (login) REFERENCES solana_users(login)
+ );
+ """);
+ st.executeUpdate("""
+ CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
+ ON test_free_avatar_uploads (updated_at_ms);
+ """);
+ }
+
private static void createConnectionsStateTable(Statement st) throws SQLException {
st.executeUpdate("""
diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java
new file mode 100644
index 0000000..7357140
--- /dev/null
+++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java
@@ -0,0 +1,82 @@
+package shine.db.dao;
+
+import shine.db.SqliteDbController;
+import shine.db.entities.TestFreeAvatarUploadEntry;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+public final class TestFreeAvatarUploadsDAO {
+
+ private static volatile TestFreeAvatarUploadsDAO instance;
+ private final SqliteDbController db = SqliteDbController.getInstance();
+
+ private TestFreeAvatarUploadsDAO() {
+ }
+
+ public static TestFreeAvatarUploadsDAO getInstance() {
+ if (instance == null) {
+ synchronized (TestFreeAvatarUploadsDAO.class) {
+ if (instance == null) instance = new TestFreeAvatarUploadsDAO();
+ }
+ }
+ return instance;
+ }
+
+ public TestFreeAvatarUploadEntry getByLogin(Connection c, String login) throws SQLException {
+ String sql = """
+ SELECT login, used_count, updated_at_ms, last_tx_id
+ FROM test_free_avatar_uploads
+ WHERE login = ? COLLATE NOCASE
+ LIMIT 1
+ """;
+ try (PreparedStatement ps = c.prepareStatement(sql)) {
+ ps.setString(1, login);
+ try (ResultSet rs = ps.executeQuery()) {
+ if (!rs.next()) return null;
+ return mapRow(rs);
+ }
+ }
+ }
+
+ public TestFreeAvatarUploadEntry getByLogin(String login) throws SQLException {
+ try (Connection c = db.getConnection()) {
+ return getByLogin(c, login);
+ }
+ }
+
+ public void upsertUsage(Connection c, String login, int usedCount, long updatedAtMs, String lastTxId) throws SQLException {
+ String sql = """
+ INSERT INTO test_free_avatar_uploads (login, used_count, updated_at_ms, last_tx_id)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(login) DO UPDATE SET
+ used_count = excluded.used_count,
+ updated_at_ms = excluded.updated_at_ms,
+ last_tx_id = excluded.last_tx_id
+ """;
+ try (PreparedStatement ps = c.prepareStatement(sql)) {
+ ps.setString(1, login);
+ ps.setInt(2, usedCount);
+ ps.setLong(3, updatedAtMs);
+ ps.setString(4, lastTxId);
+ ps.executeUpdate();
+ }
+ }
+
+ public void upsertUsage(String login, int usedCount, long updatedAtMs, String lastTxId) throws SQLException {
+ try (Connection c = db.getConnection()) {
+ upsertUsage(c, login, usedCount, updatedAtMs, lastTxId);
+ }
+ }
+
+ private static TestFreeAvatarUploadEntry mapRow(ResultSet rs) throws SQLException {
+ return new TestFreeAvatarUploadEntry(
+ rs.getString("login"),
+ rs.getInt("used_count"),
+ rs.getLong("updated_at_ms"),
+ rs.getString("last_tx_id")
+ );
+ }
+}
diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java
new file mode 100644
index 0000000..3f878b2
--- /dev/null
+++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java
@@ -0,0 +1,50 @@
+package shine.db.entities;
+
+public class TestFreeAvatarUploadEntry {
+ private String login;
+ private int usedCount;
+ private long updatedAtMs;
+ private String lastTxId;
+
+ public TestFreeAvatarUploadEntry() {
+ }
+
+ public TestFreeAvatarUploadEntry(String login, int usedCount, long updatedAtMs, String lastTxId) {
+ this.login = login;
+ this.usedCount = usedCount;
+ this.updatedAtMs = updatedAtMs;
+ this.lastTxId = lastTxId;
+ }
+
+ public String getLogin() {
+ return login;
+ }
+
+ public void setLogin(String login) {
+ this.login = login;
+ }
+
+ public int getUsedCount() {
+ return usedCount;
+ }
+
+ public void setUsedCount(int usedCount) {
+ this.usedCount = usedCount;
+ }
+
+ public long getUpdatedAtMs() {
+ return updatedAtMs;
+ }
+
+ public void setUpdatedAtMs(long updatedAtMs) {
+ this.updatedAtMs = updatedAtMs;
+ }
+
+ public String getLastTxId() {
+ return lastTxId;
+ }
+
+ public void setLastTxId(String lastTxId) {
+ this.lastTxId = lastTxId;
+ }
+}
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
index ecf050a..39e8056 100644
--- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
@@ -45,7 +45,11 @@ 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.Net_TestGetFreeAvatarQuota_Handler;
+import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
+import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
+import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
// --- NEW: SearchUsers ---
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
@@ -127,6 +131,8 @@ public final class JsonHandlerRegistry {
Map.entry("AddUser", new Net_AddUser_Handler()),
Map.entry("GetUser", new Net_GetUser_Handler()),
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
+ Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
+ Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
// --- auth ---
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
@@ -200,6 +206,8 @@ public final class JsonHandlerRegistry {
Map.entry("AddUser", Net_AddUser_Request.class),
Map.entry("GetUser", Net_GetUser_Request.class),
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
+ Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
+ Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
// --- auth ---
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java
new file mode 100644
index 0000000..03bfe10
--- /dev/null
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java
@@ -0,0 +1,37 @@
+package server.logic.ws_protocol.JSON.handlers.tempToTest;
+
+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_TestGetFreeAvatarQuota_Request;
+import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Response;
+import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
+import server.logic.ws_protocol.WireCodes;
+
+public class Net_TestGetFreeAvatarQuota_Handler implements JsonMessageHandler {
+
+ private final TestFreeAvatarArweaveService service = new TestFreeAvatarArweaveService();
+
+ @Override
+ public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
+ Net_TestGetFreeAvatarQuota_Request req = (Net_TestGetFreeAvatarQuota_Request) baseRequest;
+ if (ctx == null || !ctx.isAuthenticatedUser()) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
+ }
+
+ String login = String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim();
+ TestFreeAvatarArweaveService.Quota quota = service.getQuota(login);
+
+ Net_TestGetFreeAvatarQuota_Response resp = new Net_TestGetFreeAvatarQuota_Response();
+ resp.setOp(req.getOp());
+ resp.setRequestId(req.getRequestId());
+ resp.setStatus(WireCodes.Status.OK);
+ resp.setEnabled(quota.enabled());
+ resp.setLimit(quota.limit());
+ resp.setUsedCount(quota.usedCount());
+ resp.setRemainingCount(quota.remainingCount());
+ resp.setMaxBytes(service.getMaxBytes());
+ return resp;
+ }
+}
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java
new file mode 100644
index 0000000..e6ef565
--- /dev/null
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java
@@ -0,0 +1,60 @@
+package server.logic.ws_protocol.JSON.handlers.tempToTest;
+
+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_TestUploadFreeAvatar_Request;
+import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Response;
+import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
+import server.logic.ws_protocol.WireCodes;
+
+import java.util.Base64;
+
+public class Net_TestUploadFreeAvatar_Handler implements JsonMessageHandler {
+
+ private final TestFreeAvatarArweaveService service = new TestFreeAvatarArweaveService();
+
+ @Override
+ public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
+ Net_TestUploadFreeAvatar_Request req = (Net_TestUploadFreeAvatar_Request) baseRequest;
+ if (ctx == null || !ctx.isAuthenticatedUser()) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
+ }
+
+ String fileBase64 = String.valueOf(req.getFileBytesBase64() == null ? "" : req.getFileBytesBase64()).trim();
+ String contentType = String.valueOf(req.getContentType() == null ? "" : req.getContentType()).trim();
+ if (fileBase64.isBlank() || contentType.isBlank()) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Нужно передать contentType и fileBytesBase64.");
+ }
+
+ byte[] fileBytes;
+ try {
+ fileBytes = Base64.getDecoder().decode(fileBase64);
+ } catch (IllegalArgumentException e) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_BASE64", "fileBytesBase64 должен быть корректным Base64.");
+ }
+
+ String login = String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim();
+ try {
+ TestFreeAvatarArweaveService.UploadResult result = service.uploadAvatar(login, contentType, fileBytes, req.getSha256Hex());
+ Net_TestUploadFreeAvatar_Response resp = new Net_TestUploadFreeAvatar_Response();
+ resp.setOp(req.getOp());
+ resp.setRequestId(req.getRequestId());
+ resp.setStatus(WireCodes.Status.OK);
+ resp.setTxId(result.txId());
+ resp.setSha256Hex(result.sha256Hex());
+ resp.setUsedCount(result.usedCount());
+ resp.setRemainingCount(result.remainingCount());
+ resp.setLimit(result.limit());
+ resp.setGateway(result.gateway());
+ return resp;
+ } catch (TestFreeAvatarArweaveService.FreeAvatarLimitExceededException e) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "FREE_AVATAR_LIMIT_EXHAUSTED", e.getMessage());
+ } catch (IllegalArgumentException e) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_AVATAR_FILE", e.getMessage());
+ } catch (IllegalStateException e) {
+ return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "FREE_AVATAR_TEMP_DISABLED", e.getMessage());
+ }
+ }
+}
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java
new file mode 100644
index 0000000..0015eb1
--- /dev/null
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java
@@ -0,0 +1,391 @@
+package server.logic.ws_protocol.JSON.handlers.tempToTest;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import shine.db.dao.TestFreeAvatarUploadsDAO;
+import shine.db.entities.TestFreeAvatarUploadEntry;
+import utils.config.AppConfig;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.Signature;
+import java.security.interfaces.RSAPrivateCrtKey;
+import java.security.spec.MGF1ParameterSpec;
+import java.security.spec.PSSParameterSpec;
+import java.security.spec.RSAPrivateCrtKeySpec;
+import java.sql.SQLException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public final class TestFreeAvatarArweaveService {
+
+ private static final Logger log = LoggerFactory.getLogger(TestFreeAvatarArweaveService.class);
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(20))
+ .build();
+ private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
+ private static final Base64.Encoder B64URL_NOPAD = Base64.getUrlEncoder().withoutPadding();
+ private static final int DEFAULT_LIMIT = 3;
+ private static final int DEFAULT_MAX_BYTES = 128 * 1024;
+ private static final String DEFAULT_GATEWAY = "https://arweave.net";
+ private static final ConcurrentHashMap LOGIN_LOCKS = new ConcurrentHashMap<>();
+
+ private final AppConfig config = AppConfig.getInstance();
+ private final TestFreeAvatarUploadsDAO quotaDao = TestFreeAvatarUploadsDAO.getInstance();
+
+ public Quota getQuota(String login) throws SQLException {
+ int limit = getLimitPerUser();
+ TestFreeAvatarUploadEntry entry = quotaDao.getByLogin(login);
+ int used = entry == null ? 0 : Math.max(0, entry.getUsedCount());
+ int remaining = Math.max(0, limit - used);
+ return new Quota(limit, used, remaining, isEnabled());
+ }
+
+ public UploadResult uploadAvatar(String login, String contentType, byte[] fileBytes, String expectedSha256Hex)
+ throws Exception {
+ if (!isEnabled()) {
+ throw new IllegalStateException("Временная бесплатная загрузка аватаров сейчас отключена на сервере.");
+ }
+
+ String cleanLogin = String.valueOf(login == null ? "" : login).trim();
+ if (cleanLogin.isBlank()) {
+ throw new IllegalArgumentException("Пустой login для бесплатной загрузки аватара.");
+ }
+
+ String cleanType = normalizeContentType(contentType);
+ validatePayload(cleanType, fileBytes);
+ String actualSha256Hex = sha256Hex(fileBytes);
+ String expectedSha = String.valueOf(expectedSha256Hex == null ? "" : expectedSha256Hex).trim().toLowerCase();
+ if (!expectedSha.isBlank() && !actualSha256Hex.equals(expectedSha)) {
+ throw new IllegalArgumentException("SHA-256 файла не совпадает с присланным клиентом.");
+ }
+
+ Object lock = LOGIN_LOCKS.computeIfAbsent(cleanLogin.toLowerCase(), key -> new Object());
+ synchronized (lock) {
+ Quota before = getQuota(cleanLogin);
+ if (before.remainingCount() <= 0) {
+ throw new FreeAvatarLimitExceededException("Вы исчерпали бесплатный лимит аватарок.");
+ }
+
+ ArweaveConfig arConfig = loadArweaveConfig();
+ String txId = postAvatarTransaction(arConfig, cleanLogin, cleanType, fileBytes);
+
+ int usedAfter = before.usedCount() + 1;
+ int remainingAfter = Math.max(0, before.limit() - usedAfter);
+ quotaDao.upsertUsage(cleanLogin, usedAfter, System.currentTimeMillis(), txId);
+ return new UploadResult(txId, actualSha256Hex, usedAfter, remainingAfter, before.limit(), arConfig.gateway());
+ }
+ }
+
+ public boolean isEnabled() {
+ return config.getBoolean("test.freeAvatar.enabled", true);
+ }
+
+ public int getLimitPerUser() {
+ int configured = config.getInt("test.freeAvatar.limitPerUser", DEFAULT_LIMIT);
+ return Math.max(1, configured);
+ }
+
+ public int getMaxBytes() {
+ int configured = config.getInt("test.freeAvatar.maxBytes", DEFAULT_MAX_BYTES);
+ return Math.max(1024, configured);
+ }
+
+ private String postAvatarTransaction(ArweaveConfig arConfig, String login, String contentType, byte[] data)
+ throws IOException, InterruptedException, GeneralSecurityException {
+ String gateway = arConfig.gateway();
+ String anchor = getRequiredText(gateway, "/tx_anchor");
+ String reward = getRequiredText(gateway, "/price/" + data.length);
+ if (!reward.matches("^\\d+$")) {
+ throw new IllegalStateException("Arweave gateway вернул некорректную цену загрузки.");
+ }
+
+ if (!arConfig.address().isBlank()) {
+ String balance = getRequiredText(gateway, "/wallet/" + arConfig.address() + "/balance");
+ if (balance.matches("^\\d+$")) {
+ BigInteger balanceWinston = new BigInteger(balance);
+ BigInteger rewardWinston = new BigInteger(reward);
+ if (balanceWinston.compareTo(rewardWinston) < 0) {
+ throw new IllegalStateException("На серверном Arweave-кошельке недостаточно AR для бесплатной загрузки аватара.");
+ }
+ }
+ }
+
+ byte[] dataRoot = singleChunkDataRoot(data);
+ List> tagList = new ArrayList<>();
+ List