Compare commits
2 Commits
d0e7998650
...
ecc9efd434
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
ecc9efd434 | ||
|
|
dd35e56029 |
@ -15,6 +15,8 @@
|
||||
| `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) |
|
||||
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна |
|
||||
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
|
||||
| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара |
|
||||
| `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave |
|
||||
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
|
||||
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
|
||||
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
|
||||
|
||||
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);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
|
||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
last_tx_id TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
|
||||
ON test_free_avatar_uploads (updated_at_ms);
|
||||
""");
|
||||
|
||||
// 5. blockchain_state
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS blockchain_state (
|
||||
|
||||
@ -14,7 +14,7 @@ import java.sql.Statement;
|
||||
public final class SqliteDbController {
|
||||
|
||||
private static volatile SqliteDbController instance;
|
||||
private static final int LATEST_SCHEMA_VERSION = 7;
|
||||
private static final int LATEST_SCHEMA_VERSION = 8;
|
||||
|
||||
private final String jdbcUrl;
|
||||
|
||||
@ -90,6 +90,7 @@ public final class SqliteDbController {
|
||||
case 5 -> migrateToV5();
|
||||
case 6 -> migrateToV6();
|
||||
case 7 -> migrateToV7();
|
||||
case 8 -> migrateToV8();
|
||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||
}
|
||||
}
|
||||
@ -249,6 +250,25 @@ public final class SqliteDbController {
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV8() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureTestFreeAvatarUploadsTable(st);
|
||||
setSchemaVersion(c, 8);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v8 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v8 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||
@ -432,6 +452,22 @@ public final class SqliteDbController {
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureTestFreeAvatarUploadsTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
|
||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
last_tx_id TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
|
||||
ON test_free_avatar_uploads (updated_at_ms);
|
||||
""");
|
||||
}
|
||||
|
||||
|
||||
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
|
||||
@ -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.Net_GetUser_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
|
||||
|
||||
// --- NEW: SearchUsers ---
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
|
||||
@ -127,6 +131,8 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("AddUser", new Net_AddUser_Handler()),
|
||||
Map.entry("GetUser", new Net_GetUser_Handler()),
|
||||
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
|
||||
Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
|
||||
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
|
||||
|
||||
// --- auth ---
|
||||
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
|
||||
@ -200,6 +206,8 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("AddUser", Net_AddUser_Request.class),
|
||||
Map.entry("GetUser", Net_GetUser_Request.class),
|
||||
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
|
||||
Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
|
||||
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
|
||||
|
||||
// --- auth ---
|
||||
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
|
||||
|
||||
@ -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
|
||||
# ------------------------------------------------------------
|
||||
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
|
||||
server.version=1.2.212
|
||||
client.version=1.2.227
|
||||
server.version=1.2.213
|
||||
|
||||
@ -34,8 +34,8 @@ import {
|
||||
|
||||
import * as startView from './pages/start-view.js?v=202606142105';
|
||||
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
|
||||
import * as registerView from './pages/register-view.js';
|
||||
import * as registrationFaqView from './pages/registration-faq-view.js';
|
||||
import * as registerView from './pages/register-view.js?v=202606201650';
|
||||
import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650';
|
||||
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
|
||||
import * as registrationKeysView from './pages/registration-keys-view.js';
|
||||
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
|
||||
@ -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 loginCameraView from './pages/login-camera-view.js';
|
||||
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
|
||||
import * as loginPasswordView from './pages/login-password-view.js';
|
||||
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
|
||||
import * as keyStorageView from './pages/key-storage-view.js';
|
||||
|
||||
import * as profileView from './pages/profile-view.js';
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { authService } from '../state.js';
|
||||
import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
|
||||
import {
|
||||
buildArweaveDataUrl,
|
||||
@ -9,8 +10,11 @@ import {
|
||||
validateSha256Hex,
|
||||
validateAvatarSourceFile,
|
||||
} from '../services/arweave-file-service.js';
|
||||
import { bytesToBase64 } from '../services/crypto-utils.js';
|
||||
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
|
||||
|
||||
const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024;
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
@ -72,6 +76,8 @@ export function openAvatarWizard({
|
||||
let priceInfo = null;
|
||||
let uploadedTxId = '';
|
||||
let uploadedSha256Hex = '';
|
||||
let uploadedInfoText = '';
|
||||
let freeQuotaInfo = null;
|
||||
|
||||
function revokePreviewUrl() {
|
||||
if (!lastPreviewUrl) return;
|
||||
@ -105,10 +111,11 @@ export function openAvatarWizard({
|
||||
<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">Вы можете использовать уже загруженный файл Arweave или загрузить новый файл.</p>
|
||||
<p class="meta-muted">Вы можете использовать уже загруженный файл Arweave, загрузить новый файл со своего кошелька или попробовать временную бесплатную загрузку через сервер.</p>
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
@ -121,6 +128,7 @@ export function openAvatarWizard({
|
||||
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
|
||||
root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput);
|
||||
root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); });
|
||||
root.querySelector('[data-action="upload-free"]')?.addEventListener('click', () => { void showStepFreeUpload(); });
|
||||
};
|
||||
|
||||
const showStepExistingInput = () => {
|
||||
@ -339,6 +347,7 @@ export function openAvatarWizard({
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
try {
|
||||
uploadedInfoText = '';
|
||||
const uploaded = await uploadArweaveFile({
|
||||
gateway: cleanGateway,
|
||||
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 = () => {
|
||||
if (closed) return;
|
||||
root.innerHTML = `
|
||||
@ -373,6 +545,7 @@ export function openAvatarWizard({
|
||||
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
|
||||
<p class="meta-muted">SHA-256:</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>
|
||||
<div class="avatar-wizard-actions">
|
||||
<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');
|
||||
preview.className = 'status-line';
|
||||
|
||||
section.append(grid, hint, preview);
|
||||
section.append(grid, hint);
|
||||
return { section, inputs, preview };
|
||||
}
|
||||
|
||||
@ -119,24 +119,13 @@ export function render({ navigate }) {
|
||||
hint.className = 'meta-muted';
|
||||
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');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
|
||||
const testLoginsHint = document.createElement('p');
|
||||
testLoginsHint.className = 'meta-muted';
|
||||
testLoginsHint.textContent = 'Основные тестовые логины: 1, 2, 3 (вход без пароля).';
|
||||
let passwordField = null;
|
||||
const passwordLengthText = document.createElement('p');
|
||||
passwordLengthText.className = 'status-line';
|
||||
|
||||
function getCurrentPassword() {
|
||||
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
|
||||
@ -150,15 +139,17 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
function updateWordsPreview() {
|
||||
const password = composePasswordFromWords(passwordWords);
|
||||
const nonEmptyCount = normalizePasswordWords(passwordWords).filter((word) => word.trim()).length;
|
||||
wordsPreview.textContent = `Заполнено слов: ${nonEmptyCount} из 12 · итоговая длина пароля: ${password.length} символов.`;
|
||||
const password = getCurrentPassword();
|
||||
const text = `Итоговая длина пароля: ${password.length} символов.`;
|
||||
wordsPreview.textContent = text;
|
||||
passwordLengthText.textContent = text;
|
||||
}
|
||||
|
||||
function updatePasswordModeVisibility() {
|
||||
const wordsMode = passwordMode === 'words';
|
||||
wordsSection.hidden = !wordsMode;
|
||||
passwordInput.parentElement.hidden = wordsMode;
|
||||
wordsSection.style.display = wordsMode ? 'grid' : 'none';
|
||||
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
|
||||
passwordInput.style.display = wordsMode ? 'none' : '';
|
||||
updateWordsPreview();
|
||||
}
|
||||
|
||||
@ -167,8 +158,9 @@ export function render({ navigate }) {
|
||||
<label class="stack"><span class="field-label">Пароль</span></label>
|
||||
`;
|
||||
form.children[0].append(loginInput);
|
||||
form.children[1].append(passwordInput);
|
||||
form.append(passwordModeToggle, wordsSection, hint, advanced, status, testLoginsHint);
|
||||
passwordField = form.children[1];
|
||||
passwordField.append(passwordInput);
|
||||
form.append(passwordModeToggle, wordsSection, passwordLengthText, hint, status);
|
||||
updatePasswordModeVisibility();
|
||||
syncDraftState();
|
||||
|
||||
|
||||
@ -599,8 +599,6 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
async function onChangeAvatarClick() {
|
||||
const confirmed = window.confirm('Сменить аватар?');
|
||||
if (!confirmed) return;
|
||||
status.className = 'status-line';
|
||||
status.textContent = 'Открываем мастер аватара...';
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
PASSWORD_MAX_LENGTH,
|
||||
PASSWORD_WORDS_COUNT,
|
||||
} from '../services/password-words.js';
|
||||
import { openRegistrationFaq, REGISTRATION_FAQ_TOPICS } from './registration-faq-view.js';
|
||||
import { openRegistrationFaq } from './registration-faq-view.js';
|
||||
|
||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||
|
||||
@ -55,7 +55,7 @@ function createWordsLayout({ words, onInput }) {
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'status-line';
|
||||
|
||||
section.append(grid, hint, preview);
|
||||
section.append(grid, hint);
|
||||
return { section, inputs, preview };
|
||||
}
|
||||
|
||||
@ -137,35 +137,20 @@ export function render({ navigate }) {
|
||||
|
||||
const faqText = document.createElement('p');
|
||||
faqText.className = 'meta-muted';
|
||||
faqText.textContent = 'Нажмите на вопрос, чтобы открыть отдельный экран с кратким объяснением.';
|
||||
faqText.textContent = 'Если хотите подробнее понять схему деривации, ключи, первый сервер и формат 12 слов, откройте отдельный экран с вопросами.';
|
||||
|
||||
const faqButtons = document.createElement('div');
|
||||
faqButtons.className = 'registration-faq-grid';
|
||||
REGISTRATION_FAQ_TOPICS.forEach((topic) => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'ghost-btn';
|
||||
button.type = 'button';
|
||||
button.textContent = topic.shortTitle;
|
||||
button.addEventListener('click', () => openRegistrationFaq(navigate, topic.id));
|
||||
faqButtons.append(button);
|
||||
});
|
||||
faqCard.append(faqTitle, faqText, faqButtons);
|
||||
const faqButton = document.createElement('button');
|
||||
faqButton.className = 'ghost-btn';
|
||||
faqButton.type = 'button';
|
||||
faqButton.textContent = 'Частые вопросы';
|
||||
faqButton.addEventListener('click', () => openRegistrationFaq(navigate, 'key-derivation'));
|
||||
|
||||
faqCard.append(faqTitle, faqText, faqButton);
|
||||
|
||||
const formError = document.createElement('p');
|
||||
formError.className = 'status-line is-unavailable';
|
||||
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');
|
||||
checkButton.className = 'ghost-btn';
|
||||
checkButton.type = 'button';
|
||||
@ -185,6 +170,9 @@ export function render({ navigate }) {
|
||||
nextButton.type = 'button';
|
||||
nextButton.textContent = 'Далее';
|
||||
|
||||
let passwordField = null;
|
||||
const passwordLengthText = document.createElement('p');
|
||||
passwordLengthText.className = 'status-line';
|
||||
let lastCheckedLogin = '';
|
||||
let lastCheckedFree = false;
|
||||
let lastCheckedClassName = '';
|
||||
@ -195,15 +183,17 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
function updateWordsPreview() {
|
||||
const password = composePasswordFromWords(passwordWords);
|
||||
const nonEmptyCount = normalizePasswordWords(passwordWords).filter((word) => word.trim()).length;
|
||||
wordsPreview.textContent = `Заполнено слов: ${nonEmptyCount} из 12 · итоговая длина пароля: ${password.length} символов.`;
|
||||
const password = getCurrentPassword();
|
||||
const text = `Итоговая длина пароля: ${password.length} символов.`;
|
||||
wordsPreview.textContent = text;
|
||||
passwordLengthText.textContent = text;
|
||||
}
|
||||
|
||||
function updatePasswordModeVisibility() {
|
||||
const wordsMode = passwordMode === 'words';
|
||||
wordsSection.hidden = !wordsMode;
|
||||
passwordInput.parentElement.hidden = wordsMode;
|
||||
wordsSection.style.display = wordsMode ? 'grid' : 'none';
|
||||
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
|
||||
passwordInput.style.display = wordsMode ? 'none' : '';
|
||||
updateWordsPreview();
|
||||
}
|
||||
|
||||
@ -365,9 +355,11 @@ export function render({ navigate }) {
|
||||
<label class="stack"><span class="field-label">Логин</span></label>
|
||||
<label class="stack registration-password-single"><span class="field-label">Пароль</span></label>
|
||||
`;
|
||||
form.children[0].append(loginInput);
|
||||
form.children[1].append(passwordInput);
|
||||
form.append(passwordModeToggle, wordsSection, serverNotice, checkButton, statusText, faqCard, advanced, formError);
|
||||
const loginField = form.children[0];
|
||||
passwordField = form.children[1];
|
||||
loginField.append(loginInput);
|
||||
passwordField.append(passwordInput);
|
||||
form.append(passwordModeToggle, wordsSection, passwordLengthText, serverNotice, checkButton, statusText, faqCard, formError);
|
||||
actions.innerHTML = '';
|
||||
actions.append(backButton, nextButton);
|
||||
updatePasswordModeVisibility();
|
||||
|
||||
@ -2208,6 +2208,22 @@ export class AuthService {
|
||||
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 }) {
|
||||
const cleanKind = String(kind || '').trim().toLowerCase();
|
||||
const kinds = CONNECTION_SUBTYPES[cleanKind];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user