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> jsonTags = new ArrayList<>(); + appendTag(jsonTags, tagList, "Content-Type", contentType); + appendTag(jsonTags, tagList, "App-Name", "SHiNE"); + appendTag(jsonTags, tagList, "SHiNE-Type", "avatar-free-test"); + appendTag(jsonTags, tagList, "SHiNE-Profile-Login", login); + + byte[] signaturePayload = deepHash(List.of( + utf8("2"), + b64UrlDecode(arConfig.owner()), + new byte[0], + utf8("0"), + utf8(reward), + b64UrlDecode(anchor), + tagList, + utf8(Integer.toString(data.length)), + dataRoot + )); + + byte[] rawSignature = signPayload(arConfig.privateKey(), signaturePayload); + String txId = b64UrlEncode(sha256(rawSignature)); + + Map body = new LinkedHashMap<>(); + body.put("format", 2); + body.put("id", txId); + body.put("last_tx", anchor); + body.put("owner", arConfig.owner()); + body.put("tags", jsonTags); + body.put("target", ""); + body.put("quantity", "0"); + body.put("data_root", b64UrlEncode(dataRoot)); + body.put("data_size", Integer.toString(data.length)); + body.put("data", b64UrlEncode(data)); + body.put("reward", reward); + body.put("signature", b64UrlEncode(rawSignature)); + + String bodyJson = MAPPER.writeValueAsString(body); + HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx")) + .timeout(Duration.ofSeconds(40)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(bodyJson, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = HTTP.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + int status = response.statusCode(); + if (status != 200 && status != 208) { + throw new IllegalStateException("Arweave отклонил транзакцию: HTTP " + status + " " + safeBody(response.body())); + } + return txId; + } + + private ArweaveConfig loadArweaveConfig() throws IOException, GeneralSecurityException { + String rawGateway = String.valueOf(config.getParam("test.freeAvatar.gateway") == null + ? DEFAULT_GATEWAY + : config.getParam("test.freeAvatar.gateway")).trim(); + String gateway = rawGateway.isBlank() ? DEFAULT_GATEWAY : rawGateway.replaceAll("/+$", ""); + + String walletPathRaw = String.valueOf(config.getParam("test.freeAvatar.walletJwkPath") == null + ? "" + : config.getParam("test.freeAvatar.walletJwkPath")).trim(); + if (walletPathRaw.isBlank()) { + throw new IllegalStateException("Не задан test.freeAvatar.walletJwkPath в настройках сервера."); + } + + JsonNode jwk = MAPPER.readTree(Files.readString(Path.of(walletPathRaw), StandardCharsets.UTF_8)); + String owner = requiredText(jwk, "n"); + PrivateKey privateKey = buildPrivateKeyFromJwk(jwk); + String computedAddress = b64UrlEncode(sha256(b64UrlDecode(owner))); + + String expectedAddress = String.valueOf(config.getParam("test.freeAvatar.walletAddress") == null + ? "" + : config.getParam("test.freeAvatar.walletAddress")).trim(); + if (!expectedAddress.isBlank() && !expectedAddress.equals(computedAddress)) { + throw new IllegalStateException("test.freeAvatar.walletAddress не совпадает с адресом из JWK."); + } + + return new ArweaveConfig(gateway, owner, computedAddress, privateKey); + } + + private static PrivateKey buildPrivateKeyFromJwk(JsonNode jwk) throws GeneralSecurityException { + RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec( + asBigInt(requiredText(jwk, "n")), + asBigInt(requiredText(jwk, "e")), + asBigInt(requiredText(jwk, "d")), + asBigInt(requiredText(jwk, "p")), + asBigInt(requiredText(jwk, "q")), + asBigInt(requiredText(jwk, "dp")), + asBigInt(requiredText(jwk, "dq")), + asBigInt(requiredText(jwk, "qi")) + ); + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } + + private static byte[] signPayload(PrivateKey key, byte[] payload) throws GeneralSecurityException { + Signature signature = Signature.getInstance("RSASSA-PSS"); + signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1)); + signature.initSign(key); + signature.update(payload); + return signature.sign(); + } + + private static byte[] singleChunkDataRoot(byte[] data) throws GeneralSecurityException { + byte[] dataHash = sha256(data); + byte[] left = sha256(dataHash); + byte[] right = sha256(intToBuffer32(data.length)); + return sha256(concat(left, right)); + } + + @SuppressWarnings("unchecked") + private static byte[] deepHash(Object data) throws GeneralSecurityException { + if (data instanceof List list) { + byte[] tag = concat(utf8("list"), utf8(Integer.toString(list.size()))); + return deepHashChunks(list, sha384(tag)); + } + if (!(data instanceof byte[] bytes)) { + throw new IllegalArgumentException("deepHash поддерживает только byte[] и list."); + } + byte[] tag = concat(utf8("blob"), utf8(Integer.toString(bytes.length))); + byte[] tagged = concat(sha384(tag), sha384(bytes)); + return sha384(tagged); + } + + private static byte[] deepHashChunks(List chunks, byte[] acc) throws GeneralSecurityException { + if (chunks.isEmpty()) return acc; + byte[] pair = concat(acc, deepHash(chunks.get(0))); + return deepHashChunks(chunks.subList(1, chunks.size()), sha384(pair)); + } + + private static void appendTag(List> jsonTags, List> tagList, String name, String value) { + byte[] nameBytes = utf8(name); + byte[] valueBytes = utf8(value); + Map item = new LinkedHashMap<>(); + item.put("name", b64UrlEncode(nameBytes)); + item.put("value", b64UrlEncode(valueBytes)); + jsonTags.add(item); + tagList.add(List.of(nameBytes, valueBytes)); + } + + private static String getRequiredText(String gateway, String path) throws IOException, InterruptedException { + HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path)) + .timeout(Duration.ofSeconds(20)) + .GET() + .build(); + HttpResponse response = HTTP.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("Arweave gateway вернул HTTP " + response.statusCode() + " для " + path); + } + String body = String.valueOf(response.body() == null ? "" : response.body()).trim(); + if (body.isBlank()) { + throw new IllegalStateException("Arweave gateway вернул пустой ответ для " + path); + } + return body; + } + + private void validatePayload(String contentType, byte[] fileBytes) { + if (fileBytes == null || fileBytes.length == 0) { + throw new IllegalArgumentException("Файл аватара пустой."); + } + if (fileBytes.length > getMaxBytes()) { + throw new IllegalArgumentException("Файл слишком большой для бесплатной загрузки. Максимум " + getMaxBytes() + " байт."); + } + if (!isSupportedContentType(contentType)) { + throw new IllegalArgumentException("Поддерживаются только JPEG, PNG или WebP."); + } + } + + private static boolean isSupportedContentType(String contentType) { + return "image/jpeg".equals(contentType) + || "image/png".equals(contentType) + || "image/webp".equals(contentType); + } + + private static String normalizeContentType(String contentType) { + return String.valueOf(contentType == null ? "" : contentType).trim().toLowerCase(); + } + + private static String requiredText(JsonNode node, String field) { + String value = node == null ? "" : String.valueOf(node.path(field).asText("")).trim(); + if (value.isBlank()) { + throw new IllegalStateException("В JWK отсутствует поле " + field + "."); + } + return value; + } + + private static BigInteger asBigInt(String b64Url) { + return new BigInteger(1, b64UrlDecode(b64Url)); + } + + private static byte[] b64UrlDecode(String value) { + return B64URL.decode(String.valueOf(value == null ? "" : value).trim()); + } + + private static String b64UrlEncode(byte[] value) { + return B64URL_NOPAD.encodeToString(value); + } + + private static byte[] utf8(String value) { + return String.valueOf(value == null ? "" : value).getBytes(StandardCharsets.UTF_8); + } + + private static byte[] concat(byte[]... arrays) { + int total = 0; + for (byte[] array : arrays) total += array.length; + byte[] out = new byte[total]; + int offset = 0; + for (byte[] array : arrays) { + System.arraycopy(array, 0, out, offset, array.length); + offset += array.length; + } + return out; + } + + private static byte[] intToBuffer32(int value) { + byte[] out = new byte[32]; + long current = Integer.toUnsignedLong(value); + for (int i = out.length - 1; i >= 0; i--) { + out[i] = (byte) (current & 0xffL); + current >>>= 8; + } + return out; + } + + private static byte[] sha256(byte[] data) throws GeneralSecurityException { + return MessageDigest.getInstance("SHA-256").digest(data); + } + + private static byte[] sha384(byte[] data) throws GeneralSecurityException { + return MessageDigest.getInstance("SHA-384").digest(data); + } + + private static String sha256Hex(byte[] data) throws GeneralSecurityException { + byte[] hash = sha256(data); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) sb.append(String.format("%02x", b)); + return sb.toString(); + } + + private static String safeBody(String body) { + String text = String.valueOf(body == null ? "" : body).replace('\n', ' ').replace('\r', ' ').trim(); + if (text.length() <= 220) return text; + return text.substring(0, 220) + "..."; + } + + public record Quota(int limit, int usedCount, int remainingCount, boolean enabled) { + } + + public record UploadResult(String txId, String sha256Hex, int usedCount, int remainingCount, int limit, String gateway) { + } + + private record ArweaveConfig(String gateway, String owner, String address, PrivateKey privateKey) { + } + + public static final class FreeAvatarLimitExceededException extends RuntimeException { + public FreeAvatarLimitExceededException(String message) { + super(message); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java new file mode 100644 index 0000000..519c3a4 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java @@ -0,0 +1,6 @@ +package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_TestGetFreeAvatarQuota_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java new file mode 100644 index 0000000..1259468 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java @@ -0,0 +1,51 @@ +package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_TestGetFreeAvatarQuota_Response extends Net_Response { + private boolean enabled; + private int limit; + private int usedCount; + private int remainingCount; + private int maxBytes; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getLimit() { + return limit; + } + + public void setLimit(int limit) { + this.limit = limit; + } + + public int getUsedCount() { + return usedCount; + } + + public void setUsedCount(int usedCount) { + this.usedCount = usedCount; + } + + public int getRemainingCount() { + return remainingCount; + } + + public void setRemainingCount(int remainingCount) { + this.remainingCount = remainingCount; + } + + public int getMaxBytes() { + return maxBytes; + } + + public void setMaxBytes(int maxBytes) { + this.maxBytes = maxBytes; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java new file mode 100644 index 0000000..777d66f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java @@ -0,0 +1,33 @@ +package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_TestUploadFreeAvatar_Request extends Net_Request { + private String contentType; + private String fileBytesBase64; + private String sha256Hex; + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getFileBytesBase64() { + return fileBytesBase64; + } + + public void setFileBytesBase64(String fileBytesBase64) { + this.fileBytesBase64 = fileBytesBase64; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java new file mode 100644 index 0000000..b3ae2f0 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_TestUploadFreeAvatar_Response extends Net_Response { + private String txId; + private String sha256Hex; + private int usedCount; + private int remainingCount; + private int limit; + private String gateway; + + public String getTxId() { + return txId; + } + + public void setTxId(String txId) { + this.txId = txId; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } + + public int getUsedCount() { + return usedCount; + } + + public void setUsedCount(int usedCount) { + this.usedCount = usedCount; + } + + public int getRemainingCount() { + return remainingCount; + } + + public void setRemainingCount(int remainingCount) { + this.remainingCount = remainingCount; + } + + public int getLimit() { + return limit; + } + + public void setLimit(int limit) { + this.limit = limit; + } + + public String getGateway() { + return gateway; + } + + public void setGateway(String gateway) { + this.gateway = gateway; + } +} diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 369673e..c6fde35 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -61,3 +61,14 @@ call.ice.turn.servers.2.password= # Если параметр отсутствует, по умолчанию считается false # ------------------------------------------------------------ debug.tempApi.enabled=true + +# ------------------------------------------------------------ +# Временная тестовая бесплатная загрузка маленьких аватаров в Arweave +# API только для тестового периода. Ключ хранится вне кода в JWK-файле. +# ------------------------------------------------------------ +test.freeAvatar.enabled=true +test.freeAvatar.gateway=https://arweave.net +test.freeAvatar.limitPerUser=3 +test.freeAvatar.maxBytes=131072 +test.freeAvatar.walletAddress= +test.freeAvatar.walletJwkPath= diff --git a/VERSION.properties b/VERSION.properties index 985e3b0..3ad2a6e 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.220 -server.version=1.2.208 +client.version=1.2.230 +server.version=1.2.216 diff --git a/build.gradle b/build.gradle index 4866cff..6c17c5e 100644 --- a/build.gradle +++ b/build.gradle @@ -185,16 +185,14 @@ tasks.named('build') { finalizedBy tasks.named('integrationTest') } -tasks.register('deployServer', JavaExec) { +tasks.register('deployServerProduction', JavaExec) { group = "!!deployment" - description = "Build → upload to server → restart service (без удаления БД, без IT тестов)" + description = "Production deploy: build → upload to shineup.me → restart service (только после явного подтверждения)" classpath = sourceSets.test.runtimeClasspath mainClass = "test.it.IT_DeployRestartNoCleanNoTestsMain" workingDir = file('SHiNE-server') - // можно переопределить при запуске: - // ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=... dependsOn shadowJar systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "shineup.me") systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player") @@ -205,13 +203,57 @@ tasks.register('deployServer', JavaExec) { dependsOn testClasses } -tasks.register('deployUI', Exec) { +tasks.register('deployUIProduction', Exec) { group = "!!deployment" - description = "Deploy WEB UI (production: shineup.me)" + description = "Production UI deploy: shineup.me (только после явного подтверждения)" workingDir = rootDir commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath } +tasks.register('deployServer', Exec) { + group = "!!deployment" + description = "Default deploy server: test2.shineup.me" + dependsOn shadowJar + workingDir = rootDir + environment 'LOCAL_JAR', file('SHiNE-server/build/libs/shine-server.jar').absolutePath + commandLine 'bash', file('deploy_shine-server_test2.sh').absolutePath +} + +tasks.register('deployUI', Exec) { + group = "!!deployment" + description = "Default deploy UI: test2.shineup.me" + workingDir = rootDir + commandLine 'bash', file('deploy_shine-ui_test2.sh').absolutePath +} + +tasks.register('deployServerTest2') { + group = "!!deployment" + description = "Явный алиас основного test deploy server: test2.shineup.me" + dependsOn tasks.named('deployServer') +} + +tasks.register('deployUITest2') { + group = "!!deployment" + description = "Явный алиас основного test deploy UI: test2.shineup.me" + dependsOn tasks.named('deployUI') +} + +tasks.register('deployServerTest', Exec) { + group = "!!deployment" + description = "Резервный test deploy: test.shineup.me (пока не использовать)" + dependsOn shadowJar + workingDir = rootDir + environment 'LOCAL_JAR', file('SHiNE-server/build/libs/shine-server.jar').absolutePath + commandLine 'bash', file('deploy_shine-server_test.sh').absolutePath +} + +tasks.register('deployUITest', Exec) { + group = "!!deployment" + description = "Резервный test UI deploy: test.shineup.me (пока не использовать)" + workingDir = rootDir + commandLine 'bash', file('deploy_shine-ui_test.sh').absolutePath +} + tasks.register('startLocal', Exec) { group = "!!run" description = "Builds server, starts local WS server and local HTTP UI for end-to-end local testing" diff --git a/deploy_shine-PWA.sh b/deploy_shine-PWA.sh index 42021df..d857555 100755 --- a/deploy_shine-PWA.sh +++ b/deploy_shine-PWA.sh @@ -24,7 +24,7 @@ if [[ -z "$CLIENT_VERSION" ]]; then fi export CLIENT_VERSION -TARGET_URL="https://shineup.me" +TARGET_URL="${TARGET_URL:-https://${EXPECTED_CADDY_SITE}}" REMOTE_DIR="${REMOTE_UI_DIR}" cleanup() { diff --git a/deploy_shine-server_test.sh b/deploy_shine-server_test.sh new file mode 100644 index 0000000..d6fd7ea --- /dev/null +++ b/deploy_shine-server_test.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROD_HOST="${PROD_HOST:-player@shineup.me}" +TEST_HOST="${TEST_HOST:-player@93.170.12.154}" +TARGET_DOMAIN="${TARGET_DOMAIN:-test.shineup.me}" +REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}" +REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:-$REMOTE_BASE/shine-server}" +REMOTE_UI_DIR="${REMOTE_UI_DIR:-$REMOTE_BASE/shine-ui}" +REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}" +REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}" +REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:-shine-server}" +LOCAL_JAR="${LOCAL_JAR:-build/libs/shine-server.jar}" +PROD_DATA_DIR="${PROD_DATA_DIR:-/home/player/SHiNE/shine-server/data}" +PROD_APP_PROPS="${PROD_APP_PROPS:-/home/player/SHiNE/shine-server/application.properties}" + +TMP_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "ERROR: файл не найден: $path" >&2 + exit 1 + fi +} + +echo "==> Проверка локального jar" +require_file "$LOCAL_JAR" +jar_size="$(stat -c %s "$LOCAL_JAR")" +if [[ "$jar_size" -lt 10485760 ]]; then + echo "ERROR: jar слишком маленький для fat-jar: $jar_size bytes" >&2 + exit 1 +fi + +echo "==> Проверка SSH и sudo" +ssh -o BatchMode=yes -o ConnectTimeout=10 "$PROD_HOST" "echo SSH OK" >/dev/null +ssh -o BatchMode=yes -o ConnectTimeout=10 "$TEST_HOST" "echo SSH OK" >/dev/null +ssh "$TEST_HOST" "sudo -n true" + +echo "==> Подготовка Caddy для $TARGET_DOMAIN" +TEST_HOST="$TEST_HOST" \ +TARGET_DOMAIN="$TARGET_DOMAIN" \ +REMOTE_UI_DIR="$REMOTE_UI_DIR" \ +bash "$(dirname "$0")/scripts/install_test_caddyfile.sh" + +echo "==> Забираем продовые данные и application.properties" +mkdir -p "$TMP_DIR/data" +rsync -az --delete "$PROD_HOST:$PROD_DATA_DIR/" "$TMP_DIR/data/" +scp -p "$PROD_HOST:$PROD_APP_PROPS" "$TMP_DIR/application.properties" >/dev/null + +if grep -q '^server\.ui\.indexPath=' "$TMP_DIR/application.properties"; then + perl -0pi -e 's@^server\.ui\.indexPath=.*$@server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html@m' "$TMP_DIR/application.properties" +else + printf '\nserver.ui.indexPath=/home/player/SHiNE/shine-ui/index.html\n' >>"$TMP_DIR/application.properties" +fi + +cat >"$TMP_DIR/shine-server.service" < Останавливаем текущий сервер на тестовом хосте" +ssh "$TEST_HOST" "sudo systemctl stop $REMOTE_SERVICE_NAME || true" + +echo "==> Создаём каталоги" +ssh "$TEST_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR' '$REMOTE_UI_DIR' '$REMOTE_BASE/caddy'" + +echo "==> Копируем продовую БД и blockchain-данные" +rsync -az --delete "$TMP_DIR/data/" "$TEST_HOST:$REMOTE_DATA_DIR/" + +echo "==> Загружаем новый jar и конфиг" +rsync -az --timeout=120 "$LOCAL_JAR" "$TEST_HOST:$REMOTE_SERVER_DIR/shine-server.jar.new" +rsync -az --timeout=30 "$TMP_DIR/application.properties" "$TEST_HOST:$REMOTE_SERVER_DIR/application.properties.new" +rsync -az --timeout=30 "$TMP_DIR/shine-server.service" "$TEST_HOST:/tmp/shine-server.service.new" + +echo "==> Применяем systemd unit и файлы сервера" +ssh "$TEST_HOST" "set -euo pipefail; \ + mv -f '$REMOTE_SERVER_DIR/shine-server.jar.new' '$REMOTE_SERVER_DIR/shine-server.jar'; \ + mv -f '$REMOTE_SERVER_DIR/application.properties.new' '$REMOTE_SERVER_DIR/application.properties'; \ + sudo mv -f /tmp/shine-server.service.new /etc/systemd/system/shine-server.service; \ + sudo chown root:root /etc/systemd/system/shine-server.service; \ + chmod 644 '$REMOTE_SERVER_DIR/application.properties'; \ + chmod 664 '$REMOTE_SERVER_DIR/shine-server.jar'; \ + mkdir -p '$REMOTE_LOGS_DIR'; \ + touch '$REMOTE_LOGS_DIR/app.log'; \ + chown -R player:player '$REMOTE_SERVER_DIR'; \ + sudo systemctl daemon-reload; \ + sudo systemctl enable '$REMOTE_SERVICE_NAME'; \ + sudo systemctl restart '$REMOTE_SERVICE_NAME'" + +echo "==> Ждём порт 7070" +for _ in $(seq 1 50); do + if ssh "$TEST_HOST" "ss -ltn '( sport = :7070 )' | grep -q 7070"; then + echo "==> Порт 7070 поднялся" + break + fi + sleep 1 +done + +if ! ssh "$TEST_HOST" "ss -ltn '( sport = :7070 )' | grep -q 7070"; then + echo "ERROR: тестовый сервер не поднял порт 7070" >&2 + exit 1 +fi + +echo "==> Проверяем статус сервиса" +ssh "$TEST_HOST" "sudo systemctl --no-pager --full status '$REMOTE_SERVICE_NAME' | sed -n '1,20p'" + +echo "test_server_deploy_done" diff --git a/deploy_shine-server_test2.sh b/deploy_shine-server_test2.sh new file mode 100644 index 0000000..d2bf746 --- /dev/null +++ b/deploy_shine-server_test2.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROD_HOST="${PROD_HOST:-player@shineup.me}" +TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}" +TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}" +REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}" +REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:-$REMOTE_BASE/shine-server}" +REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}" +REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}" +REMOTE_UI_DIR="${REMOTE_UI_DIR:-$REMOTE_BASE/shine-ui}" +REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:-shine-server}" +LOCAL_JAR="${LOCAL_JAR:-SHiNE-server/build/libs/shine-server.jar}" +PROD_DATA_DIR="${PROD_DATA_DIR:-/home/player/SHiNE/shine-server/data}" +PROD_APP_PROPS="${PROD_APP_PROPS:-/home/player/SHiNE/shine-server/application.properties}" + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +if [[ ! -f "$LOCAL_JAR" ]]; then + echo "ERROR: локальный jar не найден: $LOCAL_JAR" >&2 + exit 1 +fi + +ssh -o BatchMode=yes -o ConnectTimeout=20 "$PROD_HOST" "echo SSH OK" >/dev/null +ssh -o BatchMode=yes -o ConnectTimeout=20 "$TARGET_HOST" "echo SSH OK" >/dev/null +ssh "$TARGET_HOST" "sudo -n true" +ssh "$TARGET_HOST" "java -version >/dev/null 2>&1" + +mkdir -p "$TMP_DIR/data" +rsync -az --delete "$PROD_HOST:$PROD_DATA_DIR/" "$TMP_DIR/data/" +rsync -az "$PROD_HOST:$PROD_APP_PROPS" "$TMP_DIR/application.properties" +perl -0pi -e 's@^server\.ui\.indexPath=.*$@server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html@m' "$TMP_DIR/application.properties" + +cat >"$TMP_DIR/shine-server.service" < Подготовка Caddy для $TARGET_DOMAIN" +TEST_HOST="$TEST_HOST" \ +TARGET_DOMAIN="$TARGET_DOMAIN" \ +REMOTE_UI_DIR="$REMOTE_UI_DIR" \ +bash "$(dirname "$0")/scripts/install_test_caddyfile.sh" + +echo "==> Деплой UI на $TARGET_DOMAIN" +REMOTE_HOST="$TEST_HOST" \ +REMOTE_UI_DIR="$REMOTE_UI_DIR" \ +EXPECTED_CADDY_UI_ROOT="$REMOTE_UI_DIR" \ +EXPECTED_CADDY_SITE="$TARGET_DOMAIN" \ +TARGET_URL="https://$TARGET_DOMAIN" \ +bash "$(dirname "$0")/deploy_shine-PWA.sh" diff --git a/deploy_shine-ui_test2.sh b/deploy_shine-ui_test2.sh new file mode 100644 index 0000000..91bb06c --- /dev/null +++ b/deploy_shine-ui_test2.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}" +TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}" +REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}" + +TARGET_HOST="$TARGET_HOST" TARGET_DOMAIN="$TARGET_DOMAIN" REMOTE_UI_DIR="$REMOTE_UI_DIR" \ + bash "$(dirname "$0")/scripts/install_test2_caddyfile.sh" + +REMOTE_HOST="$TARGET_HOST" \ +REMOTE_UI_DIR="$REMOTE_UI_DIR" \ +EXPECTED_CADDY_UI_ROOT="$REMOTE_UI_DIR" \ +EXPECTED_CADDY_SITE="$TARGET_DOMAIN" \ +TARGET_URL="https://$TARGET_DOMAIN" \ +bash "$(dirname "$0")/deploy_shine-PWA.sh" + +ssh "$TARGET_HOST" "sudo chmod o+x /home/player /home/player/SHiNE '$REMOTE_UI_DIR'; \ + sudo find '$REMOTE_UI_DIR' -type d -exec chmod o+rx {} +; \ + sudo find '$REMOTE_UI_DIR' -type f -exec chmod o+r {} +" + diff --git a/scripts/install_test2_caddyfile.sh b/scripts/install_test2_caddyfile.sh new file mode 100644 index 0000000..d28f505 --- /dev/null +++ b/scripts/install_test2_caddyfile.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}" +TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}" +REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}" +REMOTE_CADDYFILE="${REMOTE_CADDYFILE:-/etc/caddy/Caddyfile}" + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +cat >"$TMP_DIR/Caddyfile" </dev/null +ssh "$TARGET_HOST" "sudo -n true" +rsync -az "$TMP_DIR/Caddyfile" "$TARGET_HOST:/tmp/caddy-test2.new" +ssh "$TARGET_HOST" "set -euo pipefail; \ + sudo mv -f /tmp/caddy-test2.new '$REMOTE_CADDYFILE'; \ + sudo chown root:root '$REMOTE_CADDYFILE'; \ + sudo caddy validate --config '$REMOTE_CADDYFILE'; \ + sudo systemctl restart caddy" + diff --git a/scripts/install_test_caddyfile.sh b/scripts/install_test_caddyfile.sh new file mode 100644 index 0000000..9c47d92 --- /dev/null +++ b/scripts/install_test_caddyfile.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_HOST="${TEST_HOST:-player@93.170.12.154}" +TARGET_DOMAIN="${TARGET_DOMAIN:-test.shineup.me}" +REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}" +REMOTE_CADDYFILE="${REMOTE_CADDYFILE:-/etc/caddy/Caddyfile}" + +TMP_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +cat >"$TMP_DIR/Caddyfile" < Проверка SSH и sudo на $TEST_HOST" +ssh -o BatchMode=yes -o ConnectTimeout=10 "$TEST_HOST" "echo SSH OK" >/dev/null +ssh "$TEST_HOST" "sudo -n true" + +echo "==> Установка Caddy-конфига для $TARGET_DOMAIN" +scp -p "$TMP_DIR/Caddyfile" "$TEST_HOST:/tmp/shine-test-caddyfile.new" >/dev/null +ssh "$TEST_HOST" "sudo mkdir -p \"$(dirname "$REMOTE_CADDYFILE")\" && \ + sudo mv -f /tmp/shine-test-caddyfile.new \"$REMOTE_CADDYFILE\" && \ + sudo chown root:root \"$REMOTE_CADDYFILE\" && \ + sudo caddy validate --config \"$REMOTE_CADDYFILE\" && \ + sudo systemctl reload caddy" + +echo "==> Caddy настроен для $TARGET_DOMAIN" diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 8e851ee..22b61b1 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -29,12 +29,14 @@ import { addSignedMessageToChat, markIncomingReadByBaseKey, markOutgoingReadByBaseKey, + normalizeDmChatId, setContacts, } from './state.js'; import * as startView from './pages/start-view.js?v=202606142105'; import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240'; -import * as registerView from './pages/register-view.js'; +import * as registerView from './pages/register-view.js?v=202606201650'; +import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650'; import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940'; import * as registrationKeysView from './pages/registration-keys-view.js'; import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js'; @@ -43,7 +45,7 @@ import * as devnetTopupView from './pages/devnet-topup-view.js'; import * as loginView from './pages/login-view.js?v=202606150110'; import * as loginCameraView from './pages/login-camera-view.js'; import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940'; -import * as loginPasswordView from './pages/login-password-view.js'; +import * as loginPasswordView from './pages/login-password-view.js?v=202606201650'; import * as keyStorageView from './pages/key-storage-view.js'; import * as profileView from './pages/profile-view.js'; @@ -81,6 +83,7 @@ const routes = { 'start-view': startView, 'entry-settings-view': entrySettingsView, 'register-view': registerView, + 'registration-faq-view': registrationFaqView, 'registration-payment-view': registrationPaymentView, 'registration-keys-view': registrationKeysView, 'registration-draft-keys-view': registrationDraftKeysView, @@ -910,7 +913,7 @@ async function init() { const fromLogin = parsed.fromLogin || ''; const toLogin = parsed.toLogin || ''; const messageType = Number(parsed.messageType || 0); - const chatId = messageType === 2 ? toLogin : fromLogin; + const chatId = normalizeDmChatId(messageType === 2 ? toLogin : fromLogin); const text = (messageType === 1 || messageType === 2) ? String(parsed.text || '') : ''; diff --git a/shine-UI/js/components/avatar-wizard.js b/shine-UI/js/components/avatar-wizard.js index 783c523..2e9ef06 100644 --- a/shine-UI/js/components/avatar-wizard.js +++ b/shine-UI/js/components/avatar-wizard.js @@ -1,3 +1,4 @@ +import { authService } from '../state.js'; import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js'; import { buildArweaveDataUrl, @@ -9,8 +10,11 @@ import { validateSha256Hex, validateAvatarSourceFile, } from '../services/arweave-file-service.js'; +import { bytesToBase64 } from '../services/crypto-utils.js'; import { saveProfileAvatarArweave } from '../services/user-profile-params.js'; +const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024; + function escapeHtml(text) { return String(text || '') .replaceAll('&', '&') @@ -72,6 +76,8 @@ export function openAvatarWizard({ let priceInfo = null; let uploadedTxId = ''; let uploadedSha256Hex = ''; + let uploadedInfoText = ''; + let freeQuotaInfo = null; function revokePreviewUrl() { if (!lastPreviewUrl) return; @@ -105,10 +111,11 @@ export function openAvatarWizard({