Добавить временную бесплатную загрузку аватаров в Arweave
This commit is contained in:
parent
d0e7998650
commit
dd35e56029
@ -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 для входа в существующую сессию |
|
||||||
|
|||||||
176
Dev_Docs/API/14_Test_Free_Avatar_Upload_API.md
Normal file
176
Dev_Docs/API/14_Test_Free_Avatar_Upload_API.md
Normal 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...`;
|
||||||
|
- сценарий нужен как переходный бесплатный путь для маленьких аватаров.
|
||||||
@ -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
|
||||||
@ -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 (
|
||||||
|
|||||||
@ -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("""
|
||||||
|
|||||||
@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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=
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.226
|
client.version=1.2.227
|
||||||
server.version=1.2.212
|
server.version=1.2.213
|
||||||
|
|||||||
@ -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('&', '&')
|
.replaceAll('&', '&')
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 = 'Открываем мастер аватара...';
|
||||||
|
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user