Compare commits
No commits in common. "ecc9efd4344409052ecb0e77d5db9563805e9e8608886e48abac86c78e548a3b" and "d0e7998650e773e1de7228ebd17d95b17569dff9ad6a00b4aaee37aa7bb098fa" have entirely different histories.
ecc9efd434
...
d0e7998650
@ -15,8 +15,6 @@
|
|||||||
| `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 для входа в существующую сессию |
|
||||||
|
|||||||
@ -1,176 +0,0 @@
|
|||||||
# Временное 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...`;
|
|
||||||
- сценарий нужен как переходный бесплатный путь для маленьких аватаров.
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
# Временная бесплатная загрузка аватара в 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,21 +264,6 @@ 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 = 8;
|
private static final int LATEST_SCHEMA_VERSION = 7;
|
||||||
|
|
||||||
private final String jdbcUrl;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@ -90,7 +90,6 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -250,25 +249,6 @@ 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 (
|
||||||
@ -452,22 +432,6 @@ 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("""
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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,11 +45,7 @@ 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;
|
||||||
@ -131,8 +127,6 @@ 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()),
|
||||||
@ -206,8 +200,6 @@ 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),
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,391 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
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 {
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
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,14 +61,3 @@ 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.227
|
client.version=1.2.226
|
||||||
server.version=1.2.213
|
server.version=1.2.212
|
||||||
|
|||||||
@ -34,8 +34,8 @@ import {
|
|||||||
|
|
||||||
import * as startView from './pages/start-view.js?v=202606142105';
|
import * as startView from './pages/start-view.js?v=202606142105';
|
||||||
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
|
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
|
||||||
import * as registerView from './pages/register-view.js?v=202606201650';
|
import * as registerView from './pages/register-view.js';
|
||||||
import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650';
|
import * as registrationFaqView from './pages/registration-faq-view.js';
|
||||||
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
|
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
|
||||||
import * as registrationKeysView from './pages/registration-keys-view.js';
|
import * as registrationKeysView from './pages/registration-keys-view.js';
|
||||||
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
|
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
|
||||||
@ -44,7 +44,7 @@ import * as devnetTopupView from './pages/devnet-topup-view.js';
|
|||||||
import * as loginView from './pages/login-view.js?v=202606150110';
|
import * as loginView from './pages/login-view.js?v=202606150110';
|
||||||
import * as loginCameraView from './pages/login-camera-view.js';
|
import * as loginCameraView from './pages/login-camera-view.js';
|
||||||
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
|
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
|
||||||
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
|
import * as loginPasswordView from './pages/login-password-view.js';
|
||||||
import * as keyStorageView from './pages/key-storage-view.js';
|
import * as keyStorageView from './pages/key-storage-view.js';
|
||||||
|
|
||||||
import * as profileView from './pages/profile-view.js';
|
import * as profileView from './pages/profile-view.js';
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
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,
|
||||||
@ -10,11 +9,8 @@ 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('&', '&')
|
||||||
@ -76,8 +72,6 @@ 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;
|
||||||
@ -111,11 +105,10 @@ 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>
|
||||||
@ -128,7 +121,6 @@ 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 = () => {
|
||||||
@ -347,7 +339,6 @@ 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,
|
||||||
@ -372,169 +363,6 @@ 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 = `
|
||||||
@ -545,7 +373,6 @@ 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>
|
||||||
|
|||||||
@ -53,7 +53,7 @@ function createWordsLayout({ words, onInput }) {
|
|||||||
const preview = document.createElement('p');
|
const preview = document.createElement('p');
|
||||||
preview.className = 'status-line';
|
preview.className = 'status-line';
|
||||||
|
|
||||||
section.append(grid, hint);
|
section.append(grid, hint, preview);
|
||||||
return { section, inputs, preview };
|
return { section, inputs, preview };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,13 +119,24 @@ export function render({ navigate }) {
|
|||||||
hint.className = 'meta-muted';
|
hint.className = 'meta-muted';
|
||||||
hint.textContent = 'Введите логин. На следующем шаге сохраните ключи на устройстве.';
|
hint.textContent = 'Введите логин. На следующем шаге сохраните ключи на устройстве.';
|
||||||
|
|
||||||
|
const advanced = document.createElement('details');
|
||||||
|
advanced.className = 'card stack';
|
||||||
|
advanced.innerHTML = `
|
||||||
|
<summary>Расширенные</summary>
|
||||||
|
<p class="meta-muted">Схема деривации: логин нормализуется как trim().toLowerCase(). При непустом пароле используется Argon2id, затем из результата строится секрет.</p>
|
||||||
|
<p class="meta-muted">Из секрета строятся root key, blockchain key и device key. Обычно можно не вникать в это подробно и просто хранить всё на своём устройстве.</p>
|
||||||
|
<p class="meta-muted">Режим 12 слов ничего не меняет в протоколе: слова просто склеиваются в один обычный пароль длиной до 256 символов.</p>
|
||||||
|
<p class="meta-muted">Если пароль пустой — используется прежний детерминированный режим совместимости.</p>
|
||||||
|
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
const status = document.createElement('p');
|
const status = document.createElement('p');
|
||||||
status.className = 'status-line is-unavailable';
|
status.className = 'status-line is-unavailable';
|
||||||
status.style.display = 'none';
|
status.style.display = 'none';
|
||||||
|
|
||||||
let passwordField = null;
|
const testLoginsHint = document.createElement('p');
|
||||||
const passwordLengthText = document.createElement('p');
|
testLoginsHint.className = 'meta-muted';
|
||||||
passwordLengthText.className = 'status-line';
|
testLoginsHint.textContent = 'Основные тестовые логины: 1, 2, 3 (вход без пароля).';
|
||||||
|
|
||||||
function getCurrentPassword() {
|
function getCurrentPassword() {
|
||||||
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
|
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
|
||||||
@ -139,17 +150,15 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateWordsPreview() {
|
function updateWordsPreview() {
|
||||||
const password = getCurrentPassword();
|
const password = composePasswordFromWords(passwordWords);
|
||||||
const text = `Итоговая длина пароля: ${password.length} символов.`;
|
const nonEmptyCount = normalizePasswordWords(passwordWords).filter((word) => word.trim()).length;
|
||||||
wordsPreview.textContent = text;
|
wordsPreview.textContent = `Заполнено слов: ${nonEmptyCount} из 12 · итоговая длина пароля: ${password.length} символов.`;
|
||||||
passwordLengthText.textContent = text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePasswordModeVisibility() {
|
function updatePasswordModeVisibility() {
|
||||||
const wordsMode = passwordMode === 'words';
|
const wordsMode = passwordMode === 'words';
|
||||||
wordsSection.style.display = wordsMode ? 'grid' : 'none';
|
wordsSection.hidden = !wordsMode;
|
||||||
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
|
passwordInput.parentElement.hidden = wordsMode;
|
||||||
passwordInput.style.display = wordsMode ? 'none' : '';
|
|
||||||
updateWordsPreview();
|
updateWordsPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,9 +167,8 @@ export function render({ navigate }) {
|
|||||||
<label class="stack"><span class="field-label">Пароль</span></label>
|
<label class="stack"><span class="field-label">Пароль</span></label>
|
||||||
`;
|
`;
|
||||||
form.children[0].append(loginInput);
|
form.children[0].append(loginInput);
|
||||||
passwordField = form.children[1];
|
form.children[1].append(passwordInput);
|
||||||
passwordField.append(passwordInput);
|
form.append(passwordModeToggle, wordsSection, hint, advanced, status, testLoginsHint);
|
||||||
form.append(passwordModeToggle, wordsSection, passwordLengthText, hint, status);
|
|
||||||
updatePasswordModeVisibility();
|
updatePasswordModeVisibility();
|
||||||
syncDraftState();
|
syncDraftState();
|
||||||
|
|
||||||
|
|||||||
@ -599,6 +599,8 @@ 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 = 'Открываем мастер аватара...';
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
PASSWORD_MAX_LENGTH,
|
PASSWORD_MAX_LENGTH,
|
||||||
PASSWORD_WORDS_COUNT,
|
PASSWORD_WORDS_COUNT,
|
||||||
} from '../services/password-words.js';
|
} from '../services/password-words.js';
|
||||||
import { openRegistrationFaq } from './registration-faq-view.js';
|
import { openRegistrationFaq, REGISTRATION_FAQ_TOPICS } from './registration-faq-view.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||||
|
|
||||||
@ -55,7 +55,7 @@ function createWordsLayout({ words, onInput }) {
|
|||||||
const preview = document.createElement('p');
|
const preview = document.createElement('p');
|
||||||
preview.className = 'status-line';
|
preview.className = 'status-line';
|
||||||
|
|
||||||
section.append(grid, hint);
|
section.append(grid, hint, preview);
|
||||||
return { section, inputs, preview };
|
return { section, inputs, preview };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,20 +137,35 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const faqText = document.createElement('p');
|
const faqText = document.createElement('p');
|
||||||
faqText.className = 'meta-muted';
|
faqText.className = 'meta-muted';
|
||||||
faqText.textContent = 'Если хотите подробнее понять схему деривации, ключи, первый сервер и формат 12 слов, откройте отдельный экран с вопросами.';
|
faqText.textContent = 'Нажмите на вопрос, чтобы открыть отдельный экран с кратким объяснением.';
|
||||||
|
|
||||||
const faqButton = document.createElement('button');
|
const faqButtons = document.createElement('div');
|
||||||
faqButton.className = 'ghost-btn';
|
faqButtons.className = 'registration-faq-grid';
|
||||||
faqButton.type = 'button';
|
REGISTRATION_FAQ_TOPICS.forEach((topic) => {
|
||||||
faqButton.textContent = 'Частые вопросы';
|
const button = document.createElement('button');
|
||||||
faqButton.addEventListener('click', () => openRegistrationFaq(navigate, 'key-derivation'));
|
button.className = 'ghost-btn';
|
||||||
|
button.type = 'button';
|
||||||
faqCard.append(faqTitle, faqText, faqButton);
|
button.textContent = topic.shortTitle;
|
||||||
|
button.addEventListener('click', () => openRegistrationFaq(navigate, topic.id));
|
||||||
|
faqButtons.append(button);
|
||||||
|
});
|
||||||
|
faqCard.append(faqTitle, faqText, faqButtons);
|
||||||
|
|
||||||
const formError = document.createElement('p');
|
const formError = document.createElement('p');
|
||||||
formError.className = 'status-line is-unavailable';
|
formError.className = 'status-line is-unavailable';
|
||||||
formError.style.display = 'none';
|
formError.style.display = 'none';
|
||||||
|
|
||||||
|
const advanced = document.createElement('details');
|
||||||
|
advanced.className = 'card stack';
|
||||||
|
advanced.innerHTML = `
|
||||||
|
<summary>Расширенные</summary>
|
||||||
|
<p class="meta-muted">Схема деривации: логин и пароль проходят через Argon2id, после чего получается главный секрет.</p>
|
||||||
|
<p class="meta-muted">Из этого секрета строятся три ключа: root key для основной публичной записи и важных изменений, blockchain key для подписания действий SHiNE в блокчейне, device key для входа и работы конкретного устройства.</p>
|
||||||
|
<p class="meta-muted">Разделение нужно, чтобы можно было аккуратнее выдавать права устройствам. Но если у вас нет большой суммы на счёте и нет повышенного риска, обычно можно просто хранить всё на своём устройстве.</p>
|
||||||
|
<p class="meta-muted">Режим 12 слов не меняет формат пароля и не меняет API: слова просто склеиваются в одну строку длиной до 256 символов.</p>
|
||||||
|
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MB), p=1, dkLen=32.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
const checkButton = document.createElement('button');
|
const checkButton = document.createElement('button');
|
||||||
checkButton.className = 'ghost-btn';
|
checkButton.className = 'ghost-btn';
|
||||||
checkButton.type = 'button';
|
checkButton.type = 'button';
|
||||||
@ -170,9 +185,6 @@ export function render({ navigate }) {
|
|||||||
nextButton.type = 'button';
|
nextButton.type = 'button';
|
||||||
nextButton.textContent = 'Далее';
|
nextButton.textContent = 'Далее';
|
||||||
|
|
||||||
let passwordField = null;
|
|
||||||
const passwordLengthText = document.createElement('p');
|
|
||||||
passwordLengthText.className = 'status-line';
|
|
||||||
let lastCheckedLogin = '';
|
let lastCheckedLogin = '';
|
||||||
let lastCheckedFree = false;
|
let lastCheckedFree = false;
|
||||||
let lastCheckedClassName = '';
|
let lastCheckedClassName = '';
|
||||||
@ -183,17 +195,15 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateWordsPreview() {
|
function updateWordsPreview() {
|
||||||
const password = getCurrentPassword();
|
const password = composePasswordFromWords(passwordWords);
|
||||||
const text = `Итоговая длина пароля: ${password.length} символов.`;
|
const nonEmptyCount = normalizePasswordWords(passwordWords).filter((word) => word.trim()).length;
|
||||||
wordsPreview.textContent = text;
|
wordsPreview.textContent = `Заполнено слов: ${nonEmptyCount} из 12 · итоговая длина пароля: ${password.length} символов.`;
|
||||||
passwordLengthText.textContent = text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePasswordModeVisibility() {
|
function updatePasswordModeVisibility() {
|
||||||
const wordsMode = passwordMode === 'words';
|
const wordsMode = passwordMode === 'words';
|
||||||
wordsSection.style.display = wordsMode ? 'grid' : 'none';
|
wordsSection.hidden = !wordsMode;
|
||||||
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
|
passwordInput.parentElement.hidden = wordsMode;
|
||||||
passwordInput.style.display = wordsMode ? 'none' : '';
|
|
||||||
updateWordsPreview();
|
updateWordsPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -355,11 +365,9 @@ export function render({ navigate }) {
|
|||||||
<label class="stack"><span class="field-label">Логин</span></label>
|
<label class="stack"><span class="field-label">Логин</span></label>
|
||||||
<label class="stack registration-password-single"><span class="field-label">Пароль</span></label>
|
<label class="stack registration-password-single"><span class="field-label">Пароль</span></label>
|
||||||
`;
|
`;
|
||||||
const loginField = form.children[0];
|
form.children[0].append(loginInput);
|
||||||
passwordField = form.children[1];
|
form.children[1].append(passwordInput);
|
||||||
loginField.append(loginInput);
|
form.append(passwordModeToggle, wordsSection, serverNotice, checkButton, statusText, faqCard, advanced, formError);
|
||||||
passwordField.append(passwordInput);
|
|
||||||
form.append(passwordModeToggle, wordsSection, passwordLengthText, serverNotice, checkButton, statusText, faqCard, formError);
|
|
||||||
actions.innerHTML = '';
|
actions.innerHTML = '';
|
||||||
actions.append(backButton, nextButton);
|
actions.append(backButton, nextButton);
|
||||||
updatePasswordModeVisibility();
|
updatePasswordModeVisibility();
|
||||||
|
|||||||
@ -2208,22 +2208,6 @@ 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