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/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/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 e7a1d09..9263a2d 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.226 -server.version=1.2.212 +client.version=1.2.227 +server.version=1.2.213 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({