Добавить временную бесплатную загрузку аватаров в Arweave

This commit is contained in:
AidarKC 2026-06-20 21:29:35 +04:00
parent d0e7998650
commit dd35e56029
20 changed files with 1236 additions and 6 deletions

View File

@ -15,6 +15,8 @@
| `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) | | `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) |
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна | | `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна |
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу | | `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 для создания новой сессии | | `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию | | `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |

View File

@ -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...`;
- сценарий нужен как переходный бесплатный путь для маленьких аватаров.

View File

@ -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

View File

@ -264,6 +264,21 @@ public final class DatabaseInitializer {
ON ip_geo_cache (updated_at_ms); 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 // 5. blockchain_state
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS blockchain_state ( CREATE TABLE IF NOT EXISTS blockchain_state (

View File

@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController { public final class SqliteDbController {
private static volatile SqliteDbController instance; 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; private final String jdbcUrl;
@ -90,6 +90,7 @@ public final class SqliteDbController {
case 5 -> migrateToV5(); case 5 -> migrateToV5();
case 6 -> migrateToV6(); case 6 -> migrateToV6();
case 7 -> migrateToV7(); case 7 -> migrateToV7();
case 8 -> migrateToV8();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); 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 { private static void ensureChat200StateTables(Statement st) throws SQLException {
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_state ( 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 { private static void createConnectionsStateTable(Statement st) throws SQLException {
st.executeUpdate(""" st.executeUpdate("""

View File

@ -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")
);
}
}

View File

@ -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;
}
}

View File

@ -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.entyties.Net_AddUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; 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_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 --- // --- NEW: SearchUsers ---
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler; 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("AddUser", new Net_AddUser_Handler()),
Map.entry("GetUser", new Net_GetUser_Handler()), Map.entry("GetUser", new Net_GetUser_Handler()),
Map.entry("SearchUsers", new Net_SearchUsers_Handler()), Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
// --- auth --- // --- auth ---
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()), Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
@ -200,6 +206,8 @@ public final class JsonHandlerRegistry {
Map.entry("AddUser", Net_AddUser_Request.class), Map.entry("AddUser", Net_AddUser_Request.class),
Map.entry("GetUser", Net_GetUser_Request.class), Map.entry("GetUser", Net_GetUser_Request.class),
Map.entry("SearchUsers", Net_SearchUsers_Request.class), Map.entry("SearchUsers", Net_SearchUsers_Request.class),
Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
// --- auth --- // --- auth ---
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class), Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),

View File

@ -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;
}
}

View File

@ -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());
}
}
}

View File

@ -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<String, Object> 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<List<byte[]>> tagList = new ArrayList<>();
List<Map<String, String>> 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<String, Object> 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<String> 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<Map<String, String>> jsonTags, List<List<byte[]>> tagList, String name, String value) {
byte[] nameBytes = utf8(name);
byte[] valueBytes = utf8(value);
Map<String, String> 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<String> 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);
}
}
}

View File

@ -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 {
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -61,3 +61,14 @@ call.ice.turn.servers.2.password=
# Если параметр отсутствует, по умолчанию считается false # Если параметр отсутствует, по умолчанию считается false
# ------------------------------------------------------------ # ------------------------------------------------------------
debug.tempApi.enabled=true 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=

View File

@ -1,2 +1,2 @@
client.version=1.2.226 client.version=1.2.227
server.version=1.2.212 server.version=1.2.213

View File

@ -1,3 +1,4 @@
import { authService } from '../state.js';
import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js'; import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
import { import {
buildArweaveDataUrl, buildArweaveDataUrl,
@ -9,8 +10,11 @@ import {
validateSha256Hex, validateSha256Hex,
validateAvatarSourceFile, validateAvatarSourceFile,
} from '../services/arweave-file-service.js'; } from '../services/arweave-file-service.js';
import { bytesToBase64 } from '../services/crypto-utils.js';
import { saveProfileAvatarArweave } from '../services/user-profile-params.js'; import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024;
function escapeHtml(text) { function escapeHtml(text) {
return String(text || '') return String(text || '')
.replaceAll('&', '&amp;') .replaceAll('&', '&amp;')
@ -72,6 +76,8 @@ export function openAvatarWizard({
let priceInfo = null; let priceInfo = null;
let uploadedTxId = ''; let uploadedTxId = '';
let uploadedSha256Hex = ''; let uploadedSha256Hex = '';
let uploadedInfoText = '';
let freeQuotaInfo = null;
function revokePreviewUrl() { function revokePreviewUrl() {
if (!lastPreviewUrl) return; if (!lastPreviewUrl) return;
@ -105,10 +111,11 @@ export function openAvatarWizard({
<div class="modal" data-avatar-wizard-modal="true"> <div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card"> <div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Сменить аватар</h3> <h3 class="modal-title">Сменить аватар</h3>
<p class="meta-muted">Вы можете использовать уже загруженный файл Arweave или загрузить новый файл.</p> <p class="meta-muted">Вы можете использовать уже загруженный файл Arweave, загрузить новый файл со своего кошелька или попробовать временную бесплатную загрузку через сервер.</p>
<div class="avatar-wizard-choice-grid"> <div class="avatar-wizard-choice-grid">
<button class="primary-btn" type="button" data-action="use-existing">Использовать существующий файл в Arweave</button> <button class="primary-btn" type="button" data-action="use-existing">Использовать существующий файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-new">Загрузить новый файл в Arweave</button> <button class="primary-btn" type="button" data-action="upload-new">Загрузить новый файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-free">Залить аватар бесплатно</button>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button> <button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div> </div>
</div> </div>
@ -121,6 +128,7 @@ export function openAvatarWizard({
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve)); root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput); root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput);
root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); }); root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); });
root.querySelector('[data-action="upload-free"]')?.addEventListener('click', () => { void showStepFreeUpload(); });
}; };
const showStepExistingInput = () => { const showStepExistingInput = () => {
@ -339,6 +347,7 @@ export function openAvatarWizard({
uploadBtn.disabled = true; uploadBtn.disabled = true;
try { try {
uploadedInfoText = '';
const uploaded = await uploadArweaveFile({ const uploaded = await uploadArweaveFile({
gateway: cleanGateway, gateway: cleanGateway,
jwk: walletCtx?.jwk, jwk: walletCtx?.jwk,
@ -363,6 +372,169 @@ export function openAvatarWizard({
}); });
}; };
const showStepFreeLimitExhausted = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Лимит исчерпан</h3>
<p class="meta-muted">Вы исчерпали бесплатный лимит аватарок.</p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
</div>
</div>
</div>
`;
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepFreeUploadForm = () => {
if (closed) return;
const remaining = Number(freeQuotaInfo?.remainingCount || 0);
const limit = Number(freeQuotaInfo?.limit || 3);
const maxBytes = Number(freeQuotaInfo?.maxBytes || DEFAULT_FREE_AVATAR_MAX_BYTES);
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Залить аватар бесплатно</h3>
<p class="meta-muted">Осталось бесплатных загрузок: ${remaining} из ${limit}.</p>
<label class="meta-muted" for="avatar-free-file-input">Выберите изображение</label>
<input class="input" id="avatar-free-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Перед отправкой изображение уменьшается для аватарки. Итоговый файл должен быть не больше ${formatBytes(maxBytes)}.</p>
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<div class="avatar-wizard-meta" data-meta="true"></div>
<p class="meta-muted">Это временная тестовая бесплатная загрузка через серверный кошелёк Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Залить бесплатно</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const fileInput = root.querySelector('#avatar-free-file-input');
const errorEl = root.querySelector('[data-error="true"]');
const metaEl = root.querySelector('[data-meta="true"]');
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
const previewImage = root.querySelector('[data-preview-image="true"]');
const uploadBtn = root.querySelector('[data-action="upload"]');
optimized = null;
uploadedSha256Hex = '';
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
fileInput?.addEventListener('change', async () => {
setNodeText(errorEl, '');
setNodeText(metaEl, '');
uploadBtn.disabled = true;
revokePreviewUrl();
const selectedFile = fileInput.files?.[0] || null;
if (!selectedFile) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
try {
validateAvatarSourceFile(selectedFile);
optimized = await prepareAvatarImageFile(selectedFile);
if (Number(optimized?.file?.size || 0) > maxBytes) {
throw new Error(`После уменьшения файл всё ещё больше ${formatBytes(maxBytes)}. Возьмите более простое изображение.`);
}
lastPreviewUrl = URL.createObjectURL(optimized.file);
previewImage.src = lastPreviewUrl;
previewWrap.hidden = false;
metaEl.innerHTML = `
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
`;
uploadBtn.disabled = false;
} catch (error) {
setNodeText(errorEl, String(error?.message || 'Не удалось подготовить изображение.'));
}
});
uploadBtn?.addEventListener('click', async () => {
setNodeText(errorEl, '');
if (!optimized?.file) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
uploadBtn.disabled = true;
try {
const fileBytes = new Uint8Array(await optimized.file.arrayBuffer());
const uploaded = await authService.uploadTestFreeAvatar({
contentType: optimized.contentType,
fileBytesBase64: bytesToBase64(fileBytes),
sha256Hex: String(optimized.sha256Hex || '').trim().toLowerCase(),
});
uploadedTxId = String(uploaded.txId || '').trim();
uploadedSha256Hex = String(uploaded.sha256Hex || optimized.sha256Hex || '').trim().toLowerCase();
uploadedInfoText = `Осталось бесплатных загрузок: ${Number(uploaded.remainingCount || 0)} из ${Number(uploaded.limit || limit)}.`;
if (!uploadedTxId) {
throw new Error('Сервер не вернул Transaction ID.');
}
showStepUploaded();
} catch (error) {
setNodeText(errorEl, String(error?.message || 'Не удалось бесплатно загрузить аватар.'));
uploadBtn.disabled = false;
}
});
};
const showStepFreeUpload = async () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Подготовка бесплатной загрузки</h3>
<p class="meta-muted" data-loading="true">Проверяем остаток бесплатных загрузок...</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
const loadingEl = root.querySelector('[data-loading="true"]');
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
try {
freeQuotaInfo = await authService.getTestFreeAvatarQuota();
} catch (error) {
setNodeText(errorEl, error?.message || 'Не удалось получить остаток бесплатных загрузок.');
return;
}
if (!freeQuotaInfo?.enabled) {
setNodeText(loadingEl, '');
setNodeText(errorEl, 'Временная бесплатная загрузка аватаров сейчас отключена на сервере.');
return;
}
if (Number(freeQuotaInfo?.remainingCount || 0) <= 0) {
showStepFreeLimitExhausted();
return;
}
showStepFreeUploadForm();
};
const showStepUploaded = () => { const showStepUploaded = () => {
if (closed) return; if (closed) return;
root.innerHTML = ` root.innerHTML = `
@ -373,6 +545,7 @@ export function openAvatarWizard({
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p> <p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
<p class="meta-muted">SHA-256:</p> <p class="meta-muted">SHA-256:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p> <p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
${uploadedInfoText ? `<p class="meta-muted">${escapeHtml(uploadedInfoText)}</p>` : ''}
<p class="avatar-wizard-error" data-error="true"></p> <p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions"> <div class="avatar-wizard-actions">
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button> <button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>

View File

@ -599,8 +599,6 @@ export function render({ navigate }) {
} }
async function onChangeAvatarClick() { async function onChangeAvatarClick() {
const confirmed = window.confirm('Сменить аватар?');
if (!confirmed) return;
status.className = 'status-line'; status.className = 'status-line';
status.textContent = 'Открываем мастер аватара...'; status.textContent = 'Открываем мастер аватара...';

View File

@ -2208,6 +2208,22 @@ export class AuthService {
return response.payload || {}; return response.payload || {};
} }
async getTestFreeAvatarQuota() {
const response = await this.ws.request('TestGetFreeAvatarQuota', {});
if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response);
return response.payload || {};
}
async uploadTestFreeAvatar({ contentType, fileBytesBase64, sha256Hex }) {
const response = await this.ws.request('TestUploadFreeAvatar', {
contentType,
fileBytesBase64,
sha256Hex,
}, 60000);
if (response.status !== 200) throw opError('TestUploadFreeAvatar', response);
return response.payload || {};
}
async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) { async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
const cleanKind = String(kind || '').trim().toLowerCase(); const cleanKind = String(kind || '').trim().toLowerCase();
const kinds = CONNECTION_SUBTYPES[cleanKind]; const kinds = CONNECTION_SUBTYPES[cleanKind];