From 1f70d36e74758fe1043bdec6687c1d258da064a0f431dbd279ece3f092e7c660 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 8 Sep 2026 17:54:34 +0400 Subject: [PATCH 01/30] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=B8=D0=B6=D0=BD=D1=8E=D1=8E=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D1=83=20=D0=B2=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- bundle-offline.sh | 19 ++++++++++++++++++- shine-UI/js/pages/channels-list.js | 16 +--------------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 595a0b57..778037b3 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.0 +client.version=1.12.1 server.version=1.10.0 diff --git a/bundle-offline.sh b/bundle-offline.sh index 2144f43d..07c1b329 100755 --- a/bundle-offline.sh +++ b/bundle-offline.sh @@ -123,6 +123,23 @@ find_offline_gradle_zip() { fi done + local extracted_dir="" + if [[ -d "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" ]]; then + extracted_dir="$(find "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" -type d -name 'gradle-8.14' | sort | head -n 1 || true)" + fi + if [[ -n "$extracted_dir" && -d "$extracted_dir" ]]; then + local generated_zip="$TMP/gradle-offline.zip" + ( + cd "$(dirname -- "$extracted_dir")" + rm -f -- "$generated_zip" + zip -qr "$generated_zip" "gradle-8.14" + ) + if [[ -f "$generated_zip" ]]; then + printf '%s\n' "$generated_zip" + return 0 + fi + fi + return 1 } @@ -269,7 +286,7 @@ if OFFLINE_ZIP_SRC="$(find_offline_gradle_zip)"; then OFFLINE_ZIP_NAME="gradle-offline.zip" else echo "ERROR: offline Gradle zip not found." >&2 - echo "Place it at ./offline/gradle-offline.zip or set BUNDLE_OFFLINE_GRADLE_ZIP." >&2 + echo "Place it at ./offline/gradle-offline.zip, set BUNDLE_OFFLINE_GRADLE_ZIP, or install Gradle 8.14 locally." >&2 echo "The bundle is not created because this variant is meant to be offline-ready." >&2 exit 4 fi diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index 0aa9ad82..f72bf1ec 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -1094,14 +1094,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed container.append(list); } -function updateBottomCta({ button }) { - if (!button) return; - button.hidden = true; - button.textContent = ''; - button.className = 'channels-bottom-action'; - button.onclick = null; -} - async function loadFeedAndRender({ screen, listState, contentEl, navigate }) { renderSkeletonList(contentEl, 5); @@ -1215,9 +1207,6 @@ export function render({ navigate, route, chrome }) { }); const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn'); - const bottomCta = document.createElement('button'); - bottomCta.type = 'button'; - const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate }); const rerenderList = () => { @@ -1234,18 +1223,15 @@ export function render({ navigate, route, chrome }) { topTitle.textContent = channelsViewTitle(listState.viewMode); topMenuBtn.style.display = ''; - updateBottomCta({ button: bottomCta }); }; chrome?.setTopbar(topBarEl); - screen.append(contentEl, bottomCta); + screen.append(contentEl); if (createSuccessFlash) { showToast(createSuccessFlash); } - updateBottomCta({ button: bottomCta }); - // Применяем корректное состояние хедера сразу на первом рендере, // чтобы не показывать лишние кнопки до первой перерисовки. rerenderList(); From ae2f2fac417f4f989a68895e95f2324b7a6b5f324adcde587a94557ed4362933 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 8 Sep 2026 22:33:06 +0400 Subject: [PATCH 02/30] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=8C=20UI=20=D0=B8=20=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=D1=87=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../channels/Net_GetUserCounters_Handler.java | 46 +++++++ .../channels/UserCountersSupport.java | 116 ++++++++++++++++++ .../entyties/Net_GetUserCounters_Request.java | 7 ++ .../Net_GetUserCounters_Response.java | 29 +++++ .../Net_SetNotificationState_Handler.java | 3 +- .../Net_UpsertUserSetting_Handler.java | 4 + .../Net_ReceiveIncomingMessage_Handler.java | 3 + .../messages/Net_SendMessagePair_Handler.java | 9 ++ VERSION.properties | 4 +- docs/API/17_User_Counters_API.md | 49 ++++++++ shine-UI/js/components/toolbar.js | 105 ++++++++++------ shine-UI/js/pages/channel-about-view.js | 6 +- shine-UI/js/pages/channel-view.js | 36 +++++- shine-UI/js/pages/channels-list.js | 54 -------- shine-UI/js/services/auth-service.js | 6 + shine-UI/js/state.js | 1 + shine-UI/styles/components/toolbar.css | 1 + 18 files changed, 378 insertions(+), 105 deletions(-) create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetUserCounters_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Response.java create mode 100644 docs/API/17_User_Counters_API.md diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 18861805..920797fc 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -80,6 +80,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handle import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler; +import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request; @@ -87,6 +88,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageTh import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request; import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler; import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler; @@ -211,6 +213,7 @@ public final class JsonHandlerRegistry { Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()), Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()), Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()), + Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()), Map.entry("ListContacts", new Net_ListContacts_Handler()), Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()), @@ -302,6 +305,7 @@ public final class JsonHandlerRegistry { Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class), Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class), Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class), + Map.entry("GetUserCounters", Net_GetUserCounters_Request.class), Map.entry("ListContacts", Net_ListContacts_Request.class), Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetUserCounters_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetUserCounters_Handler.java new file mode 100644 index 00000000..8583b316 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetUserCounters_Handler.java @@ -0,0 +1,46 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.channels.entyties.Net_GetUserCounters_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; + +import java.sql.Connection; + +public class Net_GetUserCounters_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_GetUserCounters_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetUserCounters_Request req = (Net_GetUserCounters_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + String login = ctx.getLogin().trim(); + try (Connection c = DbController.getInstance().getConnection()) { + UserCountersSupport.Snapshot s = UserCountersSupport.calculate(c, login); + Net_GetUserCounters_Response r = new Net_GetUserCounters_Response(); + r.setOp(req.getOp()); + r.setRequestId(req.getRequestId()); + r.setStatus(WireCodes.Status.OK); + r.setLogin(login); + r.setDmUnreadCount(s.dmUnreadCount()); + r.setChannelsUnreadCount(s.channelsUnreadCount()); + r.setNotificationsUnreadCount(s.notificationsUnreadCount()); + r.setNotificationRepliesUnreadCount(s.repliesUnreadCount()); + r.setNotificationConnectionsUnreadCount(s.connectionsUnreadCount()); + r.setNotificationEventsUnreadCount(s.eventsUnreadCount()); + return r; + } catch (Exception e) { + log.error("GetUserCounters failed for {}", login, e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Не удалось получить счётчики"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java new file mode 100644 index 00000000..c0e32f61 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java @@ -0,0 +1,116 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.push.WsEventSender; +import shine.db.DbController; +import shine.db.MsgSubType; +import shine.db.dao.UserNotificationSeenStateDAO; +import shine.db.dao.UserNotificationsStateDAO; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** + * Temporary calculator behind the stable GetUserCounters/UserCountersChanged contract. + * It intentionally uses current DB state today; later it can read one materialized row/cache + * without changing API or UI. + */ +public final class UserCountersSupport { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private UserCountersSupport() {} + + public record Snapshot(long dmUnreadCount, long channelsUnreadCount, long repliesUnreadCount, + long connectionsUnreadCount, long eventsUnreadCount) { + public long notificationsUnreadCount() { + return repliesUnreadCount + connectionsUnreadCount + eventsUnreadCount; + } + } + + public static Snapshot calculate(Connection c, String login) throws Exception { + long dm = countDmUnread(c, login); + long channels = countChannelsUnread(c, login); + UserNotificationSeenStateDAO seen = UserNotificationSeenStateDAO.getInstance(); + UserNotificationsStateDAO notifications = UserNotificationsStateDAO.getInstance(); + long repliesSeen = seen.getSeenAt(c, login, "replies"); + long connectionsSeen = seen.getSeenAt(c, login, "connections"); + long eventsSeen = seen.getSeenAt(c, login, "events"); + long replies = notifications.countUnseen(c, login, "reply", repliesSeen); + long connections = notifications.countUnseen(c, login, "connection", connectionsSeen); + long events = notifications.countUnseen(c, login, "event", eventsSeen); + return new Snapshot(dm, channels, replies, connections, events); + } + + public static Snapshot calculate(String login) throws Exception { + try (Connection c = DbController.getInstance().getConnection()) { + return calculate(c, login); + } + } + + public static void pushChanged(String login) { + if (login == null || login.isBlank()) return; + try { + Snapshot s = calculate(login); + ObjectNode payload = toPayload(login, s); + String eventId = "user-counters-" + System.currentTimeMillis(); + for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(login)) { + WsEventSender.sendEvent(ctx, "UserCountersChanged", eventId, payload); + } + } catch (Exception ignored) { + // Counter push is best-effort. The client can always recover with GetUserCounters. + } + } + + public static ObjectNode toPayload(String login, Snapshot s) { + ObjectNode p = MAPPER.createObjectNode(); + p.put("login", login); + p.put("dmUnreadCount", s.dmUnreadCount()); + p.put("channelsUnreadCount", s.channelsUnreadCount()); + p.put("notificationsUnreadCount", s.notificationsUnreadCount()); + ObjectNode n = p.putObject("notifications"); + n.put("replies", s.repliesUnreadCount()); + n.put("connections", s.connectionsUnreadCount()); + n.put("events", s.eventsUnreadCount()); + return p; + } + + private static long countDmUnread(Connection c, String login) throws Exception { + String sql = "SELECT COALESCE(SUM(unread_count),0) FROM dm_dialog_state WHERE LOWER(owner_login)=LOWER(?)"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L; + } + } + } + + private static long countChannelsUnread(Connection c, String login) throws Exception { + String sql = """ + SELECT cs.to_bch_name, COALESCE(cs.to_block_number,0) AS root_number + FROM connections_state cs + WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=? + """; + long total = 0L; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setInt(2, MsgSubType.CONNECTION_FOLLOW); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String ownerBch = rs.getString("to_bch_name"); + int rootNumber = rs.getInt("root_number"); + if (ownerBch == null || ownerBch.isBlank()) continue; + ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, rootNumber); + if (meta == null || meta.channelName == null || meta.channelName.isBlank()) continue; + if (meta.channelTypeCode == 0 || "stories".equalsIgnoreCase(meta.channelName)) continue; + int messages = ChannelsReadSupport.countPosts(c, ownerBch, rootNumber); + total += Math.max(0, ChannelsReadSupport.countUnreadMessages(c, login, ownerBch, meta.channelName, messages)); + } + } + } + return total; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Request.java new file mode 100644 index 00000000..7a1129af --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Request.java @@ -0,0 +1,7 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** Lightweight authenticated request for bottom-toolbar counters. */ +public class Net_GetUserCounters_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Response.java new file mode 100644 index 00000000..398132a5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Response.java @@ -0,0 +1,29 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +/** Stable UI contract. Server-side calculation may be replaced by materialized counters later. */ +public class Net_GetUserCounters_Response extends Net_Response { + private String login; + private long dmUnreadCount; + private long channelsUnreadCount; + private long notificationsUnreadCount; + private long notificationRepliesUnreadCount; + private long notificationConnectionsUnreadCount; + private long notificationEventsUnreadCount; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public long getDmUnreadCount() { return dmUnreadCount; } + public void setDmUnreadCount(long value) { this.dmUnreadCount = value; } + public long getChannelsUnreadCount() { return channelsUnreadCount; } + public void setChannelsUnreadCount(long value) { this.channelsUnreadCount = value; } + public long getNotificationsUnreadCount() { return notificationsUnreadCount; } + public void setNotificationsUnreadCount(long value) { this.notificationsUnreadCount = value; } + public long getNotificationRepliesUnreadCount() { return notificationRepliesUnreadCount; } + public void setNotificationRepliesUnreadCount(long value) { this.notificationRepliesUnreadCount = value; } + public long getNotificationConnectionsUnreadCount() { return notificationConnectionsUnreadCount; } + public void setNotificationConnectionsUnreadCount(long value) { this.notificationConnectionsUnreadCount = value; } + public long getNotificationEventsUnreadCount() { return notificationEventsUnreadCount; } + public void setNotificationEventsUnreadCount(long value) { this.notificationEventsUnreadCount = value; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java index f795b472..a51e0c6e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java @@ -5,6 +5,7 @@ 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.notifications.entyties.*; +import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.DbController; @@ -23,7 +24,7 @@ public final class Net_SetNotificationState_Handler implements JsonMessageHandle byte[] pub=Ed25519Util.keyFromBase64(ctx.getCurrentUser().getClientKey()); if(!Ed25519Util.verify(p.signedBody,p.signature64,pub)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_SIGNATURE","Некорректная подпись clientKey"); long now=System.currentTimeMillis(); if(p.timeMs>now+5*60_000L) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_TIME","Некорректное время подписи"); long actual; try(Connection c=DbController.getInstance().getConnection()){ actual=UserNotificationSeenStateDAO.getInstance().advance(c,login,p.categoryName(),p.seenAtMs,p.timeMs,raw); } - Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); return r; + Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); UserCountersSupport.pushChanged(login); return r; }catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); } } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java index 375dacfd..eeaf5a3e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java @@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request; import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Response; +import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.DbController; @@ -100,6 +101,9 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler { resp.setSetting_type(settingType); resp.setSetting_key(settingKey); resp.setTime_ms(timeMs); + if (settingType == 1) { + UserCountersSupport.pushChanged(login); + } return resp; } } catch (SQLException e) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java index 053038bc..39f504e7 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java @@ -4,6 +4,7 @@ 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.channels.UserCountersSupport; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; @@ -59,6 +60,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming); + UserCountersSupport.pushChanged(incoming.toLogin); + UserCountersSupport.pushChanged(incoming.fromLogin); } if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java index 50146df3..d7797ca0 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -4,6 +4,7 @@ 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.channels.UserCountersSupport; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; @@ -78,6 +79,14 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters(); if (pairStatus.applied()) { outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId); + + // SendMessagePair is also the local-server delivery path. Without this push, + // a recipient on the same access server receives the DM but the toolbar + // counter stays stale until the next explicit GetUserCounters refresh. + UserCountersSupport.pushChanged(incoming.toLogin); + if (!incoming.fromLogin.equalsIgnoreCase(incoming.toLogin)) { + UserCountersSupport.pushChanged(incoming.fromLogin); + } } if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { diff --git a/VERSION.properties b/VERSION.properties index 778037b3..521538c7 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.1 -server.version=1.10.0 +client.version=1.12.2 +server.version=1.10.1 diff --git a/docs/API/17_User_Counters_API.md b/docs/API/17_User_Counters_API.md new file mode 100644 index 00000000..8ef373ea --- /dev/null +++ b/docs/API/17_User_Counters_API.md @@ -0,0 +1,49 @@ +# User counters API + +## GetUserCounters +Authenticated lightweight WebSocket request used by the bottom toolbar. + +Request: +```json +{"op":"GetUserCounters","requestId":"...","payload":{}} +``` + +Success payload: +```json +{ + "login": "alice", + "dmUnreadCount": 7, + "channelsUnreadCount": 12, + "notificationsUnreadCount": 5, + "notificationRepliesUnreadCount": 2, + "notificationConnectionsUnreadCount": 1, + "notificationEventsUnreadCount": 2 +} +``` + +The fields form a stable UI contract. Their server-side calculation is intentionally replaceable by materialized counters/cache later. + +## UserCountersChanged +Server push event for every active session of the user. It lets the toolbar update without polling. + +```json +{ + "op":"UserCountersChanged", + "event":true, + "payload":{ + "login":"alice", + "dmUnreadCount":7, + "channelsUnreadCount":12, + "notificationsUnreadCount":5, + "notifications":{"replies":2,"connections":1,"events":2} + } +} +``` + +Current implementation emits this event after channel read-state writes, notification seen-state changes and applied incoming DM/read-receipt blocks. Future materialized counter logic can emit the same event immediately on any counter increment/decrement. + +## Channel read behaviour +- Opening a subscribed channel does **not** mark all messages read. +- The UI advances readCount only when message cards cross the viewport read threshold while scrolling. +- Writes are debounced; failed writes are shown to the user and retried. +- Immediately after a successful subscription from inside an open channel, the client stores `readCount = current messagesCount`, because those existing messages are treated as already viewed at subscription time. diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js index 0ad7e397..5a645139 100644 --- a/shine-UI/js/components/toolbar.js +++ b/shine-UI/js/components/toolbar.js @@ -20,16 +20,68 @@ function iconHtml(item) { : `${item.icon}`; } -function getTotalUnreadMessages() { - const chats = Object.values(state.chats || {}); - let total = 0; - chats.forEach((messages) => { - if (!Array.isArray(messages)) return; - messages.forEach((msg) => { - if (msg?.from === 'in' && msg?.unread) total += 1; - }); +function normalizeCounters(payload = {}) { + const notifications = payload?.notifications || {}; + const notificationTotal = Math.max(0, Number(payload?.notificationsUnreadCount ?? ( + Number(notifications.replies || 0) + Number(notifications.connections || 0) + Number(notifications.events || 0) + )) || 0); + return { + dmUnreadCount: Math.max(0, Number(payload?.dmUnreadCount || 0) || 0), + channelsUnreadCount: Math.max(0, Number(payload?.channelsUnreadCount || 0) || 0), + notificationsUnreadCount: notificationTotal, + notifications: { + replies: Math.max(0, Number(notifications.replies ?? payload?.notificationRepliesUnreadCount ?? 0) || 0), + connections: Math.max(0, Number(notifications.connections ?? payload?.notificationConnectionsUnreadCount ?? 0) || 0), + events: Math.max(0, Number(notifications.events ?? payload?.notificationEventsUnreadCount ?? 0) || 0), + }, + }; +} + +function setCounterState(payload = {}) { + state.userCounters = normalizeCounters(payload); + state.notificationUnreadTotal = state.userCounters.notificationsUnreadCount; +} + +function renderBadge(btn, count, ariaLabel, extraClass = '') { + if (!btn) return; + let badge = btn.querySelector('.toolbar-unread-badge'); + if (count <= 0) { badge?.remove(); return; } + if (!badge) { + badge = document.createElement('span'); + badge.className = `toolbar-unread-badge${extraClass ? ` ${extraClass}` : ''}`; + btn.append(badge); + } + badge.textContent = count > 99 ? '99+' : String(count); + badge.setAttribute('aria-label', `${ariaLabel}: ${count}`); +} + +function applyCountersToMountedToolbars() { + const c = normalizeCounters(state.userCounters); + document.querySelectorAll('.toolbar').forEach((toolbar) => { + renderBadge(toolbar.querySelector('[data-toolbar-page="messages-list"]'), c.dmUnreadCount, 'Непрочитанных личных сообщений'); + renderBadge(toolbar.querySelector('[data-toolbar-page="channels-list"]'), c.channelsUnreadCount, 'Непрочитанных сообщений в каналах'); + renderBadge(toolbar.querySelector('[data-toolbar-page="notifications-view"]'), c.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge'); }); - return total; +} + +let countersPushBound = false; +function ensureCountersPushBound() { + if (countersPushBound) return; + countersPushBound = true; + authService.onEvent('UserCountersChanged', (event) => { + setCounterState(event?.payload || {}); + applyCountersToMountedToolbars(); + }); +} + +async function refreshUserCounters() { + if (!state.session.isAuthorized) return; + try { + setCounterState(await authService.getUserCounters()); + applyCountersToMountedToolbars(); + } catch { + // Keep the last known counters; realtime push or the next refresh can recover. + } } function navigateWithGuestRules(pageId, navigate) { @@ -65,7 +117,8 @@ export function renderToolbar(currentPageId, navigate) { const root = document.createElement('nav'); root.className = 'toolbar'; const active = resolveToolbarActive(currentPageId); - const unreadTotal = getTotalUnreadMessages(); + ensureCountersPushBound(); + const counters = normalizeCounters(state.userCounters); ITEMS.forEach((item) => { const btn = document.createElement('button'); @@ -92,21 +145,9 @@ export function renderToolbar(currentPageId, navigate) { } else { btn.innerHTML = `${iconHtml(item)}${item.label}`; } - if (isMessages && unreadTotal > 0) { - const badge = document.createElement('span'); - badge.className = 'toolbar-unread-badge'; - badge.textContent = unreadTotal > 99 ? '99+' : String(unreadTotal); - badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`); - btn.append(badge); - } - if (isNotifications && Number(state.notificationUnreadTotal || 0) > 0) { - const badge = document.createElement('span'); - badge.className = 'toolbar-unread-badge notification-toolbar-badge'; - const n = Number(state.notificationUnreadTotal || 0); - badge.textContent = n > 99 ? '99+' : String(n); - badge.setAttribute('aria-label', `Новых уведомлений: ${n}`); - btn.append(badge); - } + if (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений'); + if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах'); + if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge'); if (item.pageId === 'channels-list') { btn.addEventListener('click', () => navigate('channels-list')); } else { @@ -115,19 +156,7 @@ export function renderToolbar(currentPageId, navigate) { root.append(btn); }); - if (state.session.isAuthorized && currentPageId !== 'notifications-view') { - void authService.getNotifications(true).then((payload) => { - const total = Number(payload?.repliesUnseenCount || 0) + Number(payload?.connectionsUnseenCount || 0) + Number(payload?.eventsUnseenCount || 0); - state.notificationUnreadTotal = total; - const btn = root.querySelector('[data-toolbar-page="notifications-view"]'); - if (!btn) return; - let badge = btn.querySelector('.notification-toolbar-badge'); - if (total <= 0) { badge?.remove(); return; } - if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); } - badge.textContent = total > 99 ? '99+' : String(total); - badge.setAttribute('aria-label', `Новых уведомлений: ${total}`); - }).catch(() => {}); - } + if (state.session.isAuthorized) void refreshUserCounters(); return root; } diff --git a/shine-UI/js/pages/channel-about-view.js b/shine-UI/js/pages/channel-about-view.js index 57054a76..bbaa231a 100644 --- a/shine-UI/js/pages/channel-about-view.js +++ b/shine-UI/js/pages/channel-about-view.js @@ -88,11 +88,7 @@ export function render({ navigate, route, chrome }) { `; - const footer = document.createElement('div'); - footer.className = 'meta-muted screen-footer'; - footer.textContent = 'О канале (channel-about-view)'; - - screen.append(card, footer); + screen.append(card); const renderContent = (channel) => { const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim(); diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index a6ac0d56..0c6f59cc 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -290,6 +290,8 @@ function createChannelReadTracker({ unreadCount, messagesCount, initialSeenCount, + onPersistError = null, + onPersistSuccess = null, }) { const login = String(state.session.login || '').trim(); const storagePwd = state.session.storagePwdInMemory; @@ -343,7 +345,9 @@ function createChannelReadTracker({ storagePwd, }); persistedSeenCount = next; - } catch { + if (typeof onPersistSuccess === 'function') onPersistSuccess(persistedSeenCount); + } catch (error) { + if (typeof onPersistError === 'function') onPersistError(error); queueFlush(800); } finally { inFlight = false; @@ -393,10 +397,8 @@ function createChannelReadTracker({ } window.addEventListener('resize', onResize); - if (canWrite) { - desiredSeenCount = safeMessagesCount; - void flush(); - } + // Opening a channel must NOT mark the whole channel as read. + // Only cards actually crossed by the viewport tracker advance desiredSeenCount. window.setTimeout(() => measure(), 120); const cleanup = () => { @@ -2230,6 +2232,12 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { unreadCount, messagesCount, initialSeenCount: readCount, + onPersistError: () => { + showStatus('Не удалось сохранить, сколько сообщений прочитано. Сервер повторит попытку автоматически.'); + }, + onPersistSuccess: () => { + showStatus(''); + }, }); return () => { @@ -2785,6 +2793,24 @@ export function render({ navigate, route, chrome }) { }); if (disposed) return; + const readSettingKey = buildChannelSettingsKey( + apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName, + apiData.channel?.name || apiData.channel?.channelName, + ); + try { + await authService.upsertUserSetting({ + login, + settingType: 1, + settingKey: readSettingKey, + timeMs: Date.now(), + valueText: '', + valueNum: Math.max(0, Number(apiData.messagesCount || 0)), + storagePwd, + }); + } catch (readStateError) { + showStatus(toUserMessage(readStateError, 'Подписка выполнена, но не удалось сохранить, сколько сообщений уже прочитано.')); + } + const feed = await authService.listSubscriptionsFeed(login, 200); if (disposed) return; setChannelsFeed(feed, state.channelsIndex); diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index f72bf1ec..a0a6d119 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -26,7 +26,6 @@ const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_PERSONAL = 100; const DIARY_CHANNEL_NAME = 'diary'; const DIARY_DISPLAY_NAME = 'Дневник'; -const CHANNEL_READ_SETTING_TYPE = 1; const CHANNELS_VIEW_ALL = 'all'; const CHANNELS_VIEW_OWNED = 'owned'; @@ -111,58 +110,6 @@ function isVisibleChannelSummary(summary) { return !!ownerLogin && !!channelName; } -function channelReadSettingKey(summary) { - const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim(); - const channelName = String(summary?.channel?.channelName || '').trim(); - if (!ownerBch || !channelName) return ''; - return `${ownerBch}/${channelName}`; -} - -async function ensureChannelReadBaselines(feed) { - const login = String(state.session.login || '').trim(); - const storagePwd = state.session.storagePwdInMemory; - if (!login || !storagePwd) return; - - let settingsPayload; - try { - settingsPayload = await authService.listUserSettings(login); - } catch { - return; - } - - const existing = new Set( - (Array.isArray(settingsPayload?.settings) ? settingsPayload.settings : []) - .filter((item) => Number(item?.setting_type) === CHANNEL_READ_SETTING_TYPE) - .map((item) => String(item?.setting_key || '').trim()) - .filter(Boolean), - ); - - const summaries = [ - ...(Array.isArray(feed?.followedUsersChannels) ? feed.followedUsersChannels : []), - ...(Array.isArray(feed?.followedChannels) ? feed.followedChannels : []), - ].filter(isVisibleChannelSummary); - - for (const summary of summaries) { - const settingKey = channelReadSettingKey(summary); - if (!settingKey || existing.has(settingKey)) continue; - - try { - await authService.upsertUserSetting({ - login, - settingType: CHANNEL_READ_SETTING_TYPE, - settingKey, - timeMs: Date.now(), - valueText: '', - valueNum: Math.max(0, Number(summary?.messagesCount || 0)), - storagePwd, - }); - existing.add(settingKey); - } catch { - // Не ломаем экран каналов из-за фоновой инициализации read-state. - } - } -} - function avatarLetterFromName(name = '') { const first = Array.from(String(name || '').trim())[0] || '#'; return first.toUpperCase(); @@ -1120,7 +1067,6 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) { try { const feed = await authService.listSubscriptionsFeed(state.session.login, 200); - void ensureChannelReadBaselines(feed); let diaryPayload = null; try { diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc'); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index c3554386..94cf1757 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -2944,6 +2944,12 @@ export class AuthService { return response.payload || {}; } + async getUserCounters() { + const response = await this.ws.request('GetUserCounters', {}); + if (response.status !== 200) throw opError('GetUserCounters', response); + return response.payload || {}; + } + async getNotifications(countsOnly = false) { const response = await this.ws.request('GetNotifications', countsOnly ? { countsOnly: true } : {}); if (response.status !== 200) throw opError('GetNotifications', response); diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 2677aeb3..b5002c4d 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -383,6 +383,7 @@ function createInitialState({ withStoredSession = true } = {}) { outgoingTempSeq: 1, notificationsTab: 'replies', notificationUnreadTotal: 0, + userCounters: { dmUnreadCount: 0, channelsUnreadCount: 0, notificationsUnreadCount: 0, notifications: { replies: 0, connections: 0, events: 0 } }, pageLabelCollapsed: false, session: { isAuthorized: storedLocalDemo, diff --git a/shine-UI/styles/components/toolbar.css b/shine-UI/styles/components/toolbar.css index 8610a177..0666f51f 100644 --- a/shine-UI/styles/components/toolbar.css +++ b/shine-UI/styles/components/toolbar.css @@ -26,6 +26,7 @@ } .toolbar-btn { + position: relative; min-height: 52px; padding: 4px 3px 2px; display: grid; From f827c3e493be75df8ab78e3d42b9a9b322fa5f51aa556e52e3beda9f31923097 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 9 Sep 2026 10:23:36 +0300 Subject: [PATCH 03/30] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BD?= =?UTF-8?q?=D1=83=D1=8E=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E=20=D0=BF=D1=80=D0=B8=20=D0=B2=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/pages/login-password-view.js | 2 +- shine-UI/js/state.js | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 521538c7..a9b1d142 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.2 +client.version=1.12.3 server.version=1.10.1 diff --git a/shine-UI/js/pages/login-password-view.js b/shine-UI/js/pages/login-password-view.js index 2e1b9b4a..2f869e8a 100644 --- a/shine-UI/js/pages/login-password-view.js +++ b/shine-UI/js/pages/login-password-view.js @@ -113,7 +113,7 @@ export function render({ navigate }) { enterButton.disabled = true; try { - await authService.reconnect(state.entrySettings.shineServer); + authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true }); // Повторная проверка защищает UI от смены access server между первым экраном и входом. const resolved = await authService.resolveLoginForAuth(currentLogin); diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index b5002c4d..1e406789 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -1313,6 +1313,7 @@ export async function closeSavedProfile(login) { if (!next) { setActiveProfileLoginRaw(''); clearStoredSession(); + authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true }); return { closed: true, nextProfile: null }; } setActiveProfileLoginRaw(next.login); From 9f77c54955d8c387dc482e2f0c2d146c9272265aad37b92e6fd1cf0837be62e0 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 9 Sep 2026 15:17:31 +0300 Subject: [PATCH 04/30] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81=D1=82=D0=B8=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- ...Черновик_CHANNEL_MEMBERSHIP_для_каналов.md | 0 .../Черновик_TEXT_CHANNEL_META_для_каналов.md | 0 ...олнений_каналов_и_типов_сообщений_SHiNE.md | 0 ...формата_вложений_в_сообщениях_блокчейна.md | 0 docs/закомментировано/Личный_дневник.md | 68 ++++++++ shine-UI/js/pages/channel-view.js | 165 +++++++++--------- shine-UI/js/pages/channels-list.js | 18 +- 8 files changed, 168 insertions(+), 85 deletions(-) rename Черновик_CHANNEL_MEMBERSHIP_для_каналов.md => docs/Черновики/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md (100%) rename Черновик_TEXT_CHANNEL_META_для_каналов.md => docs/Черновики/Черновик_TEXT_CHANNEL_META_для_каналов.md (100%) rename Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md => docs/Черновики/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md (100%) rename Черновик_формата_вложений_в_сообщениях_блокчейна.md => docs/Черновики/Черновик_формата_вложений_в_сообщениях_блокчейна.md (100%) create mode 100644 docs/закомментировано/Личный_дневник.md diff --git a/VERSION.properties b/VERSION.properties index a9b1d142..306b82aa 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.3 +client.version=1.12.4 server.version=1.10.1 diff --git a/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md b/docs/Черновики/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md similarity index 100% rename from Черновик_CHANNEL_MEMBERSHIP_для_каналов.md rename to docs/Черновики/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md diff --git a/Черновик_TEXT_CHANNEL_META_для_каналов.md b/docs/Черновики/Черновик_TEXT_CHANNEL_META_для_каналов.md similarity index 100% rename from Черновик_TEXT_CHANNEL_META_для_каналов.md rename to docs/Черновики/Черновик_TEXT_CHANNEL_META_для_каналов.md diff --git a/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md b/docs/Черновики/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md similarity index 100% rename from Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md rename to docs/Черновики/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md diff --git a/Черновик_формата_вложений_в_сообщениях_блокчейна.md b/docs/Черновики/Черновик_формата_вложений_в_сообщениях_блокчейна.md similarity index 100% rename from Черновик_формата_вложений_в_сообщениях_блокчейна.md rename to docs/Черновики/Черновик_формата_вложений_в_сообщениях_блокчейна.md diff --git a/docs/закомментировано/Личный_дневник.md b/docs/закомментировано/Личный_дневник.md new file mode 100644 index 00000000..6da008e5 --- /dev/null +++ b/docs/закомментировано/Личный_дневник.md @@ -0,0 +1,68 @@ +# Личный дневник — временно отключён в UI + +Статус: **закомментировано / скрыто из интерфейса**. + +Личный дневник SHiNE не удалён из протокола и серверной части. На текущем этапе он только перестал отображаться пользователю в списке каналов. + +## Что это за функция + +«Дневник» — виртуальный персональный канал пользователя. Он формируется не как обычный канал с отдельной цепочкой постов, а собирается сервером из `STATUS_ACTION` записей пользователя. + +Основной read API: + +- `GetPersonalDiary` + +Связанная документация: + +- `docs/API/06_Channels_Read_API.md` — раздел `GetPersonalDiary`; +- `docs/API/09_Operations_Index.md` — операция `GetPersonalDiary`; +- `docs/Blockchain/15_STATUS_ACTION_Blocks.md` — действия, из которых собирается дневник; +- `docs/Blockchain/CHANGELOG.md` — история добавления функции. + +## Что отключено сейчас + +В `shine-UI/js/pages/channels-list.js` отключён запрос `authService.getPersonalDiary(...)` при построении списка каналов. + +Код оставлен рядом в комментариях, а в `mapApiFeed(...)` передаётся `diaryPayload = null`. Благодаря этому карточка «Дневник» не появляется в разделе каналов. + +## Что намеренно НЕ удалено + +Чтобы не ломать обратную совместимость и сохранить возможность вернуть функцию позже, оставлены: + +- серверная операция `GetPersonalDiary`; +- `authService.getPersonalDiary(...)`; +- обработка diary-route в `channel-view.js`; +- форматы `STATUS_ACTION`; +- чтение старых типов сообщений и действий. + +То есть функция сохранена технически, но скрыта из обычной навигации. + +## Как вернуть дневник + +В `shine-UI/js/pages/channels-list.js` вернуть получение `diaryPayload`: + +```js +let diaryPayload = null; +try { + diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc'); +} catch { + diaryPayload = null; +} +``` + +и убрать временное: + +```js +const diaryPayload = null; +``` + +После этого `mapApiFeed(...)` снова сможет добавить виртуальную карточку дневника в список каналов. + +## Связанные типы контента + +В интерфейсе создания новой записи канала сейчас доступны только: + +- `POST (10)` — Пост; +- `TEXT_ENTRYPOINT (100)` — Оглавление канала. + +Варианты `TEXT_EXERCISE (110)`, `TEXT_SERVICE (120)` и `TEXT_COURSE (130)` убраны только из формы создания новой записи. Их константы и обработчики чтения сохранены для совместимости со старыми данными. diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 0c6f59cc..6fe5a6cd 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -1217,9 +1217,6 @@ function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () =>
@@ -2211,9 +2208,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { } else if (channelData.isOwnChannel) { screen.append(feed, addMessageButton); } else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) { - screen.append(actionButton, feed, backButton); + screen.append(feed, actionButton); } else { - screen.append(feed, backButton); + screen.append(feed); } const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey); @@ -2319,32 +2316,19 @@ export function render({ navigate, route, chrome }) { const items = [ { label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } }, ]; - if (apiData?.isSubscribed && !apiData?.isOwnChannel) { - items.push({ - label: 'Отписаться от канала', - danger: true, - action: async () => { - try { - const { login, storagePwd } = requireSigningSession(); - await authService.addBlockFollowChannel({ - login, - storagePwd, - targetBlockchainName: apiData.selector.ownerBlockchainName, - targetBlockNumber: apiData.selector.channelRootBlockNumber, - targetBlockHashHex: apiData.selector.channelRootBlockHash, - unfollow: true, - }); - if (disposed) return; - const feed = await authService.listSubscriptionsFeed(login, 200); - if (disposed) return; - setChannelsFeed(feed, state.channelsIndex); - showToast('Вы отписались от канала'); - void refresh(); - } catch (error) { - showStatus(toUserMessage(error, 'Не удалось отписаться от канала.')); - } - }, - }); + if (!apiData?.isOwnChannel && !isStoriesChannel(apiData?.channel)) { + if (apiData?.isSubscribed) { + items.push({ + label: 'Отписаться от канала', + danger: true, + action: () => unsubscribeFromChannel(apiData), + }); + } else { + items.push({ + label: 'Подписаться на канал', + action: () => subscribeToChannel(apiData), + }); + } } return items; }, @@ -2373,6 +2357,77 @@ export function render({ navigate, route, chrome }) { return { login, storagePwd }; }; + const subscribeToChannel = async (apiData, event = null) => { + animatePress(event?.currentTarget); + try { + const { login, storagePwd } = requireSigningSession(); + if (!apiData?.selector) throw new Error('Не удалось определить канал для подписки.'); + const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`; + const ok = window.confirm(`Подписаться на канал ${targetName}?`); + if (!ok) return; + + await authService.addBlockFollowChannel({ + login, + storagePwd, + targetBlockchainName: apiData.selector.ownerBlockchainName, + targetBlockNumber: apiData.selector.channelRootBlockNumber, + targetBlockHashHex: apiData.selector.channelRootBlockHash, + unfollow: false, + }); + if (disposed) return; + + const readSettingKey = buildChannelSettingsKey( + apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName, + apiData.channel?.name || apiData.channel?.channelName, + ); + try { + await authService.upsertUserSetting({ + login, + settingType: 1, + settingKey: readSettingKey, + timeMs: Date.now(), + valueText: '', + valueNum: Math.max(0, Number(apiData.messagesCount || 0)), + storagePwd, + }); + } catch (readStateError) { + showStatus(toUserMessage(readStateError, 'Подписка выполнена, но не удалось сохранить, сколько сообщений уже прочитано.')); + } + + const feed = await authService.listSubscriptionsFeed(login, 200); + if (disposed) return; + setChannelsFeed(feed, state.channelsIndex); + softHaptic(15); + showToast('Подписка на канал выполнена'); + void refresh(); + } catch (error) { + showStatus(toUserMessage(error, 'Не удалось подписаться на канал.')); + } + }; + + const unsubscribeFromChannel = async (apiData) => { + try { + const { login, storagePwd } = requireSigningSession(); + if (!apiData?.selector) throw new Error('Не удалось определить канал для отписки.'); + await authService.addBlockFollowChannel({ + login, + storagePwd, + targetBlockchainName: apiData.selector.ownerBlockchainName, + targetBlockNumber: apiData.selector.channelRootBlockNumber, + targetBlockHashHex: apiData.selector.channelRootBlockHash, + unfollow: true, + }); + if (disposed) return; + const feed = await authService.listSubscriptionsFeed(login, 200); + if (disposed) return; + setChannelsFeed(feed, state.channelsIndex); + showToast('Вы отписались от канала'); + void refresh(); + } catch (error) { + showStatus(toUserMessage(error, 'Не удалось отписаться от канала.')); + } + }; + const onToggleLike = async (messageRef, action) => { const actionKey = makeReactionActionKey(messageRef); if (!actionKey) { @@ -2774,53 +2829,7 @@ export function render({ navigate, route, chrome }) { throw new Error(toUserMessage(error, 'Не удалось изменить сообщение.')); } }, - onSubscribeChannel: async (event) => { - animatePress(event?.currentTarget); - try { - const { login, storagePwd } = requireSigningSession(); - if (!apiData.selector) throw new Error('Не удалось определить канал для подписки.'); - const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`; - const ok = window.confirm(`Подписаться на канал ${targetName}?`); - if (!ok) return; - - await authService.addBlockFollowChannel({ - login, - storagePwd, - targetBlockchainName: apiData.selector.ownerBlockchainName, - targetBlockNumber: apiData.selector.channelRootBlockNumber, - targetBlockHashHex: apiData.selector.channelRootBlockHash, - unfollow: false, - }); - if (disposed) return; - - const readSettingKey = buildChannelSettingsKey( - apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName, - apiData.channel?.name || apiData.channel?.channelName, - ); - try { - await authService.upsertUserSetting({ - login, - settingType: 1, - settingKey: readSettingKey, - timeMs: Date.now(), - valueText: '', - valueNum: Math.max(0, Number(apiData.messagesCount || 0)), - storagePwd, - }); - } catch (readStateError) { - showStatus(toUserMessage(readStateError, 'Подписка выполнена, но не удалось сохранить, сколько сообщений уже прочитано.')); - } - - const feed = await authService.listSubscriptionsFeed(login, 200); - if (disposed) return; - setChannelsFeed(feed, state.channelsIndex); - softHaptic(15); - showToast('Подписка на канал выполнена'); - void refresh(); - } catch (error) { - showStatus(toUserMessage(error, 'Не удалось подписаться на канал.')); - } - }, + onSubscribeChannel: (event) => subscribeToChannel(apiData, event), }); } catch (error) { if (disposed || seq !== refreshSeq) return; diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index a0a6d119..08188241 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -1067,12 +1067,18 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) { try { const feed = await authService.listSubscriptionsFeed(state.session.login, 200); - let diaryPayload = null; - try { - diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc'); - } catch { - diaryPayload = null; - } + + // FEATURE DISABLED: personal Diary is intentionally hidden from the Channels UI. + // The server/API implementation is preserved so the feature can be restored later. + // See: docs/закомментировано/Личный_дневник.md + // let diaryPayload = null; + // try { + // diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc'); + // } catch { + // diaryPayload = null; + // } + const diaryPayload = null; + const groups = mapApiFeed(feed, listState.notificationsState, diaryPayload); listState.channels = toListModel(groups); From 2d059e9ff530fd2901a82a4e210c7d9a7744fabfbc1b49a110eb50b4e3c57811 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 9 Sep 2026 17:01:13 +0300 Subject: [PATCH 05/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80?= =?UTF-8?q?=20=D0=BB=D0=B0=D0=B9=D0=BA=D0=BE=D0=B2=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../channels/Net_GetMessageLikes_Handler.java | 112 +++++++++++ .../entyties/Net_GetMessageLikes_Request.java | 29 +++ .../Net_GetMessageLikes_Response.java | 40 ++++ VERSION.properties | 4 +- docs/API/06_Channels_Read_API.md | 74 +++++++- docs/API/07_Channels_Feature_Runbook.md | 26 ++- docs/API/09_Operations_Index.md | 1 + shine-UI/js/pages/channel-view.js | 174 +++++++++++++++++- shine-UI/js/services/auth-service.js | 11 ++ shine-UI/styles/features/channel.css | 140 ++++++++++++++ 11 files changed, 595 insertions(+), 20 deletions(-) create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Response.java diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 920797fc..3462dfc9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -75,6 +75,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler; +import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageLikes_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler; @@ -85,6 +86,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsC import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; @@ -210,6 +212,7 @@ public final class JsonHandlerRegistry { Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()), Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()), Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()), + Map.entry("GetMessageLikes", new Net_GetMessageLikes_Handler()), Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()), Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()), Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()), @@ -302,6 +305,7 @@ public final class JsonHandlerRegistry { Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class), Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class), Map.entry("GetMessageThread", Net_GetMessageThread_Request.class), + Map.entry("GetMessageLikes", Net_GetMessageLikes_Request.class), Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class), Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class), Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java new file mode 100644 index 00000000..beec7175 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java @@ -0,0 +1,112 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.channels.entyties.Net_GetMessageLikes_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; + +public class Net_GetMessageLikes_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class); + private static final int HARD_LIMIT = 1000; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetMessageLikes_Request req = (Net_GetMessageLikes_Request) baseRequest; + Net_GetMessageLikes_Request.MessageSelector message = req.getMessage(); + if (message == null || message.getBlockchainName() == null || message.getBlockchainName().isBlank() + || message.getBlockNumber() == null || message.getBlockNumber() < 0 + || message.getBlockHash() == null || message.getBlockHash().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message"); + } + + final byte[] blockHash; + try { + blockHash = ChannelsReadSupport.hexToBytes(message.getBlockHash()); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash"); + } + if (blockHash == null || blockHash.length == 0) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash"); + } + + int requested = req.getLimit() == null ? HARD_LIMIT : Math.max(1, Math.min(HARD_LIMIT, req.getLimit())); + try (Connection c = DbController.getInstance().getConnection()) { + String sql = """ + SELECT rs.from_login, + COALESCE(ups.first_name, '') AS first_name, + COALESCE(ups.last_name, '') AS last_name, + COALESCE(ups.ava_ar, '') AS avatar_ar, + COALESCE(ups.account_role, 'unknown') AS account_role, + COALESCE(ups.shine_status, 'unknown') AS shine_status + FROM reactions_state rs + LEFT JOIN user_profile_state ups ON LOWER(ups.login) = LOWER(rs.from_login) + WHERE rs.reaction_type = 1 + AND rs.last_sub_type = 1 + AND rs.to_bch_name = ? + AND rs.to_block_number = ? + AND rs.to_block_hash = ? + ORDER BY LOWER(rs.from_login) + LIMIT ? + """; + + List shining = new ArrayList<>(); + List official = new ArrayList<>(); + List others = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, message.getBlockchainName().trim()); + ps.setInt(2, message.getBlockNumber()); + ps.setBytes(3, blockHash); + ps.setInt(4, requested + 1); + try (ResultSet rs = ps.executeQuery()) { + int accepted = 0; + boolean truncated = false; + while (rs.next()) { + if (accepted >= requested) { + truncated = true; + break; + } + accepted++; + Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem(); + user.setLogin(rs.getString("from_login")); + user.setFirstName(rs.getString("first_name")); + user.setLastName(rs.getString("last_name")); + user.setAvatarAr(rs.getString("avatar_ar")); + + boolean isOfficial = "primary".equalsIgnoreCase(rs.getString("account_role")); + boolean isShining = "shining".equalsIgnoreCase(rs.getString("shine_status")); + if (isOfficial && isShining) shining.add(user); + else if (isOfficial) official.add(user); + else others.add(user); + } + + Net_GetMessageLikes_Response resp = new Net_GetMessageLikes_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setShining(shining); + resp.setOfficial(official); + resp.setOthers(others); + resp.setTotal(shining.size() + official.size() + others.size()); + resp.setTruncated(truncated); + return resp; + } + } + } catch (Exception e) { + log.error("GetMessageLikes failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Request.java new file mode 100644 index 00000000..7fdd1c61 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Request.java @@ -0,0 +1,29 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetMessageLikes_Request extends Net_Request { + private MessageSelector message; + private Integer limit; + + public MessageSelector getMessage() { return message; } + public void setMessage(MessageSelector message) { this.message = message; } + + public Integer getLimit() { return limit; } + public void setLimit(Integer limit) { this.limit = limit; } + + public static class MessageSelector { + private String blockchainName; + private Integer blockNumber; + private String blockHash; + + public String getBlockchainName() { return blockchainName; } + public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } + + public Integer getBlockNumber() { return blockNumber; } + public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; } + + public String getBlockHash() { return blockHash; } + public void setBlockHash(String blockHash) { this.blockHash = blockHash; } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Response.java new file mode 100644 index 00000000..9f74bc9d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Response.java @@ -0,0 +1,40 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import java.util.ArrayList; +import java.util.List; + +public class Net_GetMessageLikes_Response extends Net_Response { + private List shining = new ArrayList<>(); + private List official = new ArrayList<>(); + private List others = new ArrayList<>(); + private int total; + private boolean truncated; + + public List getShining() { return shining; } + public void setShining(List shining) { this.shining = shining; } + public List getOfficial() { return official; } + public void setOfficial(List official) { this.official = official; } + public List getOthers() { return others; } + public void setOthers(List others) { this.others = others; } + public int getTotal() { return total; } + public void setTotal(int total) { this.total = total; } + public boolean isTruncated() { return truncated; } + public void setTruncated(boolean truncated) { this.truncated = truncated; } + + public static class UserItem { + private String login; + private String firstName; + private String lastName; + private String avatarAr; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public String getFirstName() { return firstName; } + public void setFirstName(String firstName) { this.firstName = firstName; } + public String getLastName() { return lastName; } + public void setLastName(String lastName) { this.lastName = lastName; } + public String getAvatarAr() { return avatarAr; } + public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; } + } +} diff --git a/VERSION.properties b/VERSION.properties index 306b82aa..d30e91ce 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.4 -server.version=1.10.1 +client.version=1.12.5 +server.version=1.10.2 diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index 62b97634..944a3b06 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -14,13 +14,16 @@ 3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения: предки, фокус-сообщение, потомки. -4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя. +4. `GetMessageLikes` — отдает списки пользователей, поставивших лайк сообщению, + сгруппированные для UI по статусу профиля. -5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. +5. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя. -6. `ListGroupChats200` — отдает список групповых чатов типа `200`. +6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. -7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. +7. `ListGroupChats200` — отдает список групповых чатов типа `200`. + +8. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. > На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки. > @@ -266,7 +269,62 @@ --- -## 4) GetPersonalDiary +## 4) GetMessageLikes + +Возвращает пользователей, которые поставили лайк конкретному сообщению канала. + +- `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение. +- `limit` ограничивается сервером сверху значением `1000`. +- Пользователи группируются по состоянию профиля: + - `shining` — `account_role=primary` и `shine_status=shining`; + - `official` — `account_role=primary`, но без `shine_status=shining`; + - `others` — остальные пользователи. + +### Request +```json +{ + "op": "GetMessageLikes", + "requestId": "req-4", + "payload": { + "message": { + "blockchainName": "bob-001", + "blockNumber": 140, + "blockHash": "..." + }, + "limit": 1000 + } +} +``` + +### Response (success) +```json +{ + "op": "GetMessageLikes", + "requestId": "req-4", + "status": 200, + "ok": true, + "payload": { + "shining": [ + { "login": "Alice", "firstName": "Alice", "lastName": "", "avatarAr": "ArweaveTxId..." } + ], + "official": [], + "others": [ + { "login": "Carl", "firstName": "", "lastName": "", "avatarAr": "" } + ], + "total": 2, + "truncated": false + } +} +``` + +### Ошибки +- `bad_fields` — не передан `message` или обязательные поля ссылки на сообщение. +- `bad_hash` — `message.blockHash` не является корректным hex-хэшем блока. +- `internal_error` — внутренняя ошибка чтения. + +--- + +## 5) GetPersonalDiary Возвращает виртуальный канал `Личный дневник` для самого пользователя. @@ -289,7 +347,7 @@ --- -## 4) GetChannelsCounters +## 6) GetChannelsCounters ### Request ```json @@ -321,7 +379,7 @@ --- -## 5) ListGroupChats200 +## 7) ListGroupChats200 ### Request ```json @@ -361,7 +419,7 @@ --- -## 6) GetGroupDialog +## 8) GetGroupDialog ### Request ```json diff --git a/docs/API/07_Channels_Feature_Runbook.md b/docs/API/07_Channels_Feature_Runbook.md index e60af339..9df49d25 100644 --- a/docs/API/07_Channels_Feature_Runbook.md +++ b/docs/API/07_Channels_Feature_Runbook.md @@ -8,6 +8,7 @@ - `ListSubscriptionsFeed` — экран списка каналов. - `GetChannelMessages` — сообщения конкретного канала. - `GetMessageThread` — дерево обсуждения для сообщения. + - `GetMessageLikes` — списки пользователей, поставивших лайк сообщению. 2. **UI вкладки Каналы**: - при открытии пытается загрузить реальный feed с сервера; @@ -33,12 +34,14 @@ 1. Вызвать `ListSubscriptionsFeed`. 2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`. 3. Для первого `messages[0]` вызвать `GetMessageThread`. +4. Для первого `messages[0]` вызвать `GetMessageLikes`. ### Ошибки 1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`. 2. `GetChannelMessages` с битым channel payload -> `bad_fields`. 3. `GetMessageThread` с несуществующим block -> `message_not_found`. -4. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`. +4. `GetMessageLikes` с битым `message.blockHash` -> `bad_hash`. +5. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`. --- @@ -91,6 +94,22 @@ } ``` +## 3.4 GetMessageLikes +```json +{ + "op": "GetMessageLikes", + "requestId": "debug-likes-1", + "payload": { + "message": { + "blockchainName": "TestUser1-001", + "blockNumber": 123, + "blockHash": "" + }, + "limit": 1000 + } +} +``` + --- ## 4) Что смотреть в ответах @@ -115,6 +134,11 @@ - у узлов должны быть версии и счетчики. - у каждого узла дополнительно может приходить `rawBlockB64` (Base64 сырого `block_bytes`). +### GetMessageLikes +- `payload.shining[]`, `payload.official[]`, `payload.others[]` — группы пользователей. +- у каждого пользователя есть `login`, `firstName`, `lastName`, `avatarAr`. +- `payload.truncated=true` означает, что сервер обрезал список по `limit`. + ### Важно по совместимости - `rawBlockB64` добавлен только в `GetMessageThread`. - `GetChannelMessages` не содержит `rawBlockB64` (без изменений формата ленты). diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index b961bcfc..abaa33da 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -46,6 +46,7 @@ | `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок | | `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала | | `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения | +| `GetMessageLikes` | `06_Channels_Read_API.md` | списки пользователей, поставивших лайк сообщению | | `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION | | `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов | | `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` | diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 6fe5a6cd..45c479a2 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -1790,6 +1790,169 @@ function renderChannelMetaEventCard(event) { return card; } + +function likeCategoryCounts(post) { + const total = Math.max(0, Number(post?.likesCount || 0)); + const primary = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0))); + const shining = Math.max(0, Math.min(primary, Number(post?.shiningLikesCount || 0))); + return { + shining, + official: Math.max(0, primary - shining), + others: Math.max(0, total - primary), + total, + }; +} + +function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining' }) { + const overlay = document.createElement('div'); + overlay.className = 'channel-likes-modal-overlay'; + overlay.innerHTML = ` + + `; + document.body.append(overlay); + + const modal = overlay.querySelector('.channel-likes-modal'); + const status = overlay.querySelector('.channel-likes-modal__status'); + const list = overlay.querySelector('.channel-likes-user-list'); + const close = () => overlay.remove(); + overlay.querySelector('.channel-likes-modal__close')?.addEventListener('click', close); + overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); }); + modal?.addEventListener('click', (event) => event.stopPropagation()); + + let activeTab = ['shining', 'official', 'others'].includes(initialTab) ? initialTab : 'shining'; + let payload = null; + + const renderTab = () => { + overlay.querySelectorAll('[data-like-tab]').forEach((button) => { + const isActive = button.dataset.likeTab === activeTab; + button.classList.toggle('is-active', isActive); + button.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); + if (!payload) return; + const rows = Array.isArray(payload?.[activeTab]) ? payload[activeTab] : []; + list.innerHTML = ''; + rows.forEach((row) => { + const userButton = document.createElement('button'); + userButton.type = 'button'; + userButton.className = 'ui-button card row profile-list-row channel-like-user-row'; + const avatarRaw = String(row?.avatarAr || '').trim(); + userButton.append(renderUserAvatar({ + login: String(row?.login || '').trim() || 'unknown', + firstName: String(row?.firstName || ''), + lastName: String(row?.lastName || ''), + avatar: avatarRaw ? { ar: avatarRaw } : null, + size: 'md', + })); + const firstName = String(row?.firstName || '').trim(); + const lastName = String(row?.lastName || '').trim(); + const login = String(row?.login || '').trim(); + const name = [firstName, lastName].filter(Boolean).join(' ') || login; + const text = document.createElement('div'); + text.className = 'profile-list-row-text'; + text.innerHTML = `${escapeHtml(name)}${escapeHtml(login)}`; + userButton.append(text); + userButton.addEventListener('click', () => { + close(); + navigate(makeProfileRoute(login)); + }); + list.append(userButton); + }); + status.textContent = rows.length ? (payload?.truncated ? 'Показаны первые 1000 лайков.' : '') : 'В этом списке пока никого нет.'; + }; + + overlay.querySelectorAll('[data-like-tab]').forEach((button) => { + button.addEventListener('click', () => { + activeTab = button.dataset.likeTab; + renderTab(); + }); + }); + renderTab(); + + (async () => { + try { + payload = await authService.getMessageLikes(messageRef, 1000); + if (!overlay.isConnected) return; + ['shining', 'official', 'others'].forEach((key) => { + const countEl = overlay.querySelector(`[data-like-tab-count="${key}"]`); + if (countEl) countEl.textContent = String(Array.isArray(payload?.[key]) ? payload[key].length : 0); + }); + renderTab(); + } catch (error) { + if (!overlay.isConnected) return; + status.className = 'channel-likes-modal__status is-error'; + status.textContent = toUserMessage(error, 'Не удалось загрузить список лайков.'); + list.innerHTML = ''; + } + })(); +} + +function openMessageLikePopup({ anchor, post, navigate, onToggleLike }) { + document.querySelectorAll('.channel-like-popup-layer').forEach((el) => el.remove()); + const counts = likeCategoryCounts(post); + const layer = document.createElement('div'); + layer.className = 'channel-like-popup-layer'; + layer.innerHTML = ` + + `; + document.body.append(layer); + const popup = layer.querySelector('.channel-like-popup'); + const rect = anchor.getBoundingClientRect(); + const width = Math.min(330, Math.max(270, window.innerWidth - 24)); + const left = Math.max(12, Math.min(window.innerWidth - width - 12, rect.left + rect.width / 2 - width / 2)); + const estimatedHeight = 255; + const top = rect.bottom + estimatedHeight < window.innerHeight - 8 + ? rect.bottom + 8 + : Math.max(8, rect.top - estimatedHeight - 8); + popup.style.width = `${width}px`; + popup.style.left = `${left}px`; + popup.style.top = `${top}px`; + + const close = () => layer.remove(); + layer.addEventListener('click', (event) => { if (event.target === layer) close(); }); + popup.addEventListener('click', (event) => event.stopPropagation()); + layer.querySelector('.channel-like-popup__close')?.addEventListener('click', close); + layer.querySelectorAll('[data-like-list]').forEach((button) => { + button.addEventListener('click', () => { + const initialTab = button.dataset.likeList; + close(); + openMessageLikesListModal({ navigate, messageRef: post.messageRef, initialTab }); + }); + }); + layer.querySelector('.channel-like-popup__action')?.addEventListener('click', async (event) => { + const button = event.currentTarget; + button.disabled = true; + try { + await onToggleLike(post.messageRef, post.reactionState === 'liked' ? 'unlike' : 'like'); + close(); + } catch (error) { + button.disabled = false; + showToast(toUserMessage(error, 'Не удалось изменить лайк.')); + } + }); +} + function renderPostCard(post, { navigate, selector, @@ -1976,18 +2139,11 @@ function renderPostCard(post, { `; setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк'); likeButton.disabled = isPending; - likeButton.addEventListener('click', async (event) => { + likeButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); if (isPending) return; - if (!isLiked) { - const ok = window.confirm('Поставить лайк?'); - if (!ok) return; - } - await longPressFeel(event.currentTarget, 130); - likeButton.disabled = true; - setActionTitle(likeButton, 'Лайк...'); - await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton }); + openMessageLikePopup({ anchor: event.currentTarget, post, navigate, onToggleLike }); }); const replyButton = document.createElement('button'); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 94cf1757..b8e4879b 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1595,6 +1595,17 @@ export class AuthService { return response.payload || {}; } + async getMessageLikes(message, limit = 1000) { + const normalizedMessage = { + blockchainName: String(message?.blockchainName || '').trim(), + blockNumber: Number(message?.blockNumber), + blockHash: String(message?.blockHash || '').trim(), + }; + const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage, limit }); + if (response.status !== 200) throw opError('GetMessageLikes', response); + return response.payload || {}; + } + async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') { const normalizedMessage = { blockchainName: String(message?.blockchainName || '').trim(), diff --git a/shine-UI/styles/features/channel.css b/shine-UI/styles/features/channel.css index 3cb2163c..bb9a72cc 100644 --- a/shine-UI/styles/features/channel.css +++ b/shine-UI/styles/features/channel.css @@ -683,3 +683,143 @@ color: #ffb7c5; background: rgba(var(--shine-action-blue-rgb), 0.12); } + +/* Channel message likes: compact popup + full user list modal. */ +.channel-like-popup-layer, +.channel-likes-modal-overlay { + position: fixed; + inset: 0; + z-index: 1600; + background: rgba(5, 9, 18, 0.26); +} + +.channel-like-popup { + position: fixed; + z-index: 1601; + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 18px; + background: rgba(20, 27, 44, 0.96); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.34); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +.channel-like-popup__title { + margin-bottom: 10px; + font-weight: 700; + font-size: 15px; +} + +.channel-like-popup__counts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.channel-like-count { + min-width: 0; + padding: 10px 6px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + display: flex; + flex-direction: column; + gap: 3px; + align-items: center; +} + +.channel-like-count b { font-size: 18px; } +.channel-like-count span { font-size: 10px; opacity: .72; overflow-wrap: anywhere; } + +.channel-like-popup__total { + padding: 10px 2px 8px; + font-size: 12px; + opacity: .75; +} + +.channel-like-popup__action, +.channel-like-popup__close { + width: 100%; + min-height: 40px; + margin-top: 7px; + border-radius: 12px; +} + +.channel-like-popup__close { opacity: .72; } + +.channel-likes-modal-overlay { + display: flex; + align-items: center; + justify-content: center; + padding: 14px; + background: rgba(5, 9, 18, 0.58); +} + +.channel-likes-modal { + width: min(560px, 100%); + max-height: min(720px, calc(100vh - 28px)); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 20px; + background: rgba(18, 25, 42, 0.98); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.channel-likes-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px 8px; +} + +.channel-likes-modal__header h3 { margin: 0; } +.channel-likes-modal__close { font-size: 24px; min-width: 38px; min-height: 38px; } + +.channel-likes-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 8px 12px 10px; +} + +.channel-likes-tabs > button { + min-width: 0; + min-height: 40px; + padding-inline: 8px; + border-radius: 12px; + font-size: 11px; +} + +.channel-likes-tabs > button.is-active { + background: rgba(255, 255, 255, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); +} + +.channel-likes-tabs span { margin-left: 3px; opacity: .68; } + +.channel-likes-modal__status { + min-height: 20px; + padding: 0 16px 8px; + font-size: 12px; + opacity: .7; +} + +.channel-likes-modal__status.is-error { opacity: 1; } + +.channel-likes-user-list { + overflow-y: auto; + padding: 0 10px 14px; +} + +.channel-like-user-row { + width: 100%; + margin: 4px 0; + padding: 10px; +} From 8e86872aa7cc6c7369f04a2bc8b1365369f5c84b11c53d320c7e68a2d6e195e1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 9 Sep 2026 18:59:54 +0300 Subject: [PATCH 06/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/config/ShineSignatureConstants.java | 3 + .../java/shine/db/DatabaseInitializer.java | 6 + .../shine/db/dao/ChannelReadStateDAO.java | 281 ++++ .../main/resources/postgres/migration_v21.sql | 25 + .../src/main/resources/postgres/schema_v1.sql | 22 +- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../blockchain/Net_AddBlock_Handler.java | 8 +- .../BlockchainWriter.java | 42 +- .../channels/ChannelsReadSupport.java | 36 +- .../channels/Net_GetMessageLikes_Handler.java | 13 +- .../Net_ListSubscriptionsFeed_Handler.java | 9 +- .../Net_SetChannelReadState_Handler.java | 137 ++ .../channels/UserCountersSupport.java | 26 +- .../Net_ListSubscriptionsFeed_Response.java | 4 + .../Net_SetChannelReadState_Request.java | 34 + .../Net_SetChannelReadState_Response.java | 34 + .../Net_UpsertUserSetting_Handler.java | 4 - VERSION.properties | 4 +- docs/API/06_Channels_Read_API.md | 83 +- docs/API/07_Channels_Feature_Runbook.md | 34 +- docs/API/09_Operations_Index.md | 1 + docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md | 1352 +++++++++++++++++ shine-UI/js/pages/channel-view.js | 101 +- shine-UI/js/pages/profile-view.js | 76 +- shine-UI/js/pages/user-profile-list-view.js | 135 +- shine-UI/js/pages/user-profile-view.js | 76 +- shine-UI/js/services/auth-service.js | 54 +- shine-UI/styles/features/channel.css | 3 + shine-UI/styles/features/profile.css | 162 ++ 29 files changed, 2538 insertions(+), 231 deletions(-) create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v21.sql create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Response.java create mode 100644 docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md diff --git a/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java b/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java index a8cb5d08..fd730bba 100644 --- a/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java +++ b/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java @@ -17,6 +17,9 @@ public final class ShineSignatureConstants { /** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */ public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:"; + /** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */ + public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:"; + /** TAG в HeaderBody (genesis). ASCII "SHiNe". */ public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe"; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index eb9bb3f6..021e9c8b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -37,6 +37,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_18 = 18; public static final int SCHEMA_VERSION_19 = 19; public static final int SCHEMA_VERSION_20 = 20; + public static final int SCHEMA_VERSION_21 = 21; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -57,6 +58,7 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql"; public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql"; public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql"; + public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql"; private DatabaseInitializer() {} @@ -206,6 +208,10 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE); currentVersion = SCHEMA_VERSION_20; } + if (currentVersion < SCHEMA_VERSION_21) { + runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE); + currentVersion = SCHEMA_VERSION_21; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java new file mode 100644 index 00000000..b5d0825c --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java @@ -0,0 +1,281 @@ +package shine.db.dao; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * Server projection for per-user channel read/unread state. + * + * Important rules: + * - rows are created only by a signed client SetChannelReadState; + * - channel publications increment unread_count only for rows that already exist; + * - read_count is monotonic and unread_count is reduced by the accepted read delta; + * - unread_count is never part of the client signature and is server-owned. + */ +public final class ChannelReadStateDAO { + private static volatile ChannelReadStateDAO instance; + + private ChannelReadStateDAO() {} + + public static ChannelReadStateDAO getInstance() { + if (instance == null) { + synchronized (ChannelReadStateDAO.class) { + if (instance == null) instance = new ChannelReadStateDAO(); + } + } + return instance; + } + + public record ReadState(long readCount, long unreadCount, long readUpdatedAtMs, + String clientKey, String readSignature) {} + + public record UpsertResult(boolean applied, long readCount, long unreadCount) {} + + public ReadState get(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException { + String sql = """ + SELECT read_count, unread_count, read_updated_at_ms, client_key, read_signature + FROM channel_read_state + WHERE LOWER(viewer_login)=LOWER(?) AND owner_bch_name=? AND channel_name=? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setString(2, ownerBchName); + ps.setString(3, channelName); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return new ReadState( + Math.max(0L, rs.getLong("read_count")), + Math.max(0L, rs.getLong("unread_count")), + rs.getLong("read_updated_at_ms"), + rs.getString("client_key"), + rs.getString("read_signature")); + } + } + } + + public long getUnreadCount(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException { + ReadState state = get(c, viewerLogin, ownerBchName, channelName); + return state == null ? 0L : state.unreadCount(); + } + + public long sumUnreadCount(Connection c, String viewerLogin) throws SQLException { + String sql = """ + SELECT COALESCE(SUM(crs.unread_count),0) + FROM channel_read_state crs + WHERE LOWER(crs.viewer_login)=LOWER(?) + AND EXISTS ( + SELECT 1 + FROM channel_names_state cns + JOIN connections_state cs + ON LOWER(cs.login)=LOWER(crs.viewer_login) + AND cs.rel_type=30 + AND cs.to_bch_name=cns.owner_bch_name + AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number + AND cs.to_block_hash=cns.channel_root_block_hash + WHERE cns.owner_bch_name=crs.owner_bch_name + AND cns.slug=crs.channel_name + AND cns.channel_type_code=1 + ) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L; + } + } + } + + /** + * Validates that the requested public channel exists and that viewer currently follows it. + */ + public boolean hasActiveSubscription(Connection c, String viewerLogin, String ownerBchName, String channelName, + int followRelType) throws SQLException { + String sql = """ + SELECT 1 + FROM channel_names_state cns + JOIN connections_state cs + ON LOWER(cs.login)=LOWER(?) + AND cs.rel_type=? + AND cs.to_bch_name=cns.owner_bch_name + AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number + AND cs.to_block_hash=cns.channel_root_block_hash + WHERE cns.owner_bch_name=? + AND cns.slug=? + AND cns.channel_type_code=1 + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setInt(2, followRelType); + ps.setString(3, ownerBchName); + ps.setString(4, channelName); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + public String resolvePublicChannelNameByRoot(Connection c, String ownerBchName, int rootBlockNumber) throws SQLException { + String sql = """ + SELECT slug + FROM channel_names_state + WHERE owner_bch_name=? AND channel_root_block_number=? AND channel_type_code=1 + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerBchName); + ps.setInt(2, rootBlockNumber); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString("slug") : null; + } + } + } + + public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException { + String sql = """ + SELECT owner_login + FROM channel_names_state + WHERE owner_bch_name=? AND slug=? AND channel_type_code=1 + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerBchName); + ps.setString(2, channelName); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString("owner_login") : null; + } + } + } + + /** + * Inserts the first client baseline with unread=0, or advances an existing read watermark. + * Older timestamps and backwards read_count values are ignored. + */ + public UpsertResult upsertSignedReadIfNewer(Connection c, + String viewerLogin, + String ownerLogin, + String ownerBchName, + String channelName, + long readCount, + long readUpdatedAtMs, + String clientKey, + String readSignature, + long nowMs) throws SQLException { + String sql = """ + INSERT INTO channel_read_state ( + viewer_login, owner_login, owner_bch_name, channel_name, + read_count, unread_count, + read_updated_at_ms, client_key, read_signature, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?) + ON CONFLICT (viewer_login, owner_bch_name, channel_name) + DO UPDATE SET + owner_login = EXCLUDED.owner_login, + unread_count = GREATEST( + 0, + channel_read_state.unread_count - + GREATEST(0, EXCLUDED.read_count - channel_read_state.read_count) + ), + read_count = GREATEST(channel_read_state.read_count, EXCLUDED.read_count), + read_updated_at_ms = EXCLUDED.read_updated_at_ms, + client_key = EXCLUDED.client_key, + read_signature = EXCLUDED.read_signature, + updated_at_ms = EXCLUDED.updated_at_ms + WHERE channel_read_state.read_updated_at_ms < EXCLUDED.read_updated_at_ms + AND EXCLUDED.read_count >= channel_read_state.read_count + RETURNING read_count, unread_count + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setString(2, ownerLogin); + ps.setString(3, ownerBchName); + ps.setString(4, channelName); + ps.setLong(5, Math.max(0L, readCount)); + ps.setLong(6, readUpdatedAtMs); + ps.setString(7, clientKey); + ps.setString(8, readSignature); + ps.setLong(9, nowMs); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return new UpsertResult(true, + Math.max(0L, rs.getLong("read_count")), + Math.max(0L, rs.getLong("unread_count"))); + } + } + } + ReadState current = get(c, viewerLogin, ownerBchName, channelName); + return new UpsertResult(false, + current == null ? 0L : current.readCount(), + current == null ? 0L : current.unreadCount()); + } + + /** + * Increments existing rows for current subscribers of one public channel. + * Missing channel_read_state rows are deliberately NOT created. + * Returns affected viewer logins for post-commit counter pushes. + */ + public List incrementUnreadForSubscribers(Connection c, + String ownerBchName, + String channelName, + int followRelType, + long nowMs) throws SQLException { + String sql = """ + UPDATE channel_read_state crs + SET unread_count = crs.unread_count + 1, + updated_at_ms = ? + WHERE crs.owner_bch_name = ? + AND crs.channel_name = ? + AND LOWER(crs.viewer_login) <> LOWER(crs.owner_login) + AND EXISTS ( + SELECT 1 + FROM channel_names_state cns + JOIN connections_state cs + ON LOWER(cs.login)=LOWER(crs.viewer_login) + AND cs.rel_type=? + AND cs.to_bch_name=cns.owner_bch_name + AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number + AND cs.to_block_hash=cns.channel_root_block_hash + WHERE cns.owner_bch_name=crs.owner_bch_name + AND cns.slug=crs.channel_name + AND cns.channel_type_code=1 + ) + RETURNING viewer_login + """; + List affected = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, nowMs); + ps.setString(2, ownerBchName); + ps.setString(3, channelName); + ps.setInt(4, followRelType); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) affected.add(rs.getString("viewer_login")); + } + } + return affected; + } + + /** Delete the projection row when a channel FOLLOW is explicitly removed. */ + public boolean deleteForUnfollowTarget(Connection c, String viewerLogin, String ownerBchName, + int rootBlockNumber) throws SQLException { + String sql = """ + DELETE FROM channel_read_state crs + USING channel_names_state cns + WHERE LOWER(crs.viewer_login)=LOWER(?) + AND crs.owner_bch_name=? + AND cns.owner_bch_name=crs.owner_bch_name + AND cns.slug=crs.channel_name + AND cns.channel_root_block_number=? + AND cns.channel_type_code=1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setString(2, ownerBchName); + ps.setInt(3, rootBlockNumber); + return ps.executeUpdate() > 0; + } + } +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v21.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v21.sql new file mode 100644 index 00000000..d5af4b69 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v21.sql @@ -0,0 +1,25 @@ +-- Channel unread/read projection. +-- The row is created only by a signed SetChannelReadState from the client. +-- New channel publications only increment existing rows. + +CREATE TABLE IF NOT EXISTS channel_read_state ( + viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + owner_login TEXT NOT NULL, + owner_bch_name TEXT NOT NULL, + channel_name TEXT NOT NULL, + read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0), + unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0), + read_updated_at_ms BIGINT NOT NULL, + client_key TEXT NOT NULL, + read_signature TEXT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (viewer_login, owner_bch_name, channel_name) +); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer + ON channel_read_state(viewer_login); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel + ON channel_read_state(owner_bch_name, channel_name); + +UPDATE db_schema_version SET schema_version = 21 WHERE id = 1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 08330371..52081438 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -425,6 +425,26 @@ CREATE INDEX IF NOT EXISTS idx_user_settings_login CREATE INDEX IF NOT EXISTS idx_user_settings_type_key ON user_settings(setting_type, setting_key); +CREATE TABLE IF NOT EXISTS channel_read_state ( + viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + owner_login TEXT NOT NULL, + owner_bch_name TEXT NOT NULL, + channel_name TEXT NOT NULL, + read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0), + unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0), + read_updated_at_ms BIGINT NOT NULL, + client_key TEXT NOT NULL, + read_signature TEXT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (viewer_login, owner_bch_name, channel_name) +); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer + ON channel_read_state(viewer_login); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel + ON channel_read_state(owner_bch_name, channel_name); + CREATE TABLE IF NOT EXISTS ip_geo_cache ( ip TEXT PRIMARY KEY, geo TEXT, @@ -2009,7 +2029,7 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state ( ); INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,20,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,21,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 3462dfc9..5dbf6e86 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -82,6 +82,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_H import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler; +import server.logic.ws_protocol.JSON.handlers.channels.Net_SetChannelReadState_Handler; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request; @@ -91,6 +92,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalD import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request; import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler; import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler; @@ -217,6 +219,7 @@ public final class JsonHandlerRegistry { Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()), Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()), Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()), + Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()), Map.entry("ListContacts", new Net_ListContacts_Handler()), Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()), @@ -310,6 +313,7 @@ public final class JsonHandlerRegistry { Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class), Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class), Map.entry("GetUserCounters", Net_GetUserCounters_Request.class), + Map.entry("SetChannelReadState", Net_SetChannelReadState_Request.class), Map.entry("ListContacts", Net_ListContacts_Request.class), Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index da343d11..0c10212e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -26,6 +26,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response; import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser; import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper; +import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport; import server.logic.ws_protocol.WireCodes; import server.sync.AddBlockSyncService; import shine.db.channels.ChannelNameRules; @@ -42,6 +43,7 @@ import shine.db.entities.UserParamEntry; import utils.blockchain.BlockchainNameUtil; import java.util.Arrays; +import java.util.List; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; @@ -586,9 +588,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } UserNotificationEntry notificationEntry = buildNotificationEntry(block, be); - dbWriter.appendBlockAndState( + List counterChangedLogins = dbWriter.appendBlockAndState( blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry); + for (String changedLogin : counterChangedLogins) { + UserCountersSupport.pushChanged(changedLogin); + } + if (chat200CreateSeed != null) { upsertChat200StateFromCreate(chat200CreateSeed); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index 72a730d1..936b3c1d 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -4,6 +4,8 @@ import blockchain.BchBlockEntry; import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlocksDAO; import shine.db.dao.ChannelNameStateDAO; +import shine.db.dao.ChannelReadStateDAO; +import shine.db.MsgSubType; import shine.db.dao.UserParamsDAO; import shine.db.dao.UserNotificationsStateDAO; import shine.db.entities.BlockchainStateEntry; @@ -19,6 +21,8 @@ import java.nio.file.Path; import java.sql.Connection; import java.sql.SQLException; import java.util.HexFormat; +import java.util.ArrayList; +import java.util.List; /** * BlockchainWriter — запись блока в БД и формирование файловой версии цепочки. @@ -44,6 +48,7 @@ public final class BlockchainWriter { private final ChannelNameStateDAO channelNameStateDAO; private final UserParamsDAO userParamsDAO; private final UserNotificationsStateDAO userNotificationsStateDAO; + private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance(); private final FileStoreUtil fs = FileStoreUtil.getInstance(); public BlockchainWriter(BlocksDAO blocksDAO, @@ -58,7 +63,7 @@ public final class BlockchainWriter { this.userNotificationsStateDAO = userNotificationsStateDAO; } - public void appendBlockAndState(String blockchainName, + public List appendBlockAndState(String blockchainName, BchBlockEntry block, BlockchainStateEntry st, BlockEntry be, @@ -75,6 +80,7 @@ public final class BlockchainWriter { prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes); boolean committed = false; + List counterChangedLogins = new ArrayList<>(); try (Connection c = shine.db.DbController.getInstance().getConnection()) { c.setAutoCommit(false); try { @@ -108,6 +114,29 @@ public final class BlockchainWriter { userNotificationsStateDAO.upsert(c, notificationEntry); } + // Channel read/unread projection is updated in the same SQL transaction as the block. + int msgType = block.type & 0xFFFF; + int msgSubType = block.subType & 0xFFFF; + Integer lineCode = be.getLineCode(); + if (isPublicChannelPublication(msgType, msgSubType) && lineCode != null && lineCode > 0) { + String channelName = channelReadStateDAO.resolvePublicChannelNameByRoot(c, blockchainName, lineCode); + if (channelName != null && !channelName.isBlank()) { + counterChangedLogins.addAll(channelReadStateDAO.incrementUnreadForSubscribers( + c, blockchainName, channelName, MsgSubType.CONNECTION_FOLLOW, nowMs)); + } + } + + // Explicit UNFOLLOW removes the projection row. FOLLOW never creates it. + if (msgType == 3 + && msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF) + && be.getToBchName() != null && !be.getToBchName().isBlank() + && be.getToBlockNumber() != null && be.getToBlockNumber() > 0) { + if (channelReadStateDAO.deleteForUnfollowTarget( + c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) { + counterChangedLogins.add(be.getLogin()); + } + } + c.commit(); committed = true; } catch (Exception e) { @@ -142,6 +171,17 @@ public final class BlockchainWriter { // 4) После успешной подмены — чистим временные артефакты. cleanupWriteArtifactsBestEffort(blockchainName); + return counterChangedLogins; + } + + private static boolean isPublicChannelPublication(int msgType, int msgSubType) { + if (msgType != 1) return false; + return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF); } private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index b0cfce4d..bd3ac25a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -144,38 +144,12 @@ final class ChannelsReadSupport { } } - static String userSettingsChannelKey(String ownerBch, String channelName) { - String bch = ownerBch == null ? "" : ownerBch.trim(); - String name = channelName == null ? "" : channelName.trim(); - return bch + "/" + name; - } - static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException { - if (viewerLogin == null || viewerLogin.isBlank()) return 0; - String key = userSettingsChannelKey(ownerBch, channelName); - String sql = """ - SELECT value_num - FROM user_settings - WHERE LOWER(login) = LOWER(?) - AND setting_type = ? - AND setting_key = ? - LIMIT 1 - """; - long lastSeen = messagesCount; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, viewerLogin); - ps.setInt(2, 1); - ps.setString(3, key); - try (ResultSet rs = ps.executeQuery()) { - if (rs.next()) { - long value = rs.getLong("value_num"); - if (!rs.wasNull()) lastSeen = value; - } - } - } - if (lastSeen < 0) lastSeen = 0; - if (lastSeen > messagesCount) return 0; - return Math.max(0, messagesCount - (int) lastSeen); + if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() + || channelName == null || channelName.isBlank()) return 0; + long unread = shine.db.dao.ChannelReadStateDAO.getInstance() + .getUnreadCount(c, viewerLogin, ownerBch, channelName); + return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread); } static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java index beec7175..3cd3fa8d 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java @@ -20,7 +20,6 @@ import java.util.List; public class Net_GetMessageLikes_Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class); - private static final int HARD_LIMIT = 1000; @Override public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { @@ -42,7 +41,6 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash"); } - int requested = req.getLimit() == null ? HARD_LIMIT : Math.max(1, Math.min(HARD_LIMIT, req.getLimit())); try (Connection c = DbController.getInstance().getConnection()) { String sql = """ SELECT rs.from_login, @@ -59,7 +57,6 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler { AND rs.to_block_number = ? AND rs.to_block_hash = ? ORDER BY LOWER(rs.from_login) - LIMIT ? """; List shining = new ArrayList<>(); @@ -69,16 +66,8 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler { ps.setString(1, message.getBlockchainName().trim()); ps.setInt(2, message.getBlockNumber()); ps.setBytes(3, blockHash); - ps.setInt(4, requested + 1); try (ResultSet rs = ps.executeQuery()) { - int accepted = 0; - boolean truncated = false; while (rs.next()) { - if (accepted >= requested) { - truncated = true; - break; - } - accepted++; Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem(); user.setLogin(rs.getString("from_login")); user.setFirstName(rs.getString("first_name")); @@ -100,7 +89,7 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler { resp.setOfficial(official); resp.setOthers(others); resp.setTotal(shining.size() + official.size() + others.size()); - resp.setTruncated(truncated); + resp.setTruncated(false); return resp; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java index faebbb6c..394215c4 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java @@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; import shine.db.DbController; +import shine.db.dao.ChannelReadStateDAO; import java.sql.Connection; import java.sql.PreparedStatement; @@ -83,9 +84,11 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { row.setChannel(channelRef); row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber)); boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin); - row.setUnreadCount(ownChannel - ? 0 - : ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount())); + ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null + ? null + : ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName); + row.setReadStateInitialized(readState != null); + row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount())); ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber); if (lastPost != null) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java new file mode 100644 index 00000000..2c960e76 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java @@ -0,0 +1,137 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.Base64Ws; +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.channels.entyties.Net_SetChannelReadState_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; +import shine.db.MsgSubType; +import shine.db.dao.ChannelReadStateDAO; +import utils.config.ShineSignatureConstants; +import utils.crypto.Ed25519Util; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; + +/** Accepts the signed client read watermark for one followed public channel. */ +public final class Net_SetChannelReadState_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_SetChannelReadState_Handler.class); + private static final long MAX_FUTURE_SKEW_MS = 5 * 60_000L; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_SetChannelReadState_Request req = (Net_SetChannelReadState_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "NOT_AUTHENTICATED", "Требуется авторизация"); + } + if (req.getLogin() == null || req.getLogin().isBlank() + || req.getOwner_bch_name() == null || req.getOwner_bch_name().isBlank() + || req.getChannel_name() == null || req.getChannel_name().isBlank() + || req.getRead_count() == null || req.getRead_count() < 0 + || req.getTime_ms() == null || req.getTime_ms() <= 0 + || req.getClient_key() == null || req.getClient_key().isBlank() + || req.getSignature() == null || req.getSignature().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, + "BAD_FIELDS", "Некорректные поля ChannelReadState"); + } + + String authenticatedLogin = String.valueOf(ctx.getCurrentUser().getLogin()).trim(); + String login = req.getLogin().trim(); + String ownerBch = req.getOwner_bch_name().trim(); + String channelName = req.getChannel_name().trim(); + long readCount = req.getRead_count(); + long timeMs = req.getTime_ms(); + String clientKeyB64 = req.getClient_key().trim(); + String signatureB64 = req.getSignature().trim(); + + if (!authenticatedLogin.equalsIgnoreCase(login)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "LOGIN_MISMATCH", "Состояние чтения принадлежит другому пользователю"); + } + long nowMs = System.currentTimeMillis(); + if (timeMs > nowMs + MAX_FUTURE_SKEW_MS) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "BAD_TIME", "Некорректное время подписи"); + } + + try { + byte[] pubKey32; + byte[] sig64; + try { + pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key"); + sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, + "BAD_BASE64", "client_key/signature должны быть Base64"); + } + + String userClientKey = String.valueOf(ctx.getCurrentUser().getClientKey() == null + ? "" : ctx.getCurrentUser().getClientKey()).trim(); + if (userClientKey.isBlank() || !userClientKey.equals(clientKeyB64)) { + return NetExceptionResponseFactory.error(req, 403, + "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю"); + } + + String signText = ShineSignatureConstants.CHANNEL_READ_STATE_PREFIX + + escapePart(login) + '|' + + escapePart(ownerBch) + '|' + + escapePart(channelName) + '|' + + timeMs + '|' + + readCount; + if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "INVALID_SIGNATURE", "Подпись состояния чтения канала не прошла проверку"); + } + + ChannelReadStateDAO dao = ChannelReadStateDAO.getInstance(); + ChannelReadStateDAO.UpsertResult result; + try (Connection c = DbController.getInstance().getConnection()) { + if (!dao.hasActiveSubscription(c, authenticatedLogin, ownerBch, channelName, + MsgSubType.CONNECTION_FOLLOW)) { + return NetExceptionResponseFactory.error(req, 409, + "CHANNEL_NOT_FOLLOWED", "Пользователь не подписан на этот канал"); + } + String ownerLogin = dao.resolveOwnerLogin(c, ownerBch, channelName); + if (ownerLogin == null || ownerLogin.isBlank()) { + return NetExceptionResponseFactory.error(req, 404, + "CHANNEL_NOT_FOUND", "Канал не найден"); + } + result = dao.upsertSignedReadIfNewer(c, + authenticatedLogin, ownerLogin, ownerBch, channelName, + readCount, timeMs, clientKeyB64, signatureB64, nowMs); + } + + Net_SetChannelReadState_Response resp = new Net_SetChannelReadState_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(authenticatedLogin); + resp.setOwner_bch_name(ownerBch); + resp.setChannel_name(channelName); + resp.setRead_count(result.readCount()); + resp.setUnread_count(result.unreadCount()); + resp.setTime_ms(timeMs); + resp.setApplied(result.applied()); + if (result.applied()) UserCountersSupport.pushChanged(authenticatedLogin); + return resp; + } catch (Exception e) { + log.error("SetChannelReadState failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, + "INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage( + "Внутренняя ошибка SetChannelReadState", e)); + } + } + + private static String escapePart(String value) { + String s = String.valueOf(value == null ? "" : value); + return s.replace("\\", "\\\\").replace("|", "\\|"); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java index c0e32f61..c01830db 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java @@ -6,8 +6,8 @@ import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.push.WsEventSender; import shine.db.DbController; -import shine.db.MsgSubType; import shine.db.dao.UserNotificationSeenStateDAO; +import shine.db.dao.ChannelReadStateDAO; import shine.db.dao.UserNotificationsStateDAO; import java.sql.Connection; @@ -89,28 +89,6 @@ public final class UserCountersSupport { } private static long countChannelsUnread(Connection c, String login) throws Exception { - String sql = """ - SELECT cs.to_bch_name, COALESCE(cs.to_block_number,0) AS root_number - FROM connections_state cs - WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=? - """; - long total = 0L; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, login); - ps.setInt(2, MsgSubType.CONNECTION_FOLLOW); - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - String ownerBch = rs.getString("to_bch_name"); - int rootNumber = rs.getInt("root_number"); - if (ownerBch == null || ownerBch.isBlank()) continue; - ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, rootNumber); - if (meta == null || meta.channelName == null || meta.channelName.isBlank()) continue; - if (meta.channelTypeCode == 0 || "stories".equalsIgnoreCase(meta.channelName)) continue; - int messages = ChannelsReadSupport.countPosts(c, ownerBch, rootNumber); - total += Math.max(0, ChannelsReadSupport.countUnreadMessages(c, login, ownerBch, meta.channelName, messages)); - } - } - } - return total; + return ChannelReadStateDAO.getInstance().sumUnreadCount(c, login); } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java index 2c1651ac..1f163dea 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java @@ -27,6 +27,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { private ChannelRef channel; private int messagesCount; private int unreadCount; + private boolean readStateInitialized; private LastMessage lastMessage; public ChannelRef getChannel() { return channel; } @@ -38,6 +39,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { public int getUnreadCount() { return unreadCount; } public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; } + public boolean isReadStateInitialized() { return readStateInitialized; } + public void setReadStateInitialized(boolean readStateInitialized) { this.readStateInitialized = readStateInitialized; } + public LastMessage getLastMessage() { return lastMessage; } public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Request.java new file mode 100644 index 00000000..35c52496 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Request.java @@ -0,0 +1,34 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_SetChannelReadState_Request extends Net_Request { + private String login; + private String owner_bch_name; + private String channel_name; + private Long read_count; + private Long time_ms; + private String client_key; + private String signature; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public String getOwner_bch_name() { return owner_bch_name; } + public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; } + + public String getChannel_name() { return channel_name; } + public void setChannel_name(String channel_name) { this.channel_name = channel_name; } + + public Long getRead_count() { return read_count; } + public void setRead_count(Long read_count) { this.read_count = read_count; } + + public Long getTime_ms() { return time_ms; } + public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } + + public String getClient_key() { return client_key; } + public void setClient_key(String client_key) { this.client_key = client_key; } + + public String getSignature() { return signature; } + public void setSignature(String signature) { this.signature = signature; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Response.java new file mode 100644 index 00000000..9db51d78 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Response.java @@ -0,0 +1,34 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_SetChannelReadState_Response extends Net_Response { + private String login; + private String owner_bch_name; + private String channel_name; + private Long read_count; + private Long unread_count; + private Long time_ms; + private Boolean applied; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public String getOwner_bch_name() { return owner_bch_name; } + public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; } + + public String getChannel_name() { return channel_name; } + public void setChannel_name(String channel_name) { this.channel_name = channel_name; } + + public Long getRead_count() { return read_count; } + public void setRead_count(Long read_count) { this.read_count = read_count; } + + public Long getUnread_count() { return unread_count; } + public void setUnread_count(Long unread_count) { this.unread_count = unread_count; } + + public Long getTime_ms() { return time_ms; } + public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } + + public Boolean getApplied() { return applied; } + public void setApplied(Boolean applied) { this.applied = applied; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java index eeaf5a3e..375dacfd 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java @@ -9,7 +9,6 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request; import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Response; -import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.DbController; @@ -101,9 +100,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler { resp.setSetting_type(settingType); resp.setSetting_key(settingKey); resp.setTime_ms(timeMs); - if (settingType == 1) { - UserCountersSupport.pushChanged(login); - } return resp; } } catch (SQLException e) { diff --git a/VERSION.properties b/VERSION.properties index d30e91ce..adbdaefc 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.5 -server.version=1.10.2 +client.version=1.12.6 +server.version=1.10.3 diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index 944a3b06..99172efd 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -21,9 +21,11 @@ 6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. -7. `ListGroupChats200` — отдает список групповых чатов типа `200`. +7. `SetChannelReadState` — сохраняет подписанный watermark чтения канала и возвращает новый unread-счетчик. -8. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. +8. `ListGroupChats200` — отдает список групповых чатов типа `200`. + +9. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. > На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки. > @@ -110,6 +112,8 @@ "channelRoot": { "blockNumber": 456, "blockHash": "..." } }, "messagesCount": 90, + "unreadCount": 0, + "readStateInitialized": false, "lastMessage": { "messageRef": { "blockNumber": 1002, "blockHash": "..." }, "text": "актуальный текст", @@ -274,7 +278,7 @@ Возвращает пользователей, которые поставили лайк конкретному сообщению канала. - `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение. -- `limit` ограничивается сервером сверху значением `1000`. +- `limit` в текущей реализации не требуется: сервер возвращает полный найденный список лайков. - Пользователи группируются по состоянию профиля: - `shining` — `account_role=primary` и `shine_status=shining`; - `official` — `account_role=primary`, но без `shine_status=shining`; @@ -290,8 +294,7 @@ "blockchainName": "bob-001", "blockNumber": 140, "blockHash": "..." - }, - "limit": 1000 + } } } ``` @@ -322,6 +325,8 @@ - `bad_hash` — `message.blockHash` не является корректным hex-хэшем блока. - `internal_error` — внутренняя ошибка чтения. +`truncated` сейчас всегда `false`; поле оставлено в ответе для совместимости с UI и возможной будущей пагинацией. + --- ## 5) GetPersonalDiary @@ -379,7 +384,69 @@ --- -## 7) ListGroupChats200 +## 7) SetChannelReadState + +Сохраняет подписанную позицию чтения для одного канала, на который пользователь подписан. + +- Требует авторизованное WebSocket-соединение. +- `login` должен совпадать с текущим авторизованным пользователем. +- Подпись строится client key пользователя по строке: + +```text +SHiNe/ChannelReadState:|||| +``` + +### Request +```json +{ + "op": "SetChannelReadState", + "requestId": "req-7", + "payload": { + "login": "Alice", + "owner_bch_name": "bob-001", + "channel_name": "news", + "read_count": 90, + "time_ms": 1760000000000, + "client_key": "", + "signature": "" + } +} +``` + +### Response (success) +```json +{ + "op": "SetChannelReadState", + "requestId": "req-7", + "status": 200, + "ok": true, + "payload": { + "login": "Alice", + "owner_bch_name": "bob-001", + "channel_name": "news", + "read_count": 90, + "unread_count": 0, + "time_ms": 1760000000000, + "applied": true + } +} +``` + +### Ошибки +- `NOT_AUTHENTICATED` — нет авторизованной сессии. +- `BAD_FIELDS` — не переданы обязательные поля. +- `LOGIN_MISMATCH` — `login` не совпадает с текущей сессией. +- `BAD_TIME` — время подписи слишком далеко в будущем. +- `BAD_BASE64` — `client_key` или `signature` не являются корректным Base64. +- `DEVICE_KEY_MISMATCH` — `client_key` не совпадает с текущим client key пользователя. +- `INVALID_SIGNATURE` — подпись watermark не прошла проверку. +- `CHANNEL_NOT_FOLLOWED` — пользователь не подписан на канал. +- `CHANNEL_NOT_FOUND` — канал не найден. +- `INTERNAL_ERROR` — внутренняя ошибка записи. + +--- + +## 8) ListGroupChats200 ### Request ```json @@ -419,7 +486,7 @@ --- -## 8) GetGroupDialog +## 9) GetGroupDialog ### Request ```json @@ -474,6 +541,8 @@ - `message_not_found` - `limit_too_large` - `channel_name_already_exists` +- `CHANNEL_NOT_FOLLOWED` +- `CHANNEL_NOT_FOUND` - `internal_error` --- diff --git a/docs/API/07_Channels_Feature_Runbook.md b/docs/API/07_Channels_Feature_Runbook.md index 9df49d25..235bf663 100644 --- a/docs/API/07_Channels_Feature_Runbook.md +++ b/docs/API/07_Channels_Feature_Runbook.md @@ -9,6 +9,7 @@ - `GetChannelMessages` — сообщения конкретного канала. - `GetMessageThread` — дерево обсуждения для сообщения. - `GetMessageLikes` — списки пользователей, поставивших лайк сообщению. + - `SetChannelReadState` — подписанный watermark чтения канала. 2. **UI вкладки Каналы**: - при открытии пытается загрузить реальный feed с сервера; @@ -35,13 +36,15 @@ 2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`. 3. Для первого `messages[0]` вызвать `GetMessageThread`. 4. Для первого `messages[0]` вызвать `GetMessageLikes`. +5. Для подписанного канала вызвать `SetChannelReadState` с текущим `messagesCount`. ### Ошибки 1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`. 2. `GetChannelMessages` с битым channel payload -> `bad_fields`. 3. `GetMessageThread` с несуществующим block -> `message_not_found`. 4. `GetMessageLikes` с битым `message.blockHash` -> `bad_hash`. -5. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`. +5. `SetChannelReadState` без активной подписки -> `CHANNEL_NOT_FOLLOWED`. +6. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`. --- @@ -104,8 +107,24 @@ "blockchainName": "TestUser1-001", "blockNumber": 123, "blockHash": "" - }, - "limit": 1000 + } + } +} +``` + +## 3.5 SetChannelReadState +```json +{ + "op": "SetChannelReadState", + "requestId": "debug-read-state-1", + "payload": { + "login": "TestUser1", + "owner_bch_name": "TestUser2-001", + "channel_name": "news", + "read_count": 25, + "time_ms": 1760000000000, + "client_key": "", + "signature": "" } } ``` @@ -120,6 +139,8 @@ - у каждой записи есть: - `channel.channelRoot.blockNumber`, - `messagesCount`, + - `unreadCount`, + - `readStateInitialized`, - `lastMessage` (может быть null, если сообщений нет). ### GetChannelMessages @@ -137,7 +158,12 @@ ### GetMessageLikes - `payload.shining[]`, `payload.official[]`, `payload.others[]` — группы пользователей. - у каждого пользователя есть `login`, `firstName`, `lastName`, `avatarAr`. -- `payload.truncated=true` означает, что сервер обрезал список по `limit`. +- `payload.truncated` сейчас всегда `false`; поле оставлено для совместимости и будущей пагинации. + +### SetChannelReadState +- `payload.read_count` — сохраненная позиция чтения. +- `payload.unread_count` — пересчитанный unread для канала. +- `payload.applied=false` означает, что на сервере уже была более новая signed-позиция. ### Важно по совместимости - `rawBlockB64` добавлен только в `GetMessageThread`. diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index abaa33da..02ff9351 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -49,6 +49,7 @@ | `GetMessageLikes` | `06_Channels_Read_API.md` | списки пользователей, поставивших лайк сообщению | | `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION | | `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов | +| `SetChannelReadState` | `06_Channels_Read_API.md` | подписанный watermark чтения канала | | `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` | | `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` | | `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя | diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md new file mode 100644 index 00000000..52a0afd2 --- /dev/null +++ b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md @@ -0,0 +1,1352 @@ +# SHiNE Archive Protocol v1.0 + +## Полная русская спецификация серверной фиксации больших блоков, Arweave, Solana User PDA и навигации по предыдущим записям пользователя + +**Статус:** рабочая спецификация v1.0 +**Формат:** бинарный +**Magic:** `SHINE-ARCHIVE` +**Версия:** два отдельных байта `major` / `minor` + +--- + +# 1. Назначение + +Протокол предназначен для периодической долговременной фиксации новых SHiNE-блоков. + +Стартовая схема: + +```text +Основной SHiNE-сервер + | + | существующая межсерверная синхронизация + v +Raspberry / архивный SHiNE-сервер + | + +--> видит все новые локальные SHiNE-блоки + +--> фиксирует стабильную дельту + +--> собирает большой SHINE-ARCHIVE блок + +--> загружает его в Arweave + +--> ждёт закрепления Arweave-транзакции + +--> обычным update своего User PDA записывает новый archive head +``` + +Существующую межсерверную синхронизацию в v1 менять не требуется. + +Архивный сервер не создаёт и не переподписывает пользовательские сообщения. Он берёт уже существующие пользовательские записи и добавляет только серверную навигационную метаинформацию большого блока. + +--- + +# 2. Основные принципы + +1. Публикуется только новая дельта. +2. Дельта замораживается до начала загрузки. +3. Новые данные, пришедшие во время публикации, попадают в следующий большой блок. +4. После загрузки сервер ждёт закрепления Arweave-транзакции. +5. Только после закрепления Arweave сервер обновляет свой User PDA в Solana. +6. Только после `finalized` в Solana локальные курсоры считаются окончательно сдвинутыми. +7. Большой блок имеет собственный SHA-256 и подпись составившего его SHiNE-аккаунта. +8. Каждый большой блок содержит список предыдущих больших блоков с номером, hash и Arweave TX ID. +9. В v1 в начало каждого нового большого блока записывается полный список всех известных предыдущих больших блоков данной ветки. +10. Для каждого пользователя сервер строит обратную цепочку не на одну предыдущую запись, а на **предыдущий большой блок, где были записи этого пользователя, плюс список всех его записей внутри того блока**. + +--- + +# 3. Ключи архивного сервера + +Для первой реализации серверу требуются: + +- `root private key` SHiNE-аккаунта; +- `client private key` SHiNE-аккаунта; +- Arweave JWK/private wallet key. + +`blockchain private key` для самой архивной публикации не требуется: пользовательские записи уже существуют и уже подписаны. + +В v1 большой блок может подписываться root key того SHiNE-аккаунта, который его сформировал. + +--- + +# 4. Локальное состояние дельты + +Нужно различать: + +1. уже окончательно опубликованное состояние; +2. текущую замороженную дельту, которая ещё проходит Arweave/Solana. + +Рекомендуются три таблицы. + +## 4.1. `archive_chain_cursor` + +```sql +CREATE TABLE archive_chain_cursor ( + blockchain_name TEXT PRIMARY KEY, + last_archived_block_number BIGINT NOT NULL, + last_archived_block_hash BYTEA NOT NULL, + updated_at_ms BIGINT NOT NULL +); +``` + +Она хранит точку, до которой конкретная локальная blockchain уже окончательно вошла в опубликованный и зафиксированный archive head. + +## 4.2. `archive_publish_job` + +```sql +CREATE TABLE archive_publish_job ( + id BIGSERIAL PRIMARY KEY, + archive_number BIGINT NOT NULL, + status TEXT NOT NULL, + parent_arweave_tx_id BYTEA, + parent_archive_hash BYTEA, + created_at_ms BIGINT NOT NULL, + local_archive_path TEXT, + archive_hash BYTEA, + arweave_tx_id BYTEA, + arweave_confirmations INTEGER, + solana_signature TEXT, + error_text TEXT, + updated_at_ms BIGINT NOT NULL +); +``` + +Рекомендуемые состояния: + +```text +SNAPSHOT_CREATED +FILE_BUILT +ARWEAVE_UPLOADED +ARWEAVE_CONFIRMED +SOLANA_SUBMITTED +SOLANA_FINALIZED +CURSORS_COMMITTED +FAILED +``` + +## 4.3. `archive_publish_job_chain` + +```sql +CREATE TABLE archive_publish_job_chain ( + job_id BIGINT NOT NULL, + blockchain_name TEXT NOT NULL, + from_block_number BIGINT NOT NULL, + to_block_number BIGINT NOT NULL, + previous_block_hash BYTEA NOT NULL, + last_block_hash BYTEA NOT NULL, + PRIMARY KEY (job_id, blockchain_name) +); +``` + +Сырые блоки в staging-таблицы копировать не нужно. В job фиксируются только точные диапазоны и крайние hash. + +--- + +# 5. Заморозка дельты + +Пример: + +```text +локально сейчас: +Alice = 180 +Bob = 94 + +уже опубликовано: +Alice = 160 +Bob = 90 +``` + +Новый job фиксирует: + +```text +Alice: 161..180 +Bob: 91..94 +``` + +Если во время Arweave upload локальное состояние стало: + +```text +Alice = 185 +Bob = 97 +``` + +текущий job НЕ меняется. + +Следующая публикация начнётся с: + +```text +Alice: 181..185 +Bob: 95..97 +``` + +--- + +# 6. Общая структура большого блока + +Большой опубликованный файл одновременно является: + +- архивной дельтой; +- индексом предыдущих больших блоков; +- контейнером пользовательских записей; +- навигационным узлом для истории каждого пользователя; +- подписанным объектом того SHiNE-аккаунта, который его сформировал. + +Структура: + +```text ++------------------------------------------------+ +| PREFIX / HEADER | +| magic = SHINE-ARCHIVE | +| version major/minor | +| размеры/счётчики | +| номер большого блока | +| время | +| LOGIN составителя | +| режим таблицы предыдущих блоков | +| число предыдущих блоков | ++------------------------------------------------+ +| PREVIOUS BLOCK REFERENCES TABLE | +| | +| block_number + block_hash + arweave_tx_id | +| block_number + block_hash + arweave_tx_id | +| ... | ++------------------------------------------------+ +| USER RECORDS / SERVER NAVIGATION METADATA | +| ... | ++------------------------------------------------+ +| FOOTER | +| LOGIN закрывшего блок | +| block_hash | +| signature | ++------------------------------------------------+ +``` + +Логин в header и footer должен быть одинаковым. + +Дублирование логина намеренное: + +- header позволяет сразу при скачивании начала файла увидеть, кто составил блок; +- footer делает закрытие блока человекочитаемым рядом с hash и подписью. + +--- + +# 7. Magic и версия + +Magic: + +```text +SHINE-ARCHIVE +``` + +Это ровно 13 ASCII-байт: + +```text +53 48 49 4E 45 2D 41 52 43 48 49 56 45 +``` + +Версия: + +```text +u8 version_major +u8 version_minor +``` + +Для этой спецификации: + +```text +1 +0 +``` + +то есть `v1.0`. + +--- + +# 8. Порядок байтов + +Все fixed-size integer-поля v1 хранятся в: + +```text +big-endian +``` + +--- + +# 9. Header большого блока v1.0 + +Header должен быть устроен так, чтобы клиент мог сначала скачать только небольшой префикс файла, узнать размер всей начальной индексной области и затем докачать только её. + +Формат: + +```text +bytes[13] magic = "SHINE-ARCHIVE" + +u8 version_major +u8 version_minor + +u32 header_size +u32 block_number +u64 created_at_ms + +u8 creator_login_length +bytes[N] creator_login UTF-8 + +u8 references_mode +u32 references_count +u16 reference_entry_size + +u32 records_count +``` + +## 9.1. `header_size` + +`header_size` — абсолютный byte offset от начала файла до первого байта области пользовательских записей. + +То есть включает: + +```text +header prefix ++ creator_login ++ всю таблицу PreviousBlockReference +``` + +Клиент может: + +1. скачать первые условные 64–128 байт; +2. прочитать `header_size`; +3. докачать `0 .. header_size-1`; +4. уже иметь все адреса предыдущих больших блоков; +5. после этого скачивать только нужные user records. + +## 9.2. `block_number` + +```text +u32 +``` + +Номер большого итогового блока данной ветки. + +Диапазон: + +```text +0 .. 4 294 967 295 +``` + +Для предполагаемой частоты закрытия это более чем достаточный запас. + +## 9.3. `created_at_ms` + +```text +u64 +``` + +Unix Epoch UTC в миллисекундах. + +Это время фиксации snapshot текущего большого блока. + +## 9.4. `creator_login` + +Логин SHiNE-пользователя/сервера, который сформировал этот большой блок. + +Пример: + +```text +raspberry-archive +``` + +Это поле находится именно в header, поэтому клиент видит автора уже после скачивания начала блока. + +`creator_login` входит в hash большого блока и тем самым криптографически привязан к его подписи. + +## 9.5. `references_mode` + +```text +u8 +``` + +Зарезервированные значения: + +```text +0 = FULL_HISTORY +1 = PARTIAL_HISTORY +``` + +Для v1 publisher MUST использовать: + +```text +FULL_HISTORY +``` + +То есть каждый новый большой блок содержит ссылки на все известные предыдущие большие блоки своей ветки. + +`PARTIAL_HISTORY` резервируется на будущее, если когда-нибудь полный список станет слишком большим. + +## 9.6. `references_count` + +```text +u32 +``` + +Количество элементов в таблице предыдущих больших блоков. + +## 9.7. `reference_entry_size` + +```text +u16 +``` + +Для v1: + +```text +68 +``` + +Поле оставлено явно, чтобы будущая minor/major версия могла расширить `PreviousBlockReference`, а клиент всё ещё мог корректно пропускать неизвестные дополнительные байты. + +## 9.8. `records_count` + +```text +u32 +``` + +Количество пользовательских record-контейнеров внутри большого блока. + +--- + +# 10. Таблица всех предыдущих больших блоков + +Сразу после header идёт: + +```text +PreviousBlockReference[references_count] +``` + +В v1 это **полная история предыдущих больших блоков данной ветки**. + +При одном закрытии в сутки даже через год это около 365 элементов, поэтому простота полного списка важнее преждевременной оптимизации. + +--- + +# 11. `PreviousBlockReference v1` + +Формат одного элемента: + +```text +u32 block_number +bytes[32] block_hash +bytes[32] arweave_tx_id +``` + +Размер: + +```text +4 + 32 + 32 = 68 байт +``` + +Смысл: + +```text +block_number +-> логический номер большого блока + +block_hash +-> SHA-256 / канонический hash точного содержимого большого блока + +arweave_tx_id +-> raw 32-byte TX ID, по которому этот большой блок можно скачать из Arweave +``` + +То есть: + +```text +HASH = идентичность содержимого +TXID = место хранения +``` + +Оба поля обязательны. + +Внешнее строковое Base64URL-представление TX ID внутрь бинарного файла не записывается. + +Таблица v1 SHOULD быть отсортирована детерминированно по `block_number`, затем по `block_hash`. + +--- + +# 12. Почему в v1 записываются все предыдущие блоки + +Если закрывать примерно один большой блок в сутки: + +```text +365 × 68 ≈ 24.8 KB в год +``` + +Даже через много лет это остаётся небольшим overhead по сравнению с полезными данными. + +Главное преимущество: любой новый большой блок сразу является индексом всей предыдущей ветки. + +Клиент не обязан идти: + +```text +365 -> 364 -> 363 -> ... -> 95 +``` + +Чтобы получить адрес блока 95. + +Он получает его из начала текущего блока. + +--- + +# 13. Пользовательская обратная навигация + +Навигация пользователя строится не на одну предыдущую запись. + +Правило v1: + +> Для каждого пользователя сервер находит **предыдущий большой блок**, в котором существовала хотя бы одна запись этого пользователя, и сохраняет ссылку на этот большой блок вместе со списком `offset + size` **всех записей этого пользователя в том предыдущем большом блоке**. + +Например: + +```text +Большой block 100: +Alice сегодня имеет 2 записи. + +Предыдущий большой блок, где Alice присутствовала: +block 95. + +В block 95 у Alice было 3 записи: + offset 8123 / size 417 + offset 9001 / size 351 + offset 14020 / size 692 +``` + +Тогда server-generated ссылка Alice из block 100 содержит все три диапазона. + +--- + +# 14. `PreviousUserRecordsRef v1` + +Формат: + +```text +u32 previous_block_ref +u32 previous_records_count + +repeat previous_records_count times: + u32 previous_record_offset + u32 previous_record_size +``` + +Минимальный размер: + +```text +8 байт +``` + +Плюс: + +```text +8 байт на каждую предыдущую запись пользователя +``` + +Пример для 3 сообщений: + +```text +previous_block_ref = 94 +previous_records_count = 3 + +8123 417 +9001 351 +14020 692 +``` + +Если `PreviousBlockReference[94]` описывает big block 95, клиент получает: + +```text +block number = 95 +block hash = ... +Arweave TXID = ... +``` + +и может HTTP Range-запросами забрать только три нужных диапазона. + +--- + +# 15. `previous_block_ref` + +`previous_block_ref` — индекс в таблице `PreviousBlockReference` **текущего большого блока**. + +То есть пользовательская ссылка не повторяет 32-byte TX ID и 32-byte hash. + +Она хранит только компактный индекс. + +Специальное значение: + +```text +0xFFFFFFFF +``` + +означает: + +```text +у пользователя ещё не было записей в предыдущих больших блоках +``` + +В этом случае: + +```text +previous_records_count = 0 +``` + +--- + +# 16. Смещение и размер записи + +`previous_record_offset`: + +```text +u32 +``` + +Абсолютный offset от первого байта соответствующего большого `SHINE-ARCHIVE` файла до первого байта нужной архивированной user-record. + +`previous_record_size`: + +```text +u32 +``` + +Полный размер этой архивированной user-record в байтах. + +Таким образом, зная: + +```text +TX ID +block hash +offset +size +``` + +клиент может скачать ровно нужную пользовательскую запись. + +--- + +# 17. Как сервер добавляет пользовательскую ссылку + +`PreviousUserRecordsRef` создаёт **сервер, который формирует большой блок**, а не пользовательский клиент. + +Оригинальные подписанные пользователем bytes изменять нельзя. + +Поэтому server navigation metadata хранится как часть контейнера большого блока вокруг/после оригинальных user-record bytes и покрывается: + +- `block_hash` большого блока; +- подписью сервера/пользователя, который закрыл большой блок. + +Для каждого пользователя в текущем большом блоке достаточно одного `PreviousUserRecordsRef`, связанного с последней записью этого пользователя в текущем блоке. + +Если у пользователя сегодня в текущем большом блоке две записи, сервер после их сериализации добавляет один navigation tail, который указывает на предыдущий большой блок пользователя и перечисляет все его записи там. + +При чтении предыдущего большого блока та же схема даёт следующий переход назад. + +Получается: + +```text +Alice in block 100 + | + | PreviousUserRecordsRef + | -> block 95 + | -> offsets of ALL Alice records in block 95 + v +Alice records in block 95 + | + | server navigation metadata из block 95 + | -> block 81 + | -> offsets of ALL Alice records in block 81 + v +... +``` + +--- + +# 18. Рекомендуемый контейнер архивированной пользовательской записи + +Чтобы сервер мог добавлять свою навигационную метаинформацию, не изменяя исходную пользовательскую подпись, v1 SHOULD использовать серверный envelope. + +Рекомендуемая модель: + +```text +u32 archived_record_size +u32 raw_user_record_size +bytes[N] raw_user_record +u8 navigation_flags +[optional server navigation data] +``` + +Где: + +```text +navigation_flags bit 0 = PreviousUserRecordsRef присутствует +``` + +У большинства записей: + +```text +navigation_flags = 0 +``` + +У последней записи конкретного пользователя в текущем большом блоке: + +```text +navigation_flags bit0 = 1 +``` + +и далее сериализован `PreviousUserRecordsRef`. + +`archived_record_size` позволяет Range-reader получить и полностью декодировать конкретный archived record. + +Исходный `raw_user_record` остаётся байт-в-байт неизменным. + +--- + +# 19. Пример пользовательской цепочки + +```text +BIG BLOCK 100 + +Alice record A +Alice record B + PreviousUserRecordsRef: + previous_block_ref -> block 95 + previous_records_count = 3 + [8123,417] + [9001,351] + [14020,692] + + | + v + +BIG BLOCK 95 + +Alice old record #1 +Alice old record #2 +Alice old record #3 + PreviousUserRecordsRef: + previous_block_ref -> block 81 + previous_records_count = 1 + [4412,380] + + | + v + +BIG BLOCK 81 +... +``` + +Так клиент получает всю историю пользователя назад блок за блоком, но скачивает только конкретные нужные byte ranges. + +--- + +# 20. Hash большого блока + +В footer записывается: + +```text +bytes[32] block_hash +``` + +`block_hash` вычисляется как: + +```text +SHA256( + все байты большого блока + от magic + через header + через creator_login + через PreviousBlockReference table + через все user records и server navigation metadata + через footer closer_login_length + closer_login +) +``` + +Само поле `block_hash` и `closer_signature` в hash НЕ входят. + +--- + +# 21. Footer: кто закрыл блок, hash и подпись + +В конце каждого большого блока MUST быть: + +```text +u8 closer_login_length +bytes[N] closer_login UTF-8 +bytes[32] block_hash +bytes[64] closer_signature +``` + +Логические значения: + +```text +1. кто сформировал/закрыл блок; +2. hash точных bytes блока; +3. подпись закрывающего аккаунта. +``` + +`closer_login` MUST точно совпадать с `creator_login` из header. + +Если значения отличаются — блок невалиден. + +--- + +# 22. Подпись большого блока + +Алгоритм v1: + +```text +Ed25519 +``` + +Подписываемый payload: + +```text +ASCII("SHINE-ARCHIVE-V1") || block_hash +``` + +То есть: + +```text +closer_signature = +Ed25519Sign( + closer_private_key, + "SHINE-ARCHIVE-V1" || block_hash +) +``` + +Для первой реализации `closer_private_key` может быть root private key SHiNE-аккаунта `creator_login` / `closer_login`. + +Public key для проверки берётся из соответствующего User PDA в Solana. + +--- + +# 23. Archive head в Solana User PDA + +Отдельный PDA создавать не требуется. + +Используется тот же существующий User PDA. + +Новый block type: + +```text +100 +``` + +Формат: + +```text +u8 block_type = 100 +u8 block_version = 0 +bytes[32] archive_tx_id +bytes[32] archive_hash +``` + +Размер: + +```text +66 байт +``` + +Solana тем самым хранит: + +```text +где находится текущая голова -> Arweave TX ID +какие exact bytes являются правильными -> SHA-256 +``` + +--- + +# 24. Обновление Solana — обычный User PDA update + +**Отдельная Solana instruction для archive head не требуется.** + +Архивный сервер использует существующий обычный механизм обновления собственного User PDA: + +1. читает текущий PDA; +2. сохраняет все существующие поля; +3. меняет/добавляет только block type `100`; +4. пересобирает новую PDA-запись; +5. выполняет существующую root-signature проверку; +6. `client key` подписывает/оплачивает Solana transaction; +7. ждёт `finalized`. + +Важно: все новые Java/JS/Rust сериализаторы обязаны понимать block type `100` и сохранять его при обычных обновлениях PDA. + +--- + +# 25. `TestFreeAvatarArweaveService` + +Старый сервис больше не нужен как avatar test service. + +Его следует переименовать, например, в: + +```text +ArweaveArchiveService +``` + +и переделать только под серверную фиксацию. + +Нужно оставить/переиспользовать: + +- JWK parsing; +- wallet address; +- reward calculation; +- balance check; +- RSA-PSS signing; +- Arweave HTTP. + +Удалить avatar-specific части: + +- PNG/JPEG/WebP проверки; +- avatar quota; +- лимит 128 KiB; +- avatar DAO; +- avatar-specific API. + +Новый сервис должен уметь: + +```text +publishArchive(...) +waitForConfirmation(...) +``` + +и корректно работать с большими/chunked uploads. + +--- + +# 26. Ожидание закрепления Arweave + +После получения TX ID сервер НЕ обновляет Solana сразу. + +Он ждёт заданное число подтверждений. + +Рекомендуемые настройки: + +```properties +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 +``` + +Только после `ARWEAVE_CONFIRMED` разрешён update User PDA. + +--- + +# 27. Порядок публикации + +Строго: + +```text +1. Синхронизировать/иметь актуальные локальные блоки. + +2. Прочитать archive_chain_cursor. + +3. Зафиксировать snapshot новых диапазонов. + +4. Создать archive_publish_job. + +5. Собрать большой SHINE-ARCHIVE v1.0. + +6. В header записать creator_login. + +7. В начало блока записать FULL_HISTORY список всех предыдущих больших блоков: + block_number + hash + Arweave TX ID. + +8. Для каждого пользователя сформировать server navigation metadata: + previous big block + список всех offset/size его записей там. + +9. Посчитать block_hash. + +10. В footer записать closer_login, block_hash, signature. + +11. Загрузить файл в Arweave. + +12. Сохранить TX ID в publish job. + +13. Дождаться закрепления Arweave. + +14. Обычным User PDA update записать block type 100: + archive_tx_id + archive_hash. + +15. Дождаться Solana finalized. + +16. В одной локальной DB transaction сдвинуть archive_chain_cursor. + +17. Отметить job CURSORS_COMMITTED. +``` + +Если новых данных нет — пустой большой блок создавать не нужно. + +--- + +# 28. Crash recovery + +## Crash после snapshot + +Продолжить с теми же frozen ranges. + +## Crash после построения файла + +Использовать тот же файл и тот же hash. + +## Crash после Arweave upload + +Не загружать второй раз. Использовать сохранённый TX ID. + +## Crash после Arweave confirmation + +Продолжить с User PDA update. + +## Crash после Solana submit + +Сначала проверить текущий PDA/transaction. + +## Crash после Solana finalized, но до cursor commit + +Если PDA уже содержит ожидаемые: + +```text +archive_tx_id +archive_hash +``` + +то безопасно выполнить локальный cursor commit. + +--- + +# 29. Настройки scheduler + +```properties +archive.publish.enabled=false +archive.publish.intervalMinutes=720 +archive.publish.initialDelayMinutes=15 + +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 + +archive.solana.rootKeyPath=/opt/shine/secrets/root.key +archive.solana.clientKeyPath=/opt/shine/secrets/client.key +archive.solana.commitment=finalized +``` + +Для первой версии разумно начать с: + +```text +720 минут = раз в 12 часов +``` + +Частота обычной межсерверной синхронизации меняется независимо. + +--- + +# 30. Проверка большого блока клиентом + +Клиент SHOULD выполнять: + +```text +1. Скачать начало файла. +2. Проверить magic. +3. Прочитать major/minor. +4. Прочитать header_size. +5. Прочитать creator_login. +6. Докачать весь header + PreviousBlockReference table. +7. При необходимости получить нужные previous block TXID/hash. +8. Скачать нужные user record ranges. +9. При полной проверке файла пересчитать block_hash. +10. Проверить closer_login == creator_login. +11. Получить public key автора из User PDA. +12. Проверить Ed25519 signature. +``` + +--- + +# 31. Пример навигации Alice + +Допустим клиент уже имеет актуальную запись Alice из block 100. + +Server navigation metadata говорит: + +```text +previous_block_ref = 94 +previous_records_count = 3 + +[8123, 417] +[9001, 351] +[14020, 692] +``` + +Из начала block 100: + +```text +PreviousBlockReference[94]: + block_number = 95 + block_hash = H95 + arweave_tx_id = TX95 +``` + +Клиент: + +```text +1. идёт в TX95; +2. проверяет H95; +3. делает Range на 8123/417; +4. Range на 9001/351; +5. Range на 14020/692; +6. получает все три Alice records из block 95; +7. из server navigation metadata последней Alice record получает следующий переход, например на block 81; +8. повторяет. +``` + +То есть история читается: + +```text +100 -> 95 -> 81 -> 63 -> ... +``` + +но на каждом шаге скачиваются только конкретные записи данного пользователя. + +--- + +# 32. Почему hash и TX ID нужны одновременно + +Нельзя оставить только TX ID. + +Правильная модель: + +```text +block_number +-> логический номер + +block_hash +-> криптографическая идентичность + +arweave_tx_id +-> текущее физическое расположение +``` + +Если в будущем большой блок будет перенесён из Arweave в другое хранилище, его hash останется тем же. + +--- + +# 33. Будущее `PARTIAL_HISTORY` + +В первой реализации используется только: + +```text +references_mode = FULL_HISTORY +``` + +На будущее уже зарезервирован: + +```text +PARTIAL_HISTORY +``` + +Будущая версия сможет, например, хранить: + +- последние N больших блоков; +- checkpoint-блоки; +- только реально используемые ссылки; +- многоуровневый индекс. + +Но это НЕ требуется для v1. + +--- + +# 34. Итоговые бинарные структуры v1.0 + +## Header + +```text +bytes[13] magic = "SHINE-ARCHIVE" +u8 version_major +u8 version_minor +u32 header_size +u32 block_number +u64 created_at_ms +u8 creator_login_length +bytes[N] creator_login +u8 references_mode +u32 references_count +u16 reference_entry_size +u32 records_count +``` + +## PreviousBlockReference + +```text +u32 block_number +bytes[32] block_hash +bytes[32] arweave_tx_id +``` + +Размер v1: + +```text +68 bytes +``` + +## PreviousUserRecordsRef + +```text +u32 previous_block_ref +u32 previous_records_count + +repeat previous_records_count: + u32 previous_record_offset + u32 previous_record_size +``` + +## User archived envelope — рекомендуемая форма + +```text +u32 archived_record_size +u32 raw_user_record_size +bytes[N] raw_user_record +u8 navigation_flags +[optional PreviousUserRecordsRef] +``` + +## Footer + +```text +u8 closer_login_length +bytes[N] closer_login +bytes[32] block_hash +bytes[64] closer_signature +``` + +## Solana User PDA block 100 + +```text +u8 block_type = 100 +u8 block_version = 0 +bytes[32] archive_tx_id +bytes[32] archive_hash +``` + +--- + +# 35. Обязательные проверки v1 + +Publisher MUST: + +- не изменять frozen job после snapshot; +- проверять cursor hash; +- не двигать cursor до Solana finalized; +- не публиковать пустую дельту; +- записывать полный список предыдущих больших блоков; +- хранить hash и TX ID каждого previous block; +- записывать creator login в header; +- повторять тот же login в footer; +- подписывать hash большого блока; +- добавлять для пользователя ссылку на предыдущий большой блок и offsets/sizes всех его записей там. + +Reader MUST: + +- проверять magic/version; +- проверять границы offsets; +- проверять hash скачанного большого блока; +- проверять `closer_login == creator_login`; +- проверять подпись; +- проверять, что скачанные user records действительно принадлежат ожидаемому пользователю; +- не считать Arweave TX ID самостоятельным доказательством корректности содержимого. + +--- + +# 36. Реализационный checklist + +## Solana + +- [ ] Добавить User PDA block type `100`. +- [ ] Добавить `archive_tx_id [32]`. +- [ ] Добавить `archive_hash [32]`. +- [ ] Научить обычный User PDA update читать и сохранять block 100. +- [ ] Серверу дать возможность обычным update менять block 100. +- [ ] Ждать `finalized`. + +## Database + +- [ ] `archive_chain_cursor`. +- [ ] `archive_publish_job`. +- [ ] `archive_publish_job_chain`. + +## Arweave + +- [ ] Переименовать `TestFreeAvatarArweaveService` в `ArweaveArchiveService`. +- [ ] Удалить avatar-specific код. +- [ ] Реализовать большие/chunked uploads. +- [ ] Реализовать ожидание confirmations. +- [ ] Сохранять TX ID до ожидания подтверждений. + +## Большой блок + +- [ ] Magic `SHINE-ARCHIVE`. +- [ ] `version_major u8`. +- [ ] `version_minor u8`. +- [ ] `header_size`. +- [ ] `block_number u32`. +- [ ] `created_at_ms u64`. +- [ ] `creator_login` в header. +- [ ] `FULL_HISTORY` previous block table. +- [ ] `block_number + block_hash + txid` для каждого previous block. +- [ ] Server-side `PreviousUserRecordsRef`. +- [ ] Список всех `offset + size` пользовательских записей из предыдущего big block. +- [ ] `closer_login + block_hash + signature` в footer. + +## Scheduler / recovery + +- [ ] Настраиваемый interval. +- [ ] Initial delay. +- [ ] Один active job. +- [ ] Resume после restart. +- [ ] Cursor commit только после Solana finalized. + +--- + +# 37. Краткая итоговая модель + +```text +SOLANA USER PDA + | + +--> block 100 + archive TX ID + archive SHA-256 + + | + v + +SHINE-ARCHIVE block N + | + | HEADER: + | version + | block number + | time + | CREATOR LOGIN + | + | PREVIOUS BLOCK TABLE: + | #0 number/hash/TXID + | #1 number/hash/TXID + | ... все предыдущие в v1 + | + | USER DATA: + | original signed user records + | server navigation metadata + | previous block ref + | ALL previous user record offsets/sizes + | + | FOOTER: + | same creator/closer login + | hash + | Ed25519 signature + v + +SHINE-ARCHIVE block N-1 + | + v +... +``` + +Это завершает согласованную спецификацию `SHiNE Archive Protocol v1.0`. diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 45c479a2..75f87a51 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -234,13 +234,6 @@ function buildThreadRoute(messageRef, selector) { }); } -function buildChannelSettingsKey(ownerBlockchainName, channelName) { - const ownerBch = String(ownerBlockchainName || '').trim(); - const name = String(channelName || '').trim(); - if (!ownerBch || !name) return ''; - return `${ownerBch}/${name}`; -} - function getChannelScrollRoot() { return document.getElementById('app-screen'); } @@ -286,7 +279,9 @@ function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) { function createChannelReadTracker({ screen, routeKey, - settingKey, + ownerBlockchainName, + channelName, + initializeIfMissing = false, unreadCount, messagesCount, initialSeenCount, @@ -295,7 +290,9 @@ function createChannelReadTracker({ }) { const login = String(state.session.login || '').trim(); const storagePwd = state.session.storagePwdInMemory; - const canWrite = !!(settingKey && login && storagePwd); + const cleanOwnerBlockchainName = String(ownerBlockchainName || '').trim(); + const cleanChannelName = String(channelName || '').trim(); + const canWrite = !!(cleanOwnerBlockchainName && cleanChannelName && login && storagePwd); const safeMessagesCount = Math.max(0, Number(messagesCount || 0)); const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount)); const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null; @@ -303,6 +300,7 @@ function createChannelReadTracker({ let desiredSeenCount = safeInitialSeenCount; let persistedSeenCount = safeInitialSeenCount; + let initialPersistPending = !!initializeIfMissing; let inFlight = false; let disposed = false; let rafId = 0; @@ -327,7 +325,7 @@ function createChannelReadTracker({ const flush = async () => { if (disposed || !canWrite) return; const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount)); - if (next <= persistedSeenCount) return; + if (next <= persistedSeenCount && !initialPersistPending) return; if (inFlight) { queueFlush(120); return; @@ -335,16 +333,16 @@ function createChannelReadTracker({ inFlight = true; try { - await authService.upsertUserSetting({ + await authService.setChannelReadState({ login, - settingType: 1, - settingKey, + ownerBlockchainName: cleanOwnerBlockchainName, + channelName: cleanChannelName, + readCount: next, timeMs: Date.now(), - valueText: '', - valueNum: next, storagePwd, }); persistedSeenCount = next; + initialPersistPending = false; if (typeof onPersistSuccess === 'function') onPersistSuccess(persistedSeenCount); } catch (error) { if (typeof onPersistError === 'function') onPersistError(error); @@ -399,6 +397,8 @@ function createChannelReadTracker({ // Opening a channel must NOT mark the whole channel as read. // Only cards actually crossed by the viewport tracker advance desiredSeenCount. + // Exception: if the server has no row yet, persist the current client baseline once. + if (initialPersistPending) queueFlush(80); window.setTimeout(() => measure(), 120); const cleanup = () => { @@ -1457,6 +1457,7 @@ async function loadFromApi(route, channelId) { const isAuthorized = !!currentSessionLogin; let unreadCount = 0; let messagesCount = 0; + let readStateInitialized = false; let cachedFeed = null; const ensureFeed = async () => { if (cachedFeed) return cachedFeed; @@ -1571,6 +1572,7 @@ async function loadFromApi(route, channelId) { } unreadCount = Number(channel?.unreadCount || 0); messagesCount = Number(channel?.messagesCount || 0); + readStateInitialized = !!channel?.readStateInitialized; selector = { ownerBlockchainName: String(channel.channel.ownerBlockchainName), channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber), @@ -1664,6 +1666,7 @@ async function loadFromApi(route, channelId) { messagesCount, isOwnChannel, isSubscribed, + readStateInitialized, selector, }; } @@ -1793,12 +1796,12 @@ function renderChannelMetaEventCard(event) { function likeCategoryCounts(post) { const total = Math.max(0, Number(post?.likesCount || 0)); - const primary = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0))); - const shining = Math.max(0, Math.min(primary, Number(post?.shiningLikesCount || 0))); + const official = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0))); + const shining = Math.max(0, Math.min(official, Number(post?.shiningLikesCount || 0))); return { shining, - official: Math.max(0, primary - shining), - others: Math.max(0, total - primary), + official, + all: total, total, }; } @@ -1815,7 +1818,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining @@ -1831,7 +1834,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); }); modal?.addEventListener('click', (event) => event.stopPropagation()); - let activeTab = ['shining', 'official', 'others'].includes(initialTab) ? initialTab : 'shining'; + let activeTab = ['shining', 'official', 'all'].includes(initialTab) ? initialTab : 'shining'; let payload = null; const renderTab = () => { @@ -1841,7 +1844,14 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining button.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); if (!payload) return; - const rows = Array.isArray(payload?.[activeTab]) ? payload[activeTab] : []; + const shiningRows = Array.isArray(payload?.shining) ? payload.shining : []; + const officialOnlyRows = Array.isArray(payload?.official) ? payload.official : []; + const otherRows = Array.isArray(payload?.others) ? payload.others : []; + const rows = activeTab === 'shining' + ? shiningRows + : activeTab === 'official' + ? [...shiningRows, ...officialOnlyRows] + : [...shiningRows, ...officialOnlyRows, ...otherRows]; list.innerHTML = ''; rows.forEach((row) => { const userButton = document.createElement('button'); @@ -1869,7 +1879,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining }); list.append(userButton); }); - status.textContent = rows.length ? (payload?.truncated ? 'Показаны первые 1000 лайков.' : '') : 'В этом списке пока никого нет.'; + status.textContent = rows.length ? '' : 'В этом списке пока никого нет.'; }; overlay.querySelectorAll('[data-like-tab]').forEach((button) => { @@ -1882,11 +1892,19 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining (async () => { try { - payload = await authService.getMessageLikes(messageRef, 1000); + payload = await authService.getMessageLikes(messageRef); if (!overlay.isConnected) return; - ['shining', 'official', 'others'].forEach((key) => { + const shiningCount = Array.isArray(payload?.shining) ? payload.shining.length : 0; + const officialOnlyCount = Array.isArray(payload?.official) ? payload.official.length : 0; + const otherCount = Array.isArray(payload?.others) ? payload.others.length : 0; + const tabCounts = { + shining: shiningCount, + official: shiningCount + officialOnlyCount, + all: shiningCount + officialOnlyCount + otherCount, + }; + Object.entries(tabCounts).forEach(([key, value]) => { const countEl = overlay.querySelector(`[data-like-tab-count="${key}"]`); - if (countEl) countEl.textContent = String(Array.isArray(payload?.[key]) ? payload[key].length : 0); + if (countEl) countEl.textContent = String(value); }); renderTab(); } catch (error) { @@ -1909,25 +1927,16 @@ function openMessageLikePopup({ anchor, post, navigate, onToggleLike }) { - `; document.body.append(layer); const popup = layer.querySelector('.channel-like-popup'); - const rect = anchor.getBoundingClientRect(); const width = Math.min(330, Math.max(270, window.innerWidth - 24)); - const left = Math.max(12, Math.min(window.innerWidth - width - 12, rect.left + rect.width / 2 - width / 2)); - const estimatedHeight = 255; - const top = rect.bottom + estimatedHeight < window.innerHeight - 8 - ? rect.bottom + 8 - : Math.max(8, rect.top - estimatedHeight - 8); popup.style.width = `${width}px`; - popup.style.left = `${left}px`; - popup.style.top = `${top}px`; const close = () => layer.remove(); layer.addEventListener('click', (event) => { if (event.target === layer) close(); }); @@ -2378,10 +2387,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { const tracker = createChannelReadTracker({ screen, routeKey, - settingKey: buildChannelSettingsKey( - channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName, - channelData.channel?.name || channelData.channel?.channelName, - ), + ownerBlockchainName: channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName, + channelName: channelData.channel?.name || channelData.channel?.channelName, + initializeIfMissing: !!(channelData.isSubscribed && !channelData.readStateInitialized), unreadCount, messagesCount, initialSeenCount: readCount, @@ -2532,18 +2540,13 @@ export function render({ navigate, route, chrome }) { }); if (disposed) return; - const readSettingKey = buildChannelSettingsKey( - apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName, - apiData.channel?.name || apiData.channel?.channelName, - ); try { - await authService.upsertUserSetting({ + await authService.setChannelReadState({ login, - settingType: 1, - settingKey: readSettingKey, + ownerBlockchainName: apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName, + channelName: apiData.channel?.name || apiData.channel?.channelName, + readCount: Math.max(0, Number(apiData.messagesCount || 0)), timeMs: Date.now(), - valueText: '', - valueNum: Math.max(0, Number(apiData.messagesCount || 0)), storagePwd, }); } catch (readStateError) { diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index 031cfa8f..c6f71c00 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -30,6 +30,25 @@ function metricHtml({ kind, label, value, glow = false, positionClass = '' }) { `; } +function friendsMetricHtml(stats = {}) { + const friends = Number(stats.friendsCount || 0); + const closeFriends = Number(stats.closeFriendsCount || 0); + return ` + `; +} + +function hasContacts(card) { + return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim()); +} + function spiritualPathDetailHtml(card) { const value = String(card?.spiritualPath || '').trim(); return ``; @@ -102,52 +121,51 @@ export function render({ navigate, chrome }) { .map((value) => String(value || '').trim()) .filter(Boolean) .join(' '); + const displayName = fullName || card.login || login || 'Профиль'; const about = String(card.about || '').trim(); + const spiritualPath = String(card.spiritualPath || '').trim(); + const contactsVisible = hasContacts(card); const title = topbar.querySelector('.topbar__title'); if (title) title.textContent = card.login || login || 'Профиль'; body.innerHTML = ` - ${fullName ? `` : ''} - - ${about ? `` : ''} - - `; const avatarSlot = body.querySelector('.user-profile-avatar-slot'); avatarSlot?.append(renderUserAvatar({ diff --git a/shine-UI/js/pages/user-profile-list-view.js b/shine-UI/js/pages/user-profile-list-view.js index ef70a336..80098b33 100644 --- a/shine-UI/js/pages/user-profile-list-view.js +++ b/shine-UI/js/pages/user-profile-list-view.js @@ -13,32 +13,93 @@ function parseAvatar(raw) { } const TITLES = { - friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили аккаунт', + friends: 'Друзья', close_friends: 'Друзья', primary_received: 'Подтвердили аккаунт', primary_given: 'Подтверждённые аккаунты', shine_received: 'Подтвердили сияние', shine_given: 'Подтверждённые сияющие', channels_owned: 'Каналы', channels_following: 'Подписки на каналы', }; -export function render({navigate, route, chrome}) { - const login = String(route?.params?.login || '').trim(); - const kind = String(route?.params?.kind || '').trim(); - const screen = document.createElement('section'); screen.className = 'stack'; - const body = document.createElement('div'); body.className = 'stack'; - const status = document.createElement('div'); status.className = 'status-line'; status.textContent = 'Загрузка...'; - chrome?.setTopbar(createTopBar({ title: TITLES[kind] || 'Список', back: { label: '←', onClick: () => navigateBack() } })); - screen.append( - status, - body, - ); +function friendTabsHtml(activeKind) { + return ` +
+ + +
`; +} - (async () => { +export function render({ navigate, route, chrome }) { + const login = String(route?.params?.login || '').trim(); + const initialKind = String(route?.params?.kind || '').trim(); + let activeKind = initialKind; + const isFriendsScreen = initialKind === 'friends' || initialKind === 'close_friends'; + + const screen = document.createElement('section'); + screen.className = 'stack'; + const body = document.createElement('div'); + body.className = 'stack'; + const status = document.createElement('div'); + status.className = 'status-line'; + status.textContent = 'Загрузка...'; + + chrome?.setTopbar(createTopBar({ + title: TITLES[initialKind] || 'Список', + back: { label: '←', onClick: () => navigateBack() }, + })); + + if (isFriendsScreen) { + const tabs = document.createElement('div'); + tabs.innerHTML = friendTabsHtml(activeKind); + screen.append(tabs.firstElementChild); + } + screen.append(status, body); + + let loadGeneration = 0; + + function renderRelationRows(rows) { + rows.forEach((row) => { + const el = document.createElement('button'); + el.type = 'button'; + el.className = 'ui-button card row profile-list-row'; + el.append(renderUserAvatar({ + login: row.login, + firstName: row.firstName, + lastName: row.lastName, + avatar: parseAvatar(row.avatarAr), + size: 'md', + })); + const fullName = userDisplayName(row); + const t = document.createElement('div'); + t.className = 'profile-list-row-text'; + const marks = [ + row.relationType && row.relationType !== 'none' ? ({ contact: 'контакт', friend: 'друг', close_friend: 'близкий друг' }[row.relationType] || row.relationType) : '', + row.accountRole === 'primary' ? 'основной' : row.accountRole === 'non_voting' ? 'голос не учитывается' : '', + row.shineStatus === 'shining' ? 'сияющий' : row.shineStatus === 'not_interested' ? 'сияние неинтересно' : '', + row.primaryConfirmed ? 'основной подтверждён ✓' : '', + row.shineConfirmed ? 'сияющий подтверждён ✓' : '', + ].filter(Boolean).join(' · '); + t.innerHTML = `${fullName}@${String(row.login || '')}${marks ? ` · ${marks}` : ''}`; + el.append(t); + el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`)); + body.append(el); + }); + } + + async function load(kind) { + const generation = ++loadGeneration; + status.className = 'status-line'; + status.textContent = 'Загрузка...'; + body.replaceChildren(); try { if (kind === 'channels_owned' || kind === 'channels_following') { const payload = await authService.listUserProfileChannels(login, kind === 'channels_owned' ? 'owned' : 'following', 200, 0); + if (generation !== loadGeneration) return; const rows = Array.isArray(payload?.channels) ? payload.channels : []; rows.forEach((row) => { - const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row'; + const el = document.createElement('button'); + el.type = 'button'; + el.className = 'ui-button card row profile-list-row'; el.append(renderUserAvatar({ login: row.ownerLogin, firstName: row.displayName, lastName: '', avatar: parseAvatar(row.avatarAr), size: 'md' })); - const t = document.createElement('div'); t.className = 'profile-list-row-text'; + const t = document.createElement('div'); + t.className = 'profile-list-row-text'; t.innerHTML = `${String(row.displayName || row.slug || '')}${String(row.ownerLogin || '')} / ${String(row.slug || '')}`; el.append(t); el.addEventListener('click', () => navigate(`channel/${encodeURIComponent(row.ownerBlockchainName)}/${Number(row.rootBlockNumber || 0)}/${encodeURIComponent(row.rootBlockHashHex || '')}/about`)); @@ -47,25 +108,35 @@ export function render({navigate, route, chrome}) { status.textContent = rows.length ? '' : 'Список пуст.'; return; } + const payload = await authService.listUserProfileRelations(login, kind, 200, 0); + if (generation !== loadGeneration) return; const rows = Array.isArray(payload?.users) ? payload.users : []; - rows.forEach((row) => { - const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row'; - el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' })); - const fullName = userDisplayName(row); - const t = document.createElement('div'); t.className = 'profile-list-row-text'; - const marks = [ - row.relationType && row.relationType !== 'none' ? ({contact:'контакт',friend:'друг',close_friend:'близкий друг'}[row.relationType] || row.relationType) : '', - row.accountRole === 'primary' ? 'основной' : row.accountRole === 'non_voting' ? 'голос не учитывается' : '', - row.shineStatus === 'shining' ? 'сияющий' : row.shineStatus === 'not_interested' ? 'сияние неинтересно' : '', - row.primaryConfirmed ? 'основной подтверждён ✓' : '', - row.shineConfirmed ? 'сияющий подтверждён ✓' : '', - ].filter(Boolean).join(' · '); - t.innerHTML = `${fullName}${String(row.login || '')}${marks ? ` · ${marks}` : ''}`; - el.append(t); el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`)); body.append(el); - }); + renderRelationRows(rows); status.textContent = rows.length ? '' : 'Список пуст.'; - } catch (e) { status.className = 'status-line is-unavailable'; status.textContent = `Ошибка: ${e.message || 'unknown'}`; } - })(); + } catch (e) { + if (generation !== loadGeneration) return; + status.className = 'status-line is-unavailable'; + status.textContent = `Ошибка: ${e.message || 'unknown'}`; + } + } + + if (isFriendsScreen) { + screen.addEventListener('click', (event) => { + const tab = event.target.closest('[data-friend-kind]'); + if (!tab) return; + const nextKind = String(tab.dataset.friendKind || ''); + if (!nextKind || nextKind === activeKind) return; + activeKind = nextKind; + screen.querySelectorAll('[data-friend-kind]').forEach((button) => { + const selected = button.dataset.friendKind === activeKind; + button.classList.toggle('is-active', selected); + button.setAttribute('aria-selected', selected ? 'true' : 'false'); + }); + void load(activeKind); + }); + } + + void load(activeKind); return screen; } diff --git a/shine-UI/js/pages/user-profile-view.js b/shine-UI/js/pages/user-profile-view.js index 3c9b3700..b2f0f48b 100644 --- a/shine-UI/js/pages/user-profile-view.js +++ b/shine-UI/js/pages/user-profile-view.js @@ -38,6 +38,25 @@ function metricHtml({ kind, label, value, glow = false, positionClass = '' }) { `; } +function friendsMetricHtml(stats = {}) { + const friends = Number(stats.friendsCount || 0); + const closeFriends = Number(stats.closeFriendsCount || 0); + return ` + `; +} + +function hasContacts(card) { + return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim()); +} + function spiritualPathDetailHtml(card) { const value = String(card?.spiritualPath || '').trim(); return ``; @@ -174,54 +193,53 @@ export function render({ navigate, route, chrome }) { .map((value) => String(value || '').trim()) .filter(Boolean) .join(' '); + const displayName = fullName || card.login || 'Профиль'; const about = String(card.about || '').trim(); + const spiritualPath = String(card.spiritualPath || '').trim(); + const contactsVisible = hasContacts(card); const title = header.querySelector('.topbar__title'); if (title) title.textContent = card.login; body.innerHTML = ` - ${fullName ? `` : ''} - - ${about ? `` : ''} - - ` : ''}`; const avatarSlot = body.querySelector('.user-profile-avatar-slot'); avatarSlot?.append(renderUserAvatar({ diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index b8e4879b..5348ea28 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1595,13 +1595,13 @@ export class AuthService { return response.payload || {}; } - async getMessageLikes(message, limit = 1000) { + async getMessageLikes(message) { const normalizedMessage = { blockchainName: String(message?.blockchainName || '').trim(), blockNumber: Number(message?.blockNumber), blockHash: String(message?.blockHash || '').trim(), }; - const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage, limit }); + const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage }); if (response.status !== 200) throw opError('GetMessageLikes', response); return response.payload || {}; } @@ -3056,6 +3056,56 @@ export class AuthService { return response.payload || {}; } + async setChannelReadState({ + login, + ownerBlockchainName, + channelName, + readCount, + timeMs, + storagePwd, + }) { + const cleanLogin = String(login || '').trim(); + const cleanOwnerBch = String(ownerBlockchainName || '').trim(); + const cleanChannelName = String(channelName || '').trim(); + const cleanReadCount = Math.max(0, Math.trunc(Number(readCount || 0))); + const cleanTimeMs = Math.trunc(Number(timeMs)); + if (!cleanLogin || !cleanOwnerBch || !cleanChannelName) { + throw new Error('Не переданы login/ownerBlockchainName/channelName'); + } + if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) { + throw new Error('Не передан корректный timeMs'); + } + if (!storagePwd) throw new Error('Не передан storagePwd для подписи SetChannelReadState.'); + + const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd); + const clientPrivPkcs8 = String(secrets?.clientKey || '').trim(); + if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey'); + const privateKey = await importPkcs8Ed25519(clientPrivPkcs8); + const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8); + + const preimage = [ + 'SHiNe/ChannelReadState:', + escapeUserSettingPart(cleanLogin), + escapeUserSettingPart(cleanOwnerBch), + escapeUserSettingPart(cleanChannelName), + String(cleanTimeMs), + String(cleanReadCount), + ].join('|'); + const signature = await signBase64(privateKey, preimage); + + const response = await this.ws.request('SetChannelReadState', { + login: cleanLogin, + owner_bch_name: cleanOwnerBch, + channel_name: cleanChannelName, + read_count: cleanReadCount, + time_ms: cleanTimeMs, + client_key: clientKey, + signature, + }); + if (response.status !== 200) throw opError('SetChannelReadState', response); + return response.payload || {}; + } + async upsertUserSetting({ login, settingType, diff --git a/shine-UI/styles/features/channel.css b/shine-UI/styles/features/channel.css index bb9a72cc..8ddf33a2 100644 --- a/shine-UI/styles/features/channel.css +++ b/shine-UI/styles/features/channel.css @@ -696,6 +696,9 @@ .channel-like-popup { position: fixed; z-index: 1601; + left: 50%; + top: clamp(72px, 18vh, 180px); + transform: translateX(-50%); padding: 14px; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 18px; diff --git a/shine-UI/styles/features/profile.css b/shine-UI/styles/features/profile.css index 8a470721..cb09eda5 100644 --- a/shine-UI/styles/features/profile.css +++ b/shine-UI/styles/features/profile.css @@ -1805,3 +1805,165 @@ .profiles-actions { margin-top: 4px; } .profiles-close-all { margin-top: 4px; } + +/* ===== 2026-09-09: profile layout — identity, social row, about card ===== */ +.user-profile-identity { + width: min(88%, 340px); + margin: 12px auto 0; + text-align: center; +} + +.user-profile-identity .user-profile-full-name { + width: 100%; + margin: 0; + color: rgba(244, 248, 255, 0.96); + font-size: clamp(17px, 4.8vw, 20px); + font-weight: 700; + line-height: 1.2; +} + +.user-profile-login { + margin-top: 4px; + color: rgba(199, 211, 229, 0.62); + font-size: 12px; + font-weight: 500; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.user-profile-social-metrics { + width: min(92%, 360px); + margin: 22px auto 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: start; + justify-items: center; + gap: 4px; +} + +.user-profile-social-metrics .user-profile-metric { + width: 100%; + min-height: 58px; + gap: 5px; + justify-content: flex-start; +} + +.user-profile-social-metrics .user-profile-metric-circle, +.user-profile-metric-value-combined { + width: auto; + min-width: 0; + height: auto; + min-height: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: rgba(240, 246, 255, 0.95); + font-size: clamp(17px, 4.7vw, 20px); + font-weight: 800; + line-height: 1.1; + white-space: nowrap; +} + +.user-profile-social-metrics .user-profile-metric-label { + position: static; + width: 100%; + min-height: 0; + transform: none; + color: rgba(203, 216, 236, 0.72); + font-size: 11px; + line-height: 1.15; + text-align: center; +} + +.user-profile-about-card { + width: min(88%, 344px); + margin: 20px auto 0; +} + +.user-profile-about-title { + margin: 0 0 7px 3px; + color: rgba(224, 233, 247, 0.82); + font-size: 12px; + font-weight: 650; + line-height: 1.2; +} + +.user-profile-about-field { + min-height: 68px; + padding: 13px 14px; + border: 1px solid rgba(210, 220, 234, 0.14); + border-radius: 14px; + background: rgba(162, 172, 186, 0.16); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + color: rgba(236, 242, 251, 0.9); + font-size: 13px; + line-height: 1.5; + text-align: left; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.user-profile-about-field.is-empty { + color: rgba(199, 210, 226, 0.46); +} + +.user-profile-detail-links.user-profile-detail-links--below-about { + width: min(88%, 344px); + margin: 10px auto 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: center; + gap: 10px; +} + +.user-profile-detail-links--below-about > :first-child { + justify-self: start; +} + +.user-profile-detail-links--below-about > :last-child { + justify-self: end; +} + +.user-profile-detail-links--below-about button { + width: auto; + min-width: 0; + padding-left: 6px; + padding-right: 6px; +} + +.profile-list-tabs { + width: min(92%, 360px); + margin: 4px auto 10px; + padding: 3px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + border: 1px solid rgba(205, 221, 244, 0.12); + border-radius: 14px; + background: rgba(14, 25, 43, 0.24); +} + +.profile-list-tab { + min-height: 36px; + padding: 7px 10px; + border: 0; + border-radius: 11px; + background: transparent; + color: rgba(207, 219, 237, 0.72); + font: inherit; + font-size: 12px; + font-weight: 650; + cursor: pointer; +} + +.profile-list-tab.is-active { + background: rgba(156, 177, 204, 0.18); + color: rgba(246, 249, 255, 0.97); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +@media (max-height: 700px) { + .user-profile-identity { margin-top: 8px; } + .user-profile-social-metrics { margin-top: 16px; } + .user-profile-about-card { margin-top: 15px; } +} From 08663fa33984a98249f6732488d32296f37187c86191276ed04bcaad91209672 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 9 Sep 2026 19:06:43 +0300 Subject: [PATCH 07/30] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20?= =?UTF-8?q?=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/pages/channel-view.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/VERSION.properties b/VERSION.properties index adbdaefc..8269fbcc 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.6 +client.version=1.12.7 server.version=1.10.3 diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 75f87a51..26536cb3 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -2268,6 +2268,7 @@ function renderPostCard(post, { } function renderBody(screen, navigate, routeKey, channelData, handlers) { + const showStatus = typeof handlers?.showStatus === 'function' ? handlers.showStatus : () => {}; const unreadCount = Math.max(0, Number(channelData.unreadCount || 0)); const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0)); const readCount = Math.max(0, messagesCount - unreadCount); @@ -2910,6 +2911,7 @@ export function render({ navigate, route, chrome }) { } skeleton.remove(); cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, { + showStatus, isActive: () => !disposed, onAddMessage: () => { openAddMessageModal({ From 3a466b4c38c95dba677b484ca03b815f4b7a59216bf81de3f30c9fa00e28fd59 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 10 Sep 2026 11:08:30 +0300 Subject: [PATCH 08/30] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8C?= =?UTF-8?q?=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/services/auth-service.js | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 8269fbcc..d66506bb 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.7 +client.version=1.12.8 server.version=1.10.3 diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 5348ea28..f1687c1f 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -3083,8 +3083,7 @@ export class AuthService { const privateKey = await importPkcs8Ed25519(clientPrivPkcs8); const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8); - const preimage = [ - 'SHiNe/ChannelReadState:', + const preimage = 'SHiNe/ChannelReadState:' + [ escapeUserSettingPart(cleanLogin), escapeUserSettingPart(cleanOwnerBch), escapeUserSettingPart(cleanChannelName), @@ -3132,8 +3131,7 @@ export class AuthService { const privateKey = await importPkcs8Ed25519(clientPrivPkcs8); const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8); - const preimage = [ - 'SHiNe/UserSettings:', + const preimage = 'SHiNe/UserSettings:' + [ escapeUserSettingPart(cleanLogin), String(cleanSettingType), escapeUserSettingPart(cleanSettingKey), From 107f85b818b30bcdab50abb1defa548bb93504e91968faa7bfd6b0399e9a996e Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 10 Sep 2026 12:18:39 +0300 Subject: [PATCH 09/30] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B5=D0=BF=D1=80=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B2=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + VERSION.properties | 2 +- shine-UI/js/pages/channel-view.js | 7 ++- shine-UI/js/pages/channels-list.js | 42 ++++++++++++---- shine-UI/js/pages/messages-list.js | 2 +- shine-UI/styles/features/channels-list.css | 58 ++++++++++++++++++++++ shine-UI/styles/features/messages.css | 13 +++++ 7 files changed, 112 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 29bcddec..34986288 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,7 @@ ESP32/**/*.a # Полные серверные бэкапы (тяжёлые архивы, не коммитим) deploy/backup/archive/** !deploy/backup/archive/.gitkeep +SHiNE-bundle-*.zip # Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим .agents/ diff --git a/VERSION.properties b/VERSION.properties index d66506bb..6d57c3ce 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.8 +client.version=1.12.9 server.version=1.10.3 diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 26536cb3..c4693aed 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -360,12 +360,17 @@ function createChannelReadTracker({ const root = getChannelScrollRoot(); const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0; const thresholdTop = Math.max(0, Math.round(viewportHeight * unreadAnchorFraction)); + const visibleBottom = Math.max(thresholdTop, viewportHeight - 24); let seen = safeInitialSeenCount; for (const card of cards) { const localNumber = Number(card.dataset.localNumber || 0); if (!Number.isFinite(localNumber) || localNumber <= 0) continue; const rect = card.getBoundingClientRect(); - if (rect.top > thresholdTop + 1) break; + if (rect.top > visibleBottom) break; + if (rect.bottom < 0) { + seen = Math.max(seen, localNumber); + continue; + } seen = Math.max(seen, localNumber); } return Math.max(safeInitialSeenCount, Math.min(seen, safeMessagesCount)); diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index 08188241..5089a820 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -729,7 +729,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) { const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1); const isOwn = bucketKey === 'own'; const title = displayTitle || channelName; - const technicalLabel = `${ownerLogin} / ${channelName}`; + const technicalLabel = `@${ownerLogin}/${channelName}`; const lastMessage = resolveChannelLastMessage(summary); return { @@ -948,13 +948,21 @@ function renderChannelMain(channel) { const main = document.createElement('div'); main.className = 'channel-row-main'; + const titleLine = document.createElement('div'); + titleLine.className = 'channel-row-title-line'; + const title = document.createElement('strong'); title.className = 'channel-row-title'; title.textContent = channel.title; + const time = document.createElement('span'); + time.className = 'channel-row-time'; + time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : ''; + titleLine.append(title, time); + const technical = document.createElement('p'); technical.className = 'channel-row-technical'; - technical.textContent = channel.technicalLabel || `${channel.ownerName || ''} / ${channel.channelName || ''}`; + technical.textContent = channel.technicalLabel || `@${channel.ownerName || ''}/${channel.channelName || ''}`; if (channel.channelDescription) { const desc = document.createElement('p'); @@ -970,16 +978,13 @@ function renderChannelMain(channel) { preview.className = 'channel-row-message'; preview.textContent = channel.messagePreview || 'Ждем ваших начинаний'; - const time = document.createElement('span'); - time.className = 'channel-row-time'; - time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : ''; - previewLine.append(preview, time); + previewLine.append(preview); const meta = document.createElement('p'); meta.className = 'channel-row-owner channel-counter-meta'; meta.textContent = `Сообщений: ${channel.messagesCount || 0}`; - main.prepend(title, technical); + main.prepend(titleLine, technical); main.append(previewLine, meta); return main; } @@ -1041,8 +1046,8 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed container.append(list); } -async function loadFeedAndRender({ screen, listState, contentEl, navigate }) { - renderSkeletonList(contentEl, 5); +async function loadFeedAndRender({ screen, listState, contentEl, navigate, silent = false }) { + if (!silent) renderSkeletonList(contentEl, 5); if (!state.session.isAuthorized) { setChannelsFeed(null, {}); @@ -1092,6 +1097,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) { refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }), }); } catch (error) { + if (silent) return; setChannelsFeed(null, {}); contentEl.innerHTML = ''; @@ -1161,6 +1167,20 @@ export function render({ navigate, route, chrome }) { const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate }); + let countersRefreshTimer = null; + let lastChannelsUnreadCount = Math.max(0, Number(state.userCounters?.channelsUnreadCount || 0) || 0); + const unsubscribeCountersChanged = authService.onEvent('UserCountersChanged', (event) => { + const nextChannelsUnreadCount = Math.max(0, Number(event?.payload?.channelsUnreadCount || 0) || 0); + if (nextChannelsUnreadCount === lastChannelsUnreadCount) return; + lastChannelsUnreadCount = nextChannelsUnreadCount; + + if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer); + countersRefreshTimer = window.setTimeout(() => { + countersRefreshTimer = null; + void loadFeedAndRender({ screen, listState, contentEl, navigate, silent: true }); + }, 120); + }); + const rerenderList = () => { listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} }); @@ -1190,7 +1210,9 @@ export function render({ navigate, route, chrome }) { loadFeedAndRender({ screen, listState, contentEl, navigate }); screen.cleanup = () => { - channelsFilterMenu.destroy(); + if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer); + unsubscribeCountersChanged(); + channelsFilterMenu.destroy(); }; return screen; diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index 7048bb4e..34f9382c 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -290,11 +290,11 @@ function renderRow(item) {

- ${item.unreadCount ? `${item.unreadCount > 99 ? '99+' : item.unreadCount}` : ''}
${item.lastMessageTimeMs ? '' : ''} ${SVG_CHEVRON}
+ ${item.unreadCount ? `${item.unreadCount > 99 ? '99+' : item.unreadCount}` : ''}
`; const titleEl = row.querySelector('.dm-row-title'); diff --git a/shine-UI/styles/features/channels-list.css b/shine-UI/styles/features/channels-list.css index da1cd6ab..1f09b302 100644 --- a/shine-UI/styles/features/channels-list.css +++ b/shine-UI/styles/features/channels-list.css @@ -1517,3 +1517,61 @@ flex: 1 1 0; min-width: 0; } + +/* ===== Channel row realtime layout: time top-right, unread bottom-right ===== */ +.channels-screen--list .channel-row-main { + padding-right: 0; +} + +.channels-screen--list .channel-row-title-line { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.channels-screen--list .channel-row-title-line .channel-row-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.channels-screen--list .channel-row-title-line .channel-row-time { + position: static; + transform: none; + width: auto; + max-width: max-content; + flex: 0 0 auto; + margin: 0; + padding: 0; + font-size: 11px; + line-height: 1.12; + white-space: nowrap; +} + +.channels-screen--list .channel-row-preview-line { + display: block; + padding-right: 42px; + box-sizing: border-box; +} + +.channels-screen--list .channel-row-controls { + position: absolute; + right: 7px; + bottom: 6px; + top: auto; + width: auto; + min-width: 0; + height: auto; + display: flex; + align-items: flex-end; + pointer-events: none; +} + +.channels-screen--list .channel-row .channel-row-controls > .channel-row-count { + position: static; + margin: 0; + align-self: flex-end; +} diff --git a/shine-UI/styles/features/messages.css b/shine-UI/styles/features/messages.css index 545bbabb..775f01fb 100644 --- a/shine-UI/styles/features/messages.css +++ b/shine-UI/styles/features/messages.css @@ -722,3 +722,16 @@ button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); } opacity: 0.72; transform: translateY(-1px); } + + +/* ===== Personal chat row layout: time top-right, unread bottom-right ===== */ +.dm-list-screen .dm-row-time { + grid-row: 1; + align-self: center; +} + +.dm-list-screen .dm-unread-badge, +.dm-list-screen .dm-row-meta-spacer { + grid-row: 2; + align-self: center; +} From 4c7e71f21fc6ae8b2c200e00b66c47656effd429896d036b16483e899d719009 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 10 Sep 2026 12:33:24 +0300 Subject: [PATCH 10/30] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E=20=D0=BF=D1=80=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BA=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shine/db/dao/ChannelReadStateDAO.java | 17 ++++ .../channels/ChannelsReadSupport.java | 5 +- .../Net_GetChannelMessages_Handler.java | 10 ++- .../Net_ListSubscriptionsFeed_Handler.java | 4 +- .../Net_SetChannelReadState_Handler.java | 12 ++- .../Net_ListSubscriptionsFeed_Response.java | 4 + VERSION.properties | 4 +- docs/API/06_Channels_Read_API.md | 9 ++- shine-UI/js/pages/channel-view.js | 79 +++++++++++++++---- shine-UI/js/pages/chat-view.js | 35 ++------ shine-UI/js/services/auth-service.js | 6 +- 11 files changed, 125 insertions(+), 60 deletions(-) diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java index b5d0825c..14ddb017 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java @@ -136,6 +136,23 @@ public final class ChannelReadStateDAO { } } + + public Integer resolvePublicChannelRootByName(Connection c, String ownerBchName, String channelName) throws SQLException { + String sql = """ + SELECT channel_root_block_number + FROM channel_names_state + WHERE owner_bch_name=? AND slug=? AND channel_type_code=1 + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerBchName); + ps.setString(2, channelName); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getInt("channel_root_block_number") : null; + } + } + } + public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException { String sql = """ SELECT owner_login diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index bd3ac25a..762d8fba 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -236,12 +236,13 @@ final class ChannelsReadSupport { static List channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException { String order = asc ? "ASC" : "DESC"; + boolean bounded = limit > 0; String sql = """ SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=? ORDER BY block_number - """ + order + " LIMIT ?"; + """ + order + (bounded ? " LIMIT ?" : ""); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, ownerBch); ps.setInt(2, MSG_TYPE_TEXT); @@ -252,7 +253,7 @@ final class ChannelsReadSupport { ps.setInt(7, MsgSubType.TEXT_SERVICE); ps.setInt(8, MsgSubType.TEXT_COURSE); ps.setInt(9, lineCode); - ps.setInt(10, limit); + if (bounded) ps.setInt(10, limit); try (ResultSet rs = ps.executeQuery()) { List out = new ArrayList<>(); while (rs.next()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index bb95d644..f3c6e71c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -34,9 +34,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel"); } - int limit = req.getLimit() == null ? 30 : req.getLimit(); - if (limit <= 0 || limit > 1000) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit"); + // limit is optional. null/0 means "return the whole channel". + // Positive values are kept for backwards-compatible callers that still want a bounded response. + int limit = req.getLimit() == null ? 0 : req.getLimit(); + if (limit < 0) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_limit", "Некорректный limit"); } boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort()); @@ -113,7 +115,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { if (!asc) { java.util.Collections.reverse(posts); } - if (posts.size() > limit) { + if (limit > 0 && posts.size() > limit) { posts = new ArrayList<>(posts.subList(0, limit)); } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java index 394215c4..797295a8 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java @@ -82,12 +82,14 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { channelRef.setChannelRoot(rootRef); row.setChannel(channelRef); - row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber)); + int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber); + row.setMessagesCount(messagesCount); boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin); ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null ? null : ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName); row.setReadStateInitialized(readState != null); + row.setReadCount(readState == null ? 0L : Math.min(messagesCount, Math.max(0L, readState.readCount()))); row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount())); ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java index 2c960e76..cefc6df3 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java @@ -100,13 +100,21 @@ public final class Net_SetChannelReadState_Handler implements JsonMessageHandler "CHANNEL_NOT_FOLLOWED", "Пользователь не подписан на этот канал"); } String ownerLogin = dao.resolveOwnerLogin(c, ownerBch, channelName); - if (ownerLogin == null || ownerLogin.isBlank()) { + Integer channelRoot = dao.resolvePublicChannelRootByName(c, ownerBch, channelName); + if (ownerLogin == null || ownerLogin.isBlank() || channelRoot == null) { return NetExceptionResponseFactory.error(req, 404, "CHANNEL_NOT_FOUND", "Канал не найден"); } + + // Never persist a watermark beyond the number of messages that really exists. + // Without this guard, one buggy/malicious oversized read_count would be monotonic + // and could make all future unread counters impossible to clear correctly. + long actualMessagesCount = ChannelsReadSupport.countPosts(c, ownerBch, channelRoot); + long effectiveReadCount = Math.min(readCount, actualMessagesCount); + result = dao.upsertSignedReadIfNewer(c, authenticatedLogin, ownerLogin, ownerBch, channelName, - readCount, timeMs, clientKeyB64, signatureB64, nowMs); + effectiveReadCount, timeMs, clientKeyB64, signatureB64, nowMs); } Net_SetChannelReadState_Response resp = new Net_SetChannelReadState_Response(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java index 1f163dea..457e3f2e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java @@ -26,6 +26,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { public static class ChannelSummary { private ChannelRef channel; private int messagesCount; + private long readCount; private int unreadCount; private boolean readStateInitialized; private LastMessage lastMessage; @@ -36,6 +37,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { public int getMessagesCount() { return messagesCount; } public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; } + public long getReadCount() { return readCount; } + public void setReadCount(long readCount) { this.readCount = readCount; } + public int getUnreadCount() { return unreadCount; } public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; } diff --git a/VERSION.properties b/VERSION.properties index 6d57c3ce..d978ef40 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.9 -server.version=1.10.3 +client.version=1.12.10 +server.version=1.10.4 diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index 99172efd..33da7c92 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -29,9 +29,9 @@ > На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки. > -> `unreadCount` для канала считается по `user_settings`. +> `unreadCount` для канала считается по подписанному состоянию чтения канала. > Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого. -> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным; UI при загрузке списка каналов создаёт baseline на текущем `messagesCount`. После этого новые публикации увеличивают `unreadCount` до продвижения курсора чтения. +> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным. После появления записи новые публикации увеличивают `unreadCount` до продвижения курсора чтения. --- @@ -112,6 +112,7 @@ "channelRoot": { "blockNumber": 456, "blockHash": "..." } }, "messagesCount": 90, + "readCount": 0, "unreadCount": 0, "readStateInitialized": false, "lastMessage": { @@ -148,6 +149,8 @@ } ``` +`limit` необязателен. Если поле отсутствует или равно `0`, сервер возвращает всю ленту канала. Положительное значение ограничивает количество сообщений для совместимых клиентов. + ### Response (success) ```json { @@ -539,7 +542,7 @@ SHiNe/ChannelReadState:|||| 0 ? screen.querySelector('.channel-unread-line') : null; const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount); let desiredSeenCount = safeInitialSeenCount; @@ -333,7 +337,7 @@ function createChannelReadTracker({ inFlight = true; try { - await authService.setChannelReadState({ + const persisted = await authService.setChannelReadState({ login, ownerBlockchainName: cleanOwnerBlockchainName, channelName: cleanChannelName, @@ -341,11 +345,20 @@ function createChannelReadTracker({ timeMs: Date.now(), storagePwd, }); - persistedSeenCount = next; + const serverReadCount = Math.max(0, Number(persisted?.read_count ?? next)); + const serverUnreadCount = Math.max(0, Number(persisted?.unread_count ?? Math.max(0, safeMessagesCount - serverReadCount))); + persistedSeenCount = Math.max(persistedSeenCount, Math.min(serverReadCount, safeMessagesCount)); + desiredSeenCount = Math.max(desiredSeenCount, persistedSeenCount); initialPersistPending = false; - if (typeof onPersistSuccess === 'function') onPersistSuccess(persistedSeenCount); + if (typeof onPersistSuccess === 'function') { + onPersistSuccess({ + readCount: persistedSeenCount, + unreadCount: serverUnreadCount, + applied: persisted?.applied !== false, + }); + } } catch (error) { - if (typeof onPersistError === 'function') onPersistError(error); + if (typeof onPersistError === 'function') onPersistError(error, { retrying: true }); queueFlush(800); } finally { inFlight = false; @@ -355,7 +368,9 @@ function createChannelReadTracker({ const collectSeenCount = () => { const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]')); if (!cards.length) return safeInitialSeenCount; - if (!unreadLine) return safeMessagesCount; + // Absence of the divider must never imply that unseen messages are read. + // Use only the highest message card that actually crossed the viewport. + const root = getChannelScrollRoot(); const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0; @@ -389,6 +404,12 @@ function createChannelReadTracker({ }); }; + if (!canWrite && (initializeIfMissing || unreadCount > 0)) { + const error = new Error(`Нельзя сохранить прочитанность канала: отсутствует ${missingWriteParts.join(', ')}`); + console.error('[SHiNE][ChannelReadState]', error); + if (typeof onPersistError === 'function') onPersistError(error, { retrying: false }); + } + const scrollRoot = getChannelScrollRoot(); const onScroll = () => measure(); const onResize = () => measure(); @@ -1462,6 +1483,7 @@ async function loadFromApi(route, channelId) { const isAuthorized = !!currentSessionLogin; let unreadCount = 0; let messagesCount = 0; + let readCount = 0; let readStateInitialized = false; let cachedFeed = null; const ensureFeed = async () => { @@ -1523,9 +1545,6 @@ async function loadFromApi(route, channelId) { } if (selector?.ownerBlockchainName && selector?.channelName) { - let unreadCount = 0; - let messagesCount = 0; - const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim(); const routeOwnerNormalized = routeOwnerRaw.toLowerCase(); const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw); @@ -1577,6 +1596,7 @@ async function loadFromApi(route, channelId) { } unreadCount = Number(channel?.unreadCount || 0); messagesCount = Number(channel?.messagesCount || 0); + readCount = Number(channel?.readCount || 0); readStateInitialized = !!channel?.readStateInitialized; selector = { ownerBlockchainName: String(channel.channel.ownerBlockchainName), @@ -1590,7 +1610,7 @@ async function loadFromApi(route, channelId) { throw new Error('Не удалось определить канал из адреса страницы.'); } - const payload = await authService.getChannelMessages(selector, 200, 'asc', currentSessionLogin); + const payload = await authService.getChannelMessages(selector, null, 'asc', currentSessionLogin); const messages = Array.isArray(payload.messages) ? payload.messages : []; let reverseChannelMissingWarning = ''; let mergedMessages = [...messages]; @@ -1625,7 +1645,7 @@ async function loadFromApi(route, channelId) { channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber), channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash), }; - const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', currentSessionLogin); + const reversePayload = await authService.getChannelMessages(reverseSelector, null, 'asc', currentSessionLogin); const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : []; mergedMessages = mergedMessages.concat(reverseMessages); } else { @@ -1669,6 +1689,7 @@ async function loadFromApi(route, channelId) { reverseChannelMissingWarning, unreadCount, messagesCount, + readCount, isOwnChannel, isSubscribed, readStateInitialized, @@ -2276,7 +2297,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { const showStatus = typeof handlers?.showStatus === 'function' ? handlers.showStatus : () => {}; const unreadCount = Math.max(0, Number(channelData.unreadCount || 0)); const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0)); - const readCount = Math.max(0, messagesCount - unreadCount); + const readCount = Math.max(0, Math.min(Number(channelData.readCount || 0), messagesCount)); if (channelData.reverseChannelMissingWarning) { const reverseWarning = document.createElement('p'); @@ -2328,7 +2349,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) { const unreadLine = document.createElement('div'); unreadLine.className = 'card channel-unread-line'; - unreadLine.textContent = 'Не прочитанные сообщения'; + unreadLine.textContent = 'Новые сообщения'; feed.append(unreadLine); unreadLineInserted = true; } @@ -2399,10 +2420,34 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { unreadCount, messagesCount, initialSeenCount: readCount, - onPersistError: () => { - showStatus('Не удалось сохранить, сколько сообщений прочитано. Сервер повторит попытку автоматически.'); + onPersistError: (error, options = {}) => { + const detail = String(error?.message || '').trim(); + const retryText = options?.retrying === false ? '' : ' Сервер повторит попытку автоматически.'; + showStatus(`Не удалось сохранить, сколько сообщений прочитано.${detail ? ` ${detail}` : ''}${retryText}`); }, - onPersistSuccess: () => { + onPersistSuccess: ({ readCount: persistedReadCount, unreadCount: persistedUnreadCount }) => { + channelData.readCount = persistedReadCount; + channelData.unreadCount = persistedUnreadCount; + channelData.readStateInitialized = true; + + // The "Новые сообщения" divider is a snapshot of the unread boundary at the + // moment this channel view was opened. Persisting read state must not move or + // remove it during the current view session; reopening the channel recalculates it. + + const feedGroups = ['ownedChannels', 'followedUsersChannels', 'followedChannels']; + for (const group of feedGroups) { + const rows = Array.isArray(state.channelsFeed?.[group]) ? state.channelsFeed[group] : []; + const row = rows.find((item) => ( + String(item?.channel?.ownerBlockchainName || '') === String(channelData.selector?.ownerBlockchainName || '') + && Number(item?.channel?.channelRoot?.blockNumber) === Number(channelData.selector?.channelRootBlockNumber) + && normalizeRouteHash(item?.channel?.channelRoot?.blockHash) === normalizeRouteHash(channelData.selector?.channelRootBlockHash) + )); + if (row) { + row.readCount = persistedReadCount; + row.unreadCount = persistedUnreadCount; + row.readStateInitialized = true; + } + } showStatus(''); }, }); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 397dcb0f..746a1999 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -902,7 +902,6 @@ export function render({ navigate, route, chrome }) { const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase()); let peerRelationType = isKnownContact ? 'contact' : 'none'; const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread); - const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000; let historyHasMore = true; let historyLoading = false; let historyNextBeforeTimeMs = 0; @@ -910,16 +909,8 @@ export function render({ navigate, route, chrome }) { let historyBootstrapped = false; let boundScrollContainer = null; let unreadSeparatorVisible = hasUnreadIncoming; - let unreadSeparatorHideTimer = null; let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || ''; - const clearUnreadSeparatorHideTimer = () => { - if (unreadSeparatorHideTimer) { - window.clearTimeout(unreadSeparatorHideTimer); - unreadSeparatorHideTimer = null; - } - }; - const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => { renderLog(log, chatId, { onOpenActions: handleOpenActions, @@ -928,13 +919,9 @@ export function render({ navigate, route, chrome }) { showUnreadSeparator: unreadSeparatorVisible, unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey, }); - if (unreadSeparatorVisible) { - scheduleUnreadSeparatorAutoHide(); - } }; const hideUnreadSeparator = ({ rerender = true } = {}) => { - clearUnreadSeparatorHideTimer(); if (!unreadSeparatorVisible) return; unreadSeparatorVisible = false; if (rerender) { @@ -942,14 +929,6 @@ export function render({ navigate, route, chrome }) { } }; - const scheduleUnreadSeparatorAutoHide = () => { - clearUnreadSeparatorHideTimer(); - if (!unreadSeparatorVisible) return; - unreadSeparatorHideTimer = window.setTimeout(() => { - hideUnreadSeparator({ rerender: true }); - }, UNREAD_SEPARATOR_AUTO_HIDE_MS); - }; - const handleReadAloud = async (msg) => { if (!isTextToSpeechConfigured(state.entrySettings)) { showTtsMissingConfigDialog(); @@ -1371,7 +1350,6 @@ export function render({ navigate, route, chrome }) { const safeText = sanitizeUserDmTextForSend(String(rawText || '')); const text = safeText.trim(); if (!text) return; - hideUnreadSeparator({ rerender: false }); const editing = activeEdit; const replying = !editing ? activeReply : null; const finalText = editing @@ -1414,6 +1392,13 @@ export function render({ navigate, route, chrome }) { deliveryState: result?.deliveryState || 'accepted', }); + // In a DM the "Новые сообщения" divider stays fixed for the current open + // chat session and disappears only after a successfully sent NEW message. + // Editing an existing message must not clear the divider. + if (!editing) { + hideUnreadSeparator({ rerender: false }); + } + if (editing) { cancelEditMode({ restoreDraft: true }); } else if (replying) { @@ -1634,7 +1619,6 @@ export function render({ navigate, route, chrome }) { unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey; unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey); } - scheduleUnreadSeparatorAutoHide(); } preserveComposerSelection(input, () => { renderChatLog({ scrollMode: 'latest' }); @@ -1660,10 +1644,8 @@ export function render({ navigate, route, chrome }) { markAsRead: false, scrollMode: hasUnreadIncoming ? 'unread' : 'latest', showUnreadSeparator: unreadSeparatorVisible, + unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey, }); - if (unreadSeparatorVisible) { - scheduleUnreadSeparatorAutoHide(); - } window.setTimeout(() => { if (markChatRead(chatId) > 0) { notifyUnreadStateUpdated(); @@ -1681,7 +1663,6 @@ export function render({ navigate, route, chrome }) { window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh); boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll); - clearUnreadSeparatorHideTimer(); }; return screen; } diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index f1687c1f..3f4124f8 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1574,13 +1574,15 @@ export class AuthService { return response.payload || {}; } - async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') { + async getChannelMessages(channel, limit = null, sort = 'asc', login = '') { const normalizedChannel = { ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(), channelRootBlockNumber: Number(channel?.channelRootBlockNumber), channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(), }; - const payload = { channel: normalizedChannel, limit, sort }; + const payload = { channel: normalizedChannel, sort }; + const cleanLimit = Number(limit); + if (Number.isFinite(cleanLimit) && cleanLimit > 0) payload.limit = Math.trunc(cleanLimit); const cleanLogin = String(login || '').trim(); if (cleanLogin) payload.login = cleanLogin; const response = await this.ws.request('GetChannelMessages', payload); From d83f1d4cce86e8bbeb1531cc436cf9a174b36dec66872560a1cb74647dd5fb26 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 10 Sep 2026 12:41:33 +0300 Subject: [PATCH 11/30] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B8=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20DM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/pages/chat-view.js | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index d978ef40..59118614 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.10 +client.version=1.12.11 server.version=1.10.4 diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 746a1999..f3d7f3e9 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -1355,6 +1355,14 @@ export function render({ navigate, route, chrome }) { const finalText = editing ? `${String(editing?.prefixText || '')}${text}` : `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`; + + // A new outgoing message immediately ends the visual "Новые сообщения" section. + // Do this before awaiting the server: the divider is a UI-session marker, not a + // delivery/read receipt indicator. Editing an existing message does not clear it. + if (!editing) { + hideUnreadSeparator({ rerender: false }); + } + const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text); renderChatLog({ scrollMode: 'latest', markAsRead: false }); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); @@ -1392,13 +1400,6 @@ export function render({ navigate, route, chrome }) { deliveryState: result?.deliveryState || 'accepted', }); - // In a DM the "Новые сообщения" divider stays fixed for the current open - // chat session and disappears only after a successfully sent NEW message. - // Editing an existing message must not clear the divider. - if (!editing) { - hideUnreadSeparator({ rerender: false }); - } - if (editing) { cancelEditMode({ restoreDraft: true }); } else if (replying) { From 849250bfa8e7cf54a3da00eb75381c42a9bbea5a42a8a9ac7c717d4767feae0b Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 10 Sep 2026 14:52:20 +0300 Subject: [PATCH 12/30] =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D0=B7=D0=B8=20-=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D0=B5=D1=81=D0=BD=D0=BE=20=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BD=D0=B0=D0=B4=D0=BE=20=D0=B4=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- docs/Personal_Messages/Протокол_DM_v1.md | 6 + docs/Personal_Messages/Формат_DM_v1.md | 6 + shine-UI/assets/shine-official-badge.svg | 25 ++ shine-UI/js/pages/chat-view.js | 9 +- shine-UI/js/pages/network-view.js | 332 ++++++++++++++++++++++- shine-UI/js/pages/network/adapter.js | 2 + shine-UI/js/pages/network/force-graph.js | 272 +++++++++++++++---- shine-UI/styles/network-graph.css | 90 ++---- 9 files changed, 613 insertions(+), 131 deletions(-) create mode 100644 shine-UI/assets/shine-official-badge.svg diff --git a/VERSION.properties b/VERSION.properties index 59118614..69c6d861 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.11 +client.version=1.12.12 server.version=1.10.4 diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index e58e29d5..75c64e4d 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -131,6 +131,12 @@ dm_dialog_state хранит: WebSocket push ускоряет отображение, но после переподключения клиент запрашивает историю у своего единственного access-сервера. +UI-правило для открытого диалога: разделитель «Новые сообщения» относится только к +непрочитанным сообщениям, которые уже существовали до открытия экрана чата. Если новое +входящее сообщение приходит, пока этот диалог уже открыт, клиент просто добавляет его в +текущий поток, не создавая новый разделитель «Новые сообщения»; затем обычный механизм +видимого чтения/receipt отмечает его прочитанным. + ## 9. Подтверждения прочтения Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index a5842884..38978942 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -264,6 +264,12 @@ ReadReceiptBody_v1_0 - если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад. - в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview. + +UI-примечание (байтовый формат не меняет): разделитель «Новые сообщения» создаётся только +для непрочитанного хвоста, который существовал до открытия диалога. Входящий `type=1`, +полученный при уже открытом соответствующем чате, отображается сразу без создания нового +разделителя; это не изменяет signed-контейнер и не вводит нового поля протокола. + ## 9. Контент типов `5/6` Типы: diff --git a/shine-UI/assets/shine-official-badge.svg b/shine-UI/assets/shine-official-badge.svg new file mode 100644 index 00000000..b5768831 --- /dev/null +++ b/shine-UI/assets/shine-official-badge.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index f3d7f3e9..e76e3bfd 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -1615,12 +1615,9 @@ export function render({ navigate, route, chrome }) { const handleIncomingChatRefresh = async (event) => { const updatedChatId = normalizeDmChatId(event?.detail?.chatId); if (updatedChatId !== chatId) return; - if (Number(event?.detail?.messageType || 0) === 1) { - if (!unreadSeparatorVisible) { - unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey; - unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey); - } - } + // Если диалог уже открыт, новое входящее сразу показывается в текущем потоке и не создаёт + // новый визуальный разделитель «Новые сообщения». Такой разделитель относится только к + // непрочитанным, которые существовали ДО открытия экрана чата. preserveComposerSelection(input, () => { renderChatLog({ scrollMode: 'latest' }); }); diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index d5c04f64..25f452ca 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -201,6 +201,269 @@ function buildGraphModel(graph, centerLogin) { let persistedCenterLogin = ''; let persistedCenterHistory = []; +let persistedGraphHistory = []; + +const HISTORY_MAX_CENTERS = 4; +const HISTORY_CENTER_GAP_MIN = 280; +const HISTORY_CENTER_GAP_STEP = 72; +const HISTORY_CENTER_CLEARANCE = 110; +const HISTORY_ORBIT_FIRST_R = 104; +const HISTORY_ORBIT_GAP = 88; +const HISTORY_ORBIT_NODE_GAP = 84; +const HISTORY_NODE_CLEARANCE = 92; + +function historyHash01(value) { + let h = 2166136261; + const text = String(value || ''); + for (let i = 0; i < text.length; i += 1) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return ((h >>> 0) % 1000) / 1000; +} + +function historyOrbitCapacity(radius) { + const r = Math.max(radius, HISTORY_ORBIT_NODE_GAP / 2 + 1); + const minAngle = 2 * Math.asin(Math.min(1, HISTORY_ORBIT_NODE_GAP / (2 * r))); + return Math.max(1, Math.floor((Math.PI * 2) / minAngle)); +} + +function historyOrbitPlacements(total, seed = '') { + const count = Math.max(0, Number(total) || 0); + const out = new Array(count); + let start = 0; + let ring = 0; + const seedPhase = historyHash01(seed) * Math.PI * 2; + while (start < count) { + const radius = HISTORY_ORBIT_FIRST_R + ring * HISTORY_ORBIT_GAP; + const capacity = historyOrbitCapacity(radius); + const ringCount = Math.min(capacity, count - start); + const phase = seedPhase + ring * 0.43; + for (let i = 0; i < ringCount; i += 1) { + out[start + i] = { + radius, + angle: phase + (Math.PI * 2 * i) / Math.max(1, ringCount), + }; + } + start += ringCount; + ring += 1; + } + return out; +} + +function historyClusterRadius(snapshot) { + const nodes = Array.isArray(snapshot?.engineModel?.nodes) ? snapshot.engineModel.nodes : []; + const centerKey = normKey(snapshot?.centerLogin); + const relationCount = nodes.filter((node) => normKey(node?.id) !== centerKey).length; + const placements = historyOrbitPlacements(relationCount, snapshot?.centerLogin || ''); + const maxOrbit = placements.reduce((max, row) => Math.max(max, Number(row?.radius) || 0), 0); + return Math.max(HISTORY_ORBIT_FIRST_R, maxOrbit) + HISTORY_CENTER_CLEARANCE; +} + +function buildHistoryEngineModel(history) { + const snapshots = (Array.isArray(history) ? history : []).slice(-HISTORY_MAX_CENTERS); + const latest = snapshots[snapshots.length - 1]; + if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] }; + + const centerPos = new Map(); + const centerOrder = new Map(); + const clusterRadius = new Map(); + centerPos.set(normKey(latest.centerLogin), { x: 0, y: 0 }); + snapshots.forEach((snap, index) => { + const key = normKey(snap.centerLogin); + centerOrder.set(key, index); + clusterRadius.set(key, historyClusterRadius(snap)); + }); + + // Новейший центр имеет приоритет. Более старые центры отодвигаем вдоль направления перехода + // настолько далеко, насколько нужно, чтобы окружности их кластеров не пересекались. + for (let i = snapshots.length - 1; i > 0; i -= 1) { + const current = snapshots[i]; + const previous = snapshots[i - 1]; + const currentKey = normKey(current.centerLogin); + const previousKey = normKey(previous.centerLogin); + const curPos = centerPos.get(currentKey) || { x: 0, y: 0 }; + const angle = Number.isFinite(Number(current.transitionAngle)) ? Number(current.transitionAngle) : 0; + const prevR = clusterRadius.get(previousKey) || HISTORY_CENTER_CLEARANCE; + const curR = clusterRadius.get(currentKey) || HISTORY_CENTER_CLEARANCE; + let gap = Math.max(HISTORY_CENTER_GAP_MIN, prevR + curR); + let candidate = null; + + for (let attempt = 0; attempt < 24; attempt += 1) { + candidate = { + x: curPos.x - Math.cos(angle) * gap, + y: curPos.y - Math.sin(angle) * gap, + }; + let collides = false; + for (const [placedKey, placed] of centerPos.entries()) { + const placedR = clusterRadius.get(placedKey) || HISTORY_CENTER_CLEARANCE; + const minDist = prevR + placedR; + if (Math.hypot(candidate.x - placed.x, candidate.y - placed.y) < minDist) { + collides = true; + break; + } + } + if (!collides) break; + gap += HISTORY_CENTER_GAP_STEP; + } + centerPos.set(previousKey, candidate || { + x: curPos.x - Math.cos(angle) * gap, + y: curPos.y - Math.sin(angle) * gap, + }); + } + + const nodeMap = new Map(); + const latestPlacementParent = new Map(); + const edgeMap = new Map(); + + // Сначала собираем все узлы/рёбра, не назначая окончательные позиции периферийным пользователям. + snapshots.forEach((snap, snapIndex) => { + const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : []; + const centerKey = normKey(snap.centerLogin); + const center = modelNodes.find((node) => normKey(node?.id) === centerKey) || modelNodes[0]; + if (center) { + nodeMap.set(centerKey, { ...nodeMap.get(centerKey), ...center, keepVisible: true, isHistoryCenter: true }); + } + + const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey); + relations.forEach((node) => { + const key = normKey(node?.id); + if (!key) return; + nodeMap.set(key, { ...nodeMap.get(key), ...node }); + + const a = centerKey; + const b = key; + const edgeKey = a < b ? `${a}|${b}` : `${b}|${a}`; + edgeMap.set(edgeKey, { + a, + b, + relationType: node.relationType || 'contact', + strength: Number(node.strength) || 0.5, + snapIndex, + }); + }); + }); + + // Все исторические центры сразу считаются занятыми местами. + const occupied = []; + for (const [centerKey, pos] of centerPos.entries()) { + occupied.push({ key: centerKey, x: pos.x, y: pos.y, clearance: HISTORY_CENTER_CLEARANCE }); + } + + const collidesAt = (key, x, y, clearance = HISTORY_NODE_CLEARANCE) => occupied.some((row) => { + if (row.key === key) return false; + return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0); + }); + + // Новые/актуальные круги имеют приоритет: идём от текущего центра к старым. Если пользователь + // встречается повторно, его единственный узел получает позицию именно в наиболее свежем круге. + for (let snapIndex = snapshots.length - 1; snapIndex >= 0; snapIndex -= 1) { + const snap = snapshots[snapIndex]; + const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : []; + const centerKey = normKey(snap.centerLogin); + const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey); + const placements = historyOrbitPlacements(relations.length, snap.centerLogin); + const base = centerPos.get(centerKey) || { x: 0, y: 0 }; + + relations.forEach((node, index) => { + const key = normKey(node?.id); + if (!key || centerOrder.has(key) || latestPlacementParent.has(key)) return; + const preferred = placements[index] || { radius: HISTORY_ORBIT_FIRST_R, angle: 0 }; + let chosen = null; + // Сначала пробуем желаемую орбиту, затем соседние углы, после чего постепенно расширяем радиус. + for (let radialStep = 0; radialStep < 12 && !chosen; radialStep += 1) { + const radius = preferred.radius + radialStep * HISTORY_ORBIT_GAP * 0.55; + const angularSamples = 18 + radialStep * 2; + for (let step = 0; step < angularSamples; step += 1) { + const offsetIndex = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? 1 : -1); + const angle = preferred.angle + offsetIndex * (Math.PI * 2 / angularSamples); + const x = base.x + Math.cos(angle) * radius; + const y = base.y + Math.sin(angle) * radius; + if (!collidesAt(key, x, y, HISTORY_NODE_CLEARANCE)) { + chosen = { x, y }; + break; + } + } + } + if (!chosen) { + const radius = preferred.radius + 12 * HISTORY_ORBIT_GAP * 0.55; + chosen = { + x: base.x + Math.cos(preferred.angle) * radius, + y: base.y + Math.sin(preferred.angle) * radius, + }; + } + latestPlacementParent.set(key, { centerKey, ...chosen, snapIndex }); + occupied.push({ key, ...chosen, clearance: HISTORY_NODE_CLEARANCE }); + }); + } + + for (const [centerKey, pos] of centerPos.entries()) { + const node = nodeMap.get(centerKey); + if (!node) continue; + nodeMap.set(centerKey, { + ...node, + layoutX: pos.x, + layoutY: pos.y, + fixedLayout: true, + keepVisible: true, + isHistoryCenter: true, + }); + } + + for (const [key, placement] of latestPlacementParent.entries()) { + const node = nodeMap.get(key); + if (!node || centerOrder.has(key)) continue; + nodeMap.set(key, { + ...node, + layoutX: placement.x, + layoutY: placement.y, + fixedLayout: true, + parentId: placement.centerKey, + }); + } + + const edgeParentsByChild = new Map(); + const addEdgeParent = (childKey, parentKey, edge) => { + if (!childKey || !parentKey || childKey === parentKey) return; + const list = edgeParentsByChild.get(childKey) || []; + if (!list.some((row) => row.id === parentKey)) { + list.push({ + id: parentKey, + relationType: edge.relationType, + strength: edge.strength, + shining: edge.shining, + }); + } + edgeParentsByChild.set(childKey, list); + }; + + for (const edge of edgeMap.values()) { + const aCenterIndex = centerOrder.get(edge.a); + const bCenterIndex = centerOrder.get(edge.b); + if (aCenterIndex !== undefined && bCenterIndex !== undefined) { + if (aCenterIndex > bCenterIndex) addEdgeParent(edge.a, edge.b, edge); + else addEdgeParent(edge.b, edge.a, edge); + continue; + } + if (aCenterIndex !== undefined) addEdgeParent(edge.b, edge.a, edge); + else if (bCenterIndex !== undefined) addEdgeParent(edge.a, edge.b, edge); + } + + const nodes = [...nodeMap.entries()].map(([key, node]) => ({ + ...node, + id: key, + login: node.login || node.id || key, + tier: 1, + fixedLayout: true, + edgeParents: edgeParentsByChild.get(key) || [], + })); + + return { + focusId: normKey(latest.engineModel.focusId), + nodes, + preserveHistory: snapshots.length > 1, + }; +} export function render({ navigate, route, chrome } = {}) { const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; @@ -208,6 +471,7 @@ export function render({ navigate, route, chrome } = {}) { if (!keepHistory) { persistedCenterLogin = ''; persistedCenterHistory = []; + persistedGraphHistory = []; } const screen = document.createElement('section'); @@ -221,27 +485,36 @@ export function render({ navigate, route, chrome } = {}) { let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || ''); let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : []; + let graphHistory = Array.isArray(persistedGraphHistory) ? [...persistedGraphHistory] : []; let engine = null; let loadSeq = 0; - // Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам. + // Независимые фильтры карты. Оба выключены = показываем всё. + // Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей. const FILTERS = { - all: { label: 'Все', pred: () => true }, - friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' || n.relationType === 'close_friend' }, + close: { label: 'Близкие', pred: (n) => n.relationType === 'close_friend' }, shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) }, }; - const FILTER_ORDER = ['all', 'friends', 'shining']; - let activeFilter = 'all'; + const FILTER_ORDER = ['close', 'shining']; + const activeFilters = new Set(); const filterChips = {}; + function currentFilterPredicate(node) { + for (const key of activeFilters) { + if (!FILTERS[key].pred(node)) return false; + } + return true; + } + function applyFilter(key) { if (!FILTERS[key]) return; - activeFilter = key; + if (activeFilters.has(key)) activeFilters.delete(key); + else activeFilters.add(key); FILTER_ORDER.forEach((k) => { const el = filterChips[k]; - if (el) el.classList.toggle('is-active', k === activeFilter); + if (el) el.classList.toggle('is-active', activeFilters.has(k)); }); - if (engine) engine.setFilter(FILTERS[key].pred); + if (engine) engine.setFilter(currentFilterPredicate); } function profileInfoRoute(login) { @@ -254,6 +527,7 @@ export function render({ navigate, route, chrome } = {}) { function persistHistory() { persistedCenterLogin = centerLogin; persistedCenterHistory = [...centerHistory]; + persistedGraphHistory = [...graphHistory]; } function syncLinksUrl(login, { push = false } = {}) { @@ -392,7 +666,10 @@ export function render({ navigate, route, chrome } = {}) { stage: board, model, // тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет - onNodeTap: (node) => { void load(node.login, { pushHistory: true }); }, + onNodeTap: (node) => { + const transitionAngle = Math.atan2(Number(node?.y) || 0, Number(node?.x) || 0); + void load(node.login, { pushHistory: true, transitionAngle }); + }, // тап по центру — полноценный профиль onCenterTap: (node) => { const routeTo = profileInfoRoute(node.login); @@ -415,7 +692,7 @@ export function render({ navigate, route, chrome } = {}) { }); } - async function load(nextCenterLogin = '', { pushHistory = false } = {}) { + async function load(nextCenterLogin = '', { pushHistory = false, transitionAngle = 0 } = {}) { const requestId = ++loadSeq; const prevCenter = centerLogin; const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login); @@ -430,10 +707,35 @@ export function render({ navigate, route, chrome } = {}) { syncLinksUrl(targetCenter, { push: pushHistory }); const graphModel = buildGraphModel(graph, targetCenter); - const engineModel = engineModelFromGraphModel(graphModel); + const snapshotModel = engineModelFromGraphModel(graphModel); + const snapshot = { + centerLogin: targetCenter, + engineModel: snapshotModel, + transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0, + }; + + if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { + // Один и тот же пользователь не хранится как два исторических центра: повторный переход + // переносит его в конец истории, где он становится актуальным центром. + graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter)); + graphHistory.push(snapshot); + if (graphHistory.length > HISTORY_MAX_CENTERS) { + graphHistory = graphHistory.slice(-HISTORY_MAX_CENTERS); + centerHistory = centerHistory.slice(-(HISTORY_MAX_CENTERS - 1)); + } + } else { + const last = graphHistory[graphHistory.length - 1]; + if (last && normKey(last.centerLogin) === normKey(targetCenter)) { + snapshot.transitionAngle = Number(last.transitionAngle) || 0; + graphHistory[graphHistory.length - 1] = snapshot; + } else { + graphHistory = [snapshot]; + } + } + + const engineModel = buildHistoryEngineModel(graphHistory); ensureEngine(engineModel); - // сохраняем выбранный фильтр при перестроении графа (центрирование/переход) - if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred); + if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate); persistHistory(); } catch (error) { @@ -475,6 +777,7 @@ export function render({ navigate, route, chrome } = {}) { if (routeLogin) { centerLogin = routeLogin; centerHistory = []; + graphHistory = []; persistHistory(); void load(centerLogin, { pushHistory: false }); } else if (keepHistory && centerLogin) { @@ -482,6 +785,7 @@ export function render({ navigate, route, chrome } = {}) { } else { centerLogin = normalizeLogin(state.session.login || ''); centerHistory = []; + graphHistory = []; persistHistory(); if (centerLogin) { void load(centerLogin, { pushHistory: false }); @@ -499,7 +803,7 @@ export function render({ navigate, route, chrome } = {}) { FILTER_ORDER.forEach((key) => { const chip = document.createElement('button'); chip.type = 'button'; - chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`; + chip.className = `fg-filter-chip${activeFilters.has(key) ? ' is-active' : ''}`; chip.textContent = FILTERS[key].label; chip.addEventListener('click', () => applyFilter(key)); filterChips[key] = chip; diff --git a/shine-UI/js/pages/network/adapter.js b/shine-UI/js/pages/network/adapter.js index 81f33cc0..b7143536 100644 --- a/shine-UI/js/pages/network/adapter.js +++ b/shine-UI/js/pages/network/adapter.js @@ -47,6 +47,7 @@ export function engineModelFromGraphModel(graphModel) { relationType: 'self', strength: 1, shining: Boolean(centerMark?.shine), + official: Boolean(centerMark?.official), tier: 1, }; @@ -67,6 +68,7 @@ export function engineModelFromGraphModel(graphModel) { relationType: relationTypeFromRelation(r), strength: deriveStrength(r || {}), shining: Boolean(r?.mark?.shine), + official: Boolean(r?.mark?.official), tier: 1, }; }) diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index e84e28e2..edd46228 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -26,6 +26,7 @@ const ORBIT_GAP = 87; // между соседними кругами const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина» const K_FOCUS = 0.12; // мягкая пружина фокуса к центру +const K_FIXED_LAYOUT = 0.09; // пружина к фиксированной позиции исторической карты const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется) const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники) const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания @@ -262,21 +263,35 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const rebuildIndex = () => { nodeById = new Map(nodes.map((n) => [String(n.id), n])); hasDeep = nodes.some((n) => n.tier >= 2); - // число детей у родителя + порядковый индекс ребёнка среди братьев (для веера «полукругом наружу») childCountByParent = new Map(); degreeById = new Map(); + + const hasExplicitEdges = nodes.some((n) => Array.isArray(n.edgeParents) && n.edgeParents.length > 0); + if (hasExplicitEdges) { + for (const n of nodes) { + const refs = Array.isArray(n.edgeParents) ? n.edgeParents : []; + for (const ref of refs) { + const parentId = String(ref?.id || ''); + if (!parentId || !nodeById.has(parentId)) continue; + degreeById.set(String(n.id), (degreeById.get(String(n.id)) || 0) + 1); + degreeById.set(parentId, (degreeById.get(parentId) || 0) + 1); + } + } + return; + } + let tier1count = 0; for (const n of nodes) { if (n.tier >= 2 && n.parentId) { const i = childCountByParent.get(n.parentId) || 0; n.sibIndex = i; childCountByParent.set(n.parentId, i + 1); - degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1); // у родителя +1 связь + degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1); } else if (n.tier === 1 && String(n.id) !== focusId) { tier1count += 1; } } - degreeById.set(focusId, tier1count); // у центра — число связей 1-го уровня + degreeById.set(focusId, tier1count); }; // Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы @@ -410,7 +425,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const focusSrc = list.find((n) => String(n.id) === fId) || list[0]; const tier1Orbit = buildOrbitPlacements(tier1.length); if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false, orbit: null }); - tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES, orbit: tier1Orbit[i] })); + tier1.forEach((p, i) => specs.push({ + src: p, + id: String(p.id), + isFocus: false, + index: i, + total: tier1.length, + dotOnly: i >= MAX_FULL_NODES && !p.keepVisible, + orbit: p.fixedLayout ? null : tier1Orbit[i], + })); // 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 })); return { focusId: fId, specs }; @@ -425,24 +448,30 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) { const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5)); const tier = Number(src.tier) || 1; - // Первый уровень теперь раскладывается по строгим концентрическим кругам без джиттера: - // иначе случайное смещение могло бы снова нарушить минимальный зазор между аватарками. - const targetR = isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R); - const angle = isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total)); + // Первый уровень обычно раскладывается по орбитам. Для исторической карты network-view + // может передать фиксированную мировую позицию: тогда узел сохраняет принадлежность к своему + // кластеру, а повторно встретившийся пользователь плавно переезжает в более новый кластер. + const layoutX = Number(src.layoutX); + const layoutY = Number(src.layoutY); + const fixedLayout = Boolean(src.fixedLayout) && Number.isFinite(layoutX) && Number.isFinite(layoutY); + const targetR = fixedLayout ? Math.hypot(layoutX, layoutY) : (isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R)); + const angle = fixedLayout ? Math.atan2(layoutY, layoutX) : (isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total))); // масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда. const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE)); const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1); - // целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру — - // узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует. - const tx = isFocus ? 0 : Math.cos(angle) * targetR; - const ty = isFocus ? 0 : Math.sin(angle) * targetR; + const tx = fixedLayout ? layoutX : (isFocus ? 0 : Math.cos(angle) * targetR); + const ty = fixedLayout ? layoutY : (isFocus ? 0 : Math.sin(angle) * targetR); const el = buildNodeElement(src, isFocus, tier, dotOnly); world.append(el); return { ...src, isFocus, tier, - parentId: String(src.parentId || ''), // у tier≥2 — id родителя; пусто → центр (фокус) + parentId: String(src.parentId || ''), // layout/deep parent; для history-рёбер используется edgeParents + edgeParents: Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [], + fixedLayout, + keepVisible: Boolean(src.keepVisible), + official: Boolean(src.official), deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2, track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко pinned: false, // зафиксировано кликом/тапом — ветка раскрыта «намертво» @@ -501,6 +530,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png // на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного). const GLASS_OVERLAY_SRC = '/assets/glass_overlay_faithful.png'; + const OFFICIAL_BADGE_SRC = '/assets/shine-official-badge.svg'; function buildPngOrb(src, opts) { const o = opts || {}; const wrap = document.createElement('div'); @@ -528,6 +558,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return wrap; } + + function syncOfficialBadge(el, official) { + if (!(el instanceof HTMLElement)) return; + const dot = el.querySelector('.node-dot'); + if (!(dot instanceof HTMLElement)) return; + const existing = dot.querySelector('.fg-official-badge'); + if (!official) { + existing?.remove(); + return; + } + if (existing) return; + const badge = document.createElement('img'); + badge.className = 'fg-official-badge'; + badge.src = OFFICIAL_BADGE_SRC; + badge.alt = ''; + badge.setAttribute('aria-hidden', 'true'); + dot.append(badge); + } + function buildNodeElement(src, isFocus, tier, dotOnly = false) { const el = document.createElement('button'); el.type = 'button'; @@ -537,6 +586,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 'ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка) src.shining ? 'is-shine' : '', + src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`, ].filter(Boolean).join(' '); el.dataset.nodeId = String(src.id); @@ -549,6 +599,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 'ui-button', 'fg-node', isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', + src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный) tier >= 2 ? 'is-secondary' : '', @@ -565,12 +616,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // Единый PNG-оверлей на ВСЕХ полных орбах (фокус + спутники). tier-3 точки (dotOnly) сюда не идут. dot.appendChild(buildPngOrb(photoSrc, { isFocus, initials })); el.append(dot); + syncOfficialBadge(el, Boolean(src.official)); - // Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0. - const badge = document.createElement('span'); - badge.className = 'fg-node-badge'; - badge.hidden = true; - el.append(badge); const label = document.createElement('span'); label.className = 'fg-node-label'; @@ -579,16 +626,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return el; } - // Заполняет бейджи-счётчики связей (число детей/связей узла). Вызывается после rebuildIndex. - function updateBadges() { - for (const n of nodes) { - const badge = n.el.querySelector('.fg-node-badge'); - if (!badge) continue; // у точек (dotOnly) бейджа нет - const deg = degreeById.get(String(n.id)) || 0; - if (deg > 0) { badge.textContent = deg > 99 ? '99+' : String(deg); badge.hidden = false; } - else { badge.hidden = true; } - } - } + // Числовые бейджи связей на аватарках больше не показываем: карта остаётся визуально чистой. + function updateBadges() {} // Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком). function updateA11y() { @@ -616,11 +655,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL node.strength = strength; node.relationType = src.relationType; node.shining = Boolean(src.shining); - node.targetR = spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R); - node.angle = spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total)); + node.official = Boolean(src.official); + node.keepVisible = Boolean(src.keepVisible); + node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : []; + const layoutX = Number(src.layoutX); + const layoutY = Number(src.layoutY); + node.fixedLayout = Boolean(src.fixedLayout) && Number.isFinite(layoutX) && Number.isFinite(layoutY); + node.targetR = node.fixedLayout ? Math.hypot(layoutX, layoutY) : (spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R)); + node.angle = node.fixedLayout ? Math.atan2(layoutY, layoutX) : (spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total))); node.orbitRing = spec.orbit?.ring || 0; - node.tx = Math.cos(node.angle) * node.targetR; - node.ty = Math.sin(node.angle) * node.targetR; + node.tx = node.fixedLayout ? layoutX : Math.cos(node.angle) * node.targetR; + node.ty = node.fixedLayout ? layoutY : Math.sin(node.angle) * node.targetR; node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE)); node.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1); node.hidden = false; @@ -628,8 +673,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26))); // обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM node.el.className = spec.dotOnly - ? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ') - : ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' '); + ? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ') + : ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' '); + if (!spec.dotOnly) syncOfficialBadge(node.el, node.official); } // --- Рендер ---------------------------------------------------------------- @@ -637,6 +683,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL world.style.transform = `translate3d(${camX}px, ${camY}px, 0) scale(${zoom})`; } + + // Ограничение ручного pan: карту можно свободно двигать, пока её видимая геометрия пересекает + // обе центральные оси viewport. На крайнем положении к центральной линии прижимается край + // крайнего аватара — полностью выбросить весь граф за экран больше нельзя. + function clampCameraPosition(nextX, nextY) { + const visible = nodes.filter((n) => !n.hidden && (Number(n.opacity) || 0) > 0.02); + if (!visible.length) return { x: 0, y: 0 }; + let minX = Infinity; let maxX = -Infinity; + let minY = Infinity; let maxY = -Infinity; + for (const n of visible) { + const baseR = n.dotOnly ? (Number(n.dotRadius) || 5) : ORB_R; + const r = baseR * (Number(n.scale) || 1) * (Number(n.depthScale) || 1); + minX = Math.min(minX, n.x - r); + maxX = Math.max(maxX, n.x + r); + minY = Math.min(minY, n.y - r); + maxY = Math.max(maxY, n.y + r); + } + if (!Number.isFinite(minX) || !Number.isFinite(maxX)) return { x: nextX, y: nextY }; + const minCamX = -maxX * zoom; + const maxCamX = -minX * zoom; + const minCamY = -maxY * zoom; + const maxCamY = -minY * zoom; + return { + x: Math.max(minCamX, Math.min(maxCamX, nextX)), + y: Math.max(minCamY, Math.min(maxCamY, nextY)), + }; + } + // Камера-доводчик: мягко подвести раскрываемый кластер целиком в кадр, НЕ теряя центр (Иван остаётся // в графе, просто сдвигается). Считаем экранную позицию узла и его «веера» (DEEP_R2) и, если он // упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет. @@ -666,6 +740,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL zoom = z1; camX = sx - centerX - wx * z1; camY = sy - centerY - wy * z1; + ({ x: camX, y: camY } = clampCameraPosition(camX, camY)); camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик applyWorldTransform(); renderEdges(); @@ -784,10 +859,89 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL n.lod = full ? 'full' : 'dot'; n.dotOnly = !full; n.dotRadius = full ? 12 : 5; // радиус для расчёта концов линий связей - if (full) { const b = newEl.querySelector('.fg-node-badge'); const deg = degreeById.get(String(n.id)) || 0; if (b && deg > 0) { b.textContent = deg > 99 ? '99+' : String(deg); b.hidden = false; } } + } + + function renderHistoryEdges() { + const Z = zoom; + const parts = []; + const tx = (n) => centerX + camX + n.x * Z; + const ty = (n) => centerY + camY + n.y * Z; + + for (const n of nodes) { + const refs = Array.isArray(n.edgeParents) ? n.edgeParents : []; + if (!refs.length) continue; + const childOpacity = (typeof n.opacity === 'number' ? n.opacity : 1) * (n.spotCur ?? 1); + if (n.hidden && childOpacity <= 0.02) continue; + + for (const ref of refs) { + const parent = nodeById.get(String(ref?.id || '')); + if (!parent || parent === n) continue; + const parentOpacity = (typeof parent.opacity === 'number' ? parent.opacity : 1) * (parent.spotCur ?? 1); + if (parent.hidden && parentOpacity <= 0.02) continue; + + const fx = tx(parent); + const fy = ty(parent); + const nx = tx(n); + const ny = ty(n); + if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue; + if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue; + + const dx = nx - fx; + const dy = ny - fy; + const len = Math.hypot(dx, dy) || 1; + const ux = dx / len; + const uy = dy / len; + const fr = (parent.dotOnly ? parent.dotRadius : ORB_R) * parent.scale * (parent.depthScale ?? 1) * Z; + const nr = (n.dotOnly ? n.dotRadius : ORB_R) * n.scale * (n.depthScale ?? 1) * Z; + const x1 = fx + ux * fr; + const y1 = fy + uy * fr; + const x2 = nx - ux * nr; + const y2 = ny - uy * nr; + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2; + const segLen = Math.hypot(x2 - x1, y2 - y1); + const bow = Math.max(7, Math.min(22, segLen * 0.10)); + const sign = hash01(`${parent.id}|${n.id}|edge`) > 0.5 ? 1 : -1; + const desX = mx + (-uy) * bow * sign; + const desY = my + ux * bow * sign; + const cpx = 2 * desX - mx; + const cpy = 2 * desY - my; + const relationType = String(ref?.relationType || n.relationType || 'contact'); + const strength = Math.max(0, Math.min(1, Number(ref?.strength) || Number(n.strength) || 0.5)); + const opacity = Math.min(childOpacity, parentOpacity); + const shine = Boolean(parent.shining && n.shining) && !n.hidden; + + if (shine) { + const pnx = -uy; + const pny = ux; + const amp = Math.min(13, 5 + segLen * 0.05); + const bowX = desX - mx; + const bowY = desY - my; + const c1x = x1 + (x2 - x1) / 3 + bowX + pnx * amp; + const c1y = y1 + (y2 - y1) / 3 + bowY + pny * amp; + const c2x = x1 + 2 * (x2 - x1) / 3 + bowX - pnx * amp; + const c2y = y1 + 2 * (y2 - y1) / 3 + bowY - pny * amp; + const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} C${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; + parts.push(``); + parts.push(``); + parts.push(``); + } else { + const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; + const col = relationColor(relationType); + const haloWidth = (2.6 + strength * 1.4).toFixed(2); + parts.push(``); + parts.push(``); + } + } + } + edgesSvg.innerHTML = parts.join(''); } function renderEdges() { + if (nodes.some((n) => Array.isArray(n.edgeParents) && n.edgeParents.length > 0)) { + renderHistoryEdges(); + return; + } const focus = nodes.find((n) => n.id === focusId); if (!focus) { edgesSvg.innerHTML = ''; @@ -864,7 +1018,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность; // • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий // core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core). - const shine = Boolean(n.shining) && !n.hidden; + const shine = Boolean(parent.shining && n.shining) && !n.hidden; const sp = (n.spotCur ?? 1); // spotlight/глубина: линия тускнеет вместе со своим узлом const onPath = Boolean(diveTargetId) && ensurePathSet().has(String(n.id)) && !n.isFocus; // нить-крошка пути const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; @@ -949,7 +1103,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let ax = 0; let ay = 0; - if (n.isFocus) { + if (n.fixedLayout) { + // Историческая карта уже получила приоритетную раскладку от network-view. Не разрушаем её + // общей радиальной физикой: только мягко возвращаем узел к назначенной точке после жестов/анимаций. + ax += K_FIXED_LAYOUT * (n.tx - n.x); + ay += K_FIXED_LAYOUT * (n.ty - n.y); + } else if (n.isFocus) { // пружина к центру: быстрый влёт + лёгкий отскок (фокус сам не отталкивается) ax += K_FOCUS * (0 - n.x); ay += K_FOCUS * (0 - n.y); @@ -970,8 +1129,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let dist2 = dx * dx + dy * dy; if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST; const dist = Math.sqrt(dist2); - // адаптивное расталкивание (collision): раскрытая ветка «толще» — усиливаем отталкивание - // пропорционально прогрессу раскрытия любого из пары, чтобы кластеры разъезжались как магниты. const ex = Math.max(n.expandP || 0, m.expandP || 0); const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2; ax += (dx / dist) * f; @@ -1113,7 +1270,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const visiblePeers = []; nodes.forEach((n) => { - if (n.isFocus) { n.hidden = false; return; } + if (n.isFocus || n.keepVisible) { n.hidden = false; return; } n.hidden = !pred(n); n.vx = 0; n.vy = 0; @@ -1132,9 +1289,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL }; const focus = nodes.find((n) => n.isFocus); - if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1); + if (focus) apply(focus, focus.tx || 0, focus.ty || 0, FOCUS_SCALE, 1); - applyOrbitTargets(visiblePeers); + const fixedLayout = visiblePeers.some((n) => n.fixedLayout) || nodes.some((n) => n.fixedLayout); + if (!fixedLayout) applyOrbitTargets(visiblePeers); visiblePeers.forEach((n) => { const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE; apply(n, n.tx, n.ty, sc, 1); @@ -1189,8 +1347,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // инерция панорамирования (kinematic): камера докатывается с трением const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15); if (panActive) { - camX += panVelX; - camY += panVelY; + const proposedX = camX + panVelX; + const proposedY = camY + panVelY; + const clamped = clampCameraPosition(proposedX, proposedY); + camX = clamped.x; + camY = clamped.y; + if (camX !== proposedX) panVelX = 0; + if (camY !== proposedY) panVelY = 0; panVelX *= PAN_FRICTION; panVelY *= PAN_FRICTION; applyWorldTransform(); @@ -1479,8 +1642,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const newCamY = camStartY + dy; panVelX = newCamX - camX; // мгновенная скорость свайпа (для инерции после отпускания) panVelY = newCamY - camY; - camX = newCamX; - camY = newCamY; + const clamped = clampCameraPosition(newCamX, newCamY); + camX = clamped.x; + camY = clamped.y; + if (camX !== newCamX) panVelX = 0; + if (camY !== newCamY) panVelY = 0; applyWorldTransform(); advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа renderEdges(); // рёбра следуют за камерой синхронно (дёшево) @@ -1541,6 +1707,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL viewH = stage.clientHeight || window.innerHeight; centerX = viewW / 2; centerY = viewH / 2; + ({ x: camX, y: camY } = clampCameraPosition(camX, camY)); + applyWorldTransform(); renderEdges(); } @@ -1597,6 +1765,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep. if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } freezeGraph(); + ({ x: camX, y: camY } = clampCameraPosition(camX, camY)); + applyWorldTransform(); + renderEdges(); return; } boost = 1; // BLOOM: мягкое «гель»-демпфированное упругое покачивание в покое (0.94→0.80) @@ -1617,8 +1788,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // старый узел нового фокуса (если был) — фокус глайдит из его позиции const focusOld = oldById.get(String(newFocusId)); - // снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира - spawnGhost(); + // В обычном режиме остаётся старый шлейф. В history-режиме реальные старые узлы уже входят + // в новую модель, поэтому ghost дал бы ложные дубликаты аватаров — там его не создаём. + if (!nextModel?.preserveHistory) spawnGhost(); nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); }); focusId = String(newFocusId); @@ -1679,7 +1851,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return node; }); pendingFocusOrigin = null; - diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум + // Новый центр меняет положение карты, но пользовательский масштаб сохраняем. + // Сбрасываем только режим погружения; zoom остаётся тем, который пользователь выбрал колесом/щипком. + diveTargetId = null; surfacing = false; rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов updateBadges(); // бейджи-счётчики связей под новый набор updateA11y(); // текстовый список графа для скринридеров diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 675818bb..4736a2a6 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -200,6 +200,24 @@ background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px; } + +/* Маленький знак официального пользователя. Он находится внутри .node-dot, поэтому автоматически + масштабируется вместе с аватаркой при focus/zoom/анимациях графа. */ +.fg-official-badge { + position: absolute; + left: -2%; + bottom: 0; + width: 15%; + height: 15%; + min-width: 8px; + min-height: 8px; + object-fit: contain; + display: block; + z-index: 5; + pointer-events: none; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42)); +} + .fg-node.is-family .node-dot { background: linear-gradient(165deg, #785038, #5f3e2c); border-color: rgba(255, 194, 143, 0.6); @@ -241,15 +259,17 @@ .fg-node.is-pressed .node-dot { transform: none; } } -/* «Сияние» — постоянное живое свечение НА УЗЛЕ (аватарке), а не на линии связи. - Пульсация остаётся мягкой, но нижняя точка теперь не проваливается почти в ноль: - визуально сияющий пользователь всегда остаётся явно сияющим. */ +/* «Сияние» на аватарке теперь стационарное. Интенсивность зафиксирована примерно на 2/3 пути + от прежнего минимального состояния к максимальному: заметно, но без постоянного «дыхания». */ .fg-node.is-shine .node-dot { border-color: rgba(150, 240, 255, 0.62); - animation: fg-shine-glow 3.6s ease-in-out infinite; + box-shadow: + 0 0 8.3px rgba(146, 243, 255, 0.57), + 0 0 18.3px rgba(121, 235, 255, 0.39), + 0 0 33px rgba(100, 220, 255, 0.24); } -/* размытый радиальный ореол позади аватарки; внешний SVG-фильтр даёт мягкое гауссово размытие */ +/* Статичный размытый ореол позади аватарки — та же промежуточная интенсивность ~2/3. */ .fg-node.is-shine .node-dot::before { content: ''; position: absolute; @@ -259,34 +279,8 @@ filter: url(#fg-shine-glow); z-index: -1; pointer-events: none; - animation: fg-shine-halo 3.6s ease-in-out infinite; -} - -/* пульсация многослойной тени: компактное приглушённое → широкое мягкое свечение */ -@keyframes fg-shine-glow { - 0%, 100% { - box-shadow: - 0 0 7px rgba(138, 239, 255, 0.48), - 0 0 15px rgba(118, 232, 255, 0.32), - 0 0 27px rgba(100, 220, 255, 0.19); - } - 50% { - box-shadow: - 0 0 9px rgba(150, 245, 255, 0.62), - 0 0 20px rgba(122, 236, 255, 0.42), - 0 0 36px rgba(100, 220, 255, 0.26); - } -} - -/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */ -@keyframes fg-shine-halo { - 0%, 100% { transform: scale(0.98); opacity: 0.72; } - 50% { transform: scale(1.12); opacity: 1; } -} - -@media (prefers-reduced-motion: reduce) { - .fg-node.is-shine .node-dot { animation: none; } - .fg-node.is-shine .node-dot::before { animation: none; } + transform: scale(1.07); + opacity: 0.91; } /* мягкое свечение вокруг фокуса (статичное; «дышит» вместе с размером узла ниже) */ @@ -524,37 +518,11 @@ background: transparent; } -/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */ - -/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */ -.fg-node-badge { - position: absolute; - top: -2px; - right: -2px; - min-width: 16px; - height: 16px; - padding: 0 4px; - border-radius: 999px; - background: rgba(16, 24, 40, 0.92); - border: 1px solid rgba(150, 200, 255, 0.5); - color: #d9ecff; - font-size: 9px; - font-weight: 700; - line-height: 14px; - text-align: center; - pointer-events: none; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); -} -.fg-node.is-focus .fg-node-badge { - background: rgba(61, 196, 223, 0.95); - border-color: rgba(220, 245, 255, 0.8); - color: #06131c; -} -.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); } +/* === Партия 2: поиск, хлебные крошки, цветовые кластеры ============================== */ /* Кластерная аура по категории удалена (цветной фон/обводка узла убраны). Сияющим/фокусу box-shadow не навязываем — у них свой эффект свечения (is-shine/is-focus выше). */ -.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; } +.fg-node.is-focus:not(.is-shine) .node-dot { box-shadow: none; } /* Строка поиска (оверлей вверху, под панелью фильтров) */ .fg-search { From 4991a146d5927e065bd595996a373a9f83480294941660b8b18074e45b451372 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 10 Sep 2026 19:12:11 +0300 Subject: [PATCH 13/30] =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D0=B7=D0=B8=20-=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D0=B5=D1=81=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/pages/network-view.js | 689 ++++++++++++++++------- shine-UI/js/pages/network/force-graph.js | 20 +- shine-UI/styles/network-graph.css | 35 +- 4 files changed, 524 insertions(+), 222 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 69c6d861..a41defa7 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.12 +client.version=1.12.13 server.version=1.10.4 diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 25f452ca..1cfd0983 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -92,8 +92,14 @@ function getMarkByLogin(allUsers) { relationType: String(row?.relationType || '').trim().toLowerCase(), primaryConfirmed: Boolean(row?.primaryConfirmed), shineConfirmed: Boolean(row?.shineConfirmed), - official: Boolean(row?.official), - shine: Boolean(row?.shine), + // Основной источник — server official. accountRole оставляем как совместимый fallback, + // чтобы badge не пропадал на серверах/ответах переходного периода. + official: row?.official === true + || String(row?.official || '').trim().toLowerCase() === 'true' + || String(row?.accountRole || '').trim().toLowerCase() === 'primary', + shine: row?.shine === true + || String(row?.shine || '').trim().toLowerCase() === 'true' + || String(row?.shineStatus || '').trim().toLowerCase() === 'shining', officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')), shineLabel: String(row?.shineLabel || (row?.shine ? 'сияющий' : 'несияющий')), avatar: normalizeAvatar(row), @@ -202,15 +208,18 @@ function buildGraphModel(graph, centerLogin) { let persistedCenterLogin = ''; let persistedCenterHistory = []; let persistedGraphHistory = []; +let persistedHistoryDepth = 4; +let persistedX2Enabled = false; -const HISTORY_MAX_CENTERS = 4; -const HISTORY_CENTER_GAP_MIN = 280; -const HISTORY_CENTER_GAP_STEP = 72; -const HISTORY_CENTER_CLEARANCE = 110; -const HISTORY_ORBIT_FIRST_R = 104; -const HISTORY_ORBIT_GAP = 88; -const HISTORY_ORBIT_NODE_GAP = 84; -const HISTORY_NODE_CLEARANCE = 92; +const HISTORY_MAX_PREVIOUS = 4; +const HISTORY_CENTER_GAP_START = 108; +const HISTORY_CENTER_GAP_STEP = 28; +const HISTORY_CENTER_CLEARANCE = 88; +const HISTORY_NODE_CLEARANCE = 82; +const HISTORY_TIER2_CLEARANCE = 48; +const HISTORY_DIRECT_SPACING = 92; +const HISTORY_TIER2_SPACING = 54; +const HISTORY_LAYOUT_MAX_SHELL = 9; function historyHash01(value) { let h = 2166136261; @@ -219,244 +228,429 @@ function historyHash01(value) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 1000) / 1000; + return ((h >>> 0) % 100000) / 100000; } -function historyOrbitCapacity(radius) { - const r = Math.max(radius, HISTORY_ORBIT_NODE_GAP / 2 + 1); - const minAngle = 2 * Math.asin(Math.min(1, HISTORY_ORBIT_NODE_GAP / (2 * r))); - return Math.max(1, Math.floor((Math.PI * 2) / minAngle)); +function historySquareShellCells(shell) { + const r = Math.max(1, Math.trunc(shell)); + const cells = []; + for (let y = -r; y <= r; y += 1) { + for (let x = -r; x <= r; x += 1) { + if (Math.max(Math.abs(x), Math.abs(y)) !== r) continue; + cells.push({ x, y }); + } + } + return cells; } -function historyOrbitPlacements(total, seed = '') { - const count = Math.max(0, Number(total) || 0); - const out = new Array(count); - let start = 0; - let ring = 0; - const seedPhase = historyHash01(seed) * Math.PI * 2; - while (start < count) { - const radius = HISTORY_ORBIT_FIRST_R + ring * HISTORY_ORBIT_GAP; - const capacity = historyOrbitCapacity(radius); - const ringCount = Math.min(capacity, count - start); - const phase = seedPhase + ring * 0.43; - for (let i = 0; i < ringCount; i += 1) { - out[start + i] = { - radius, - angle: phase + (Math.PI * 2 * i) / Math.max(1, ringCount), - }; +// Компактная «квадратная» раскладка: сначала заполняем ближайший квадратный пояс, +// причём соседние выбранные точки стараемся брать далеко друг от друга. Поэтому 3–5 друзей +// не выстраиваются в длинную цепочку, а образуют небольшое облако вокруг центра. +function historyCompactSlots(total, seed = '', spacing = HISTORY_DIRECT_SPACING, maxShell = HISTORY_LAYOUT_MAX_SHELL) { + const count = Math.max(0, Math.trunc(Number(total) || 0)); + const out = []; + let globalIndex = 0; + for (let shell = 1; shell <= maxShell && out.length < count; shell += 1) { + const remaining = historySquareShellCells(shell); + const ordered = []; + const start = remaining.length ? Math.floor(historyHash01(`${seed}|${shell}|start`) * remaining.length) : 0; + if (remaining.length) ordered.push(remaining.splice(start, 1)[0]); + while (remaining.length) { + let bestIndex = 0; + let bestScore = -Infinity; + remaining.forEach((cell, index) => { + let minD2 = Infinity; + for (const used of ordered) { + const dx = cell.x - used.x; + const dy = cell.y - used.y; + minD2 = Math.min(minD2, dx * dx + dy * dy); + } + const jitter = historyHash01(`${seed}|${shell}|${cell.x}|${cell.y}`) * 0.05; + const score = minD2 + jitter; + if (score > bestScore) { bestScore = score; bestIndex = index; } + }); + ordered.push(remaining.splice(bestIndex, 1)[0]); + } + + const quarterTurns = Math.floor(historyHash01(`${seed}|rotate`) * 4); + const turn = (cell) => { + let { x, y } = cell; + for (let i = 0; i < quarterTurns; i += 1) [x, y] = [-y, x]; + return { x, y }; + }; + for (const raw of ordered) { + if (out.length >= count) break; + const cell = turn(raw); + const jx = (historyHash01(`${seed}|${globalIndex}|x`) - 0.5) * spacing * 0.10; + const jy = (historyHash01(`${seed}|${globalIndex}|y`) - 0.5) * spacing * 0.10; + out.push({ + x: cell.x * spacing + jx, + y: cell.y * spacing + jy, + shell, + }); + globalIndex += 1; } - start += ringCount; - ring += 1; } return out; } -function historyClusterRadius(snapshot) { - const nodes = Array.isArray(snapshot?.engineModel?.nodes) ? snapshot.engineModel.nodes : []; - const centerKey = normKey(snapshot?.centerLogin); - const relationCount = nodes.filter((node) => normKey(node?.id) !== centerKey).length; - const placements = historyOrbitPlacements(relationCount, snapshot?.centerLogin || ''); - const maxOrbit = placements.reduce((max, row) => Math.max(max, Number(row?.radius) || 0), 0); - return Math.max(HISTORY_ORBIT_FIRST_R, maxOrbit) + HISTORY_CENTER_CLEARANCE; +function historyIdealShell(count) { + const n = Math.max(0, Math.trunc(Number(count) || 0)); + if (!n) return 0; + let shell = 1; + while (4 * shell * (shell + 1) < n) shell += 1; + return shell; } -function buildHistoryEngineModel(history) { - const snapshots = (Array.isArray(history) ? history : []).slice(-HISTORY_MAX_CENTERS); - const latest = snapshots[snapshots.length - 1]; - if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] }; +function relationEdge(parentId, node) { + return { + id: normKey(parentId), + relationType: String(node?.relationType || 'contact'), + strength: Math.max(0, Math.min(1, Number(node?.strength) || 0.5)), + }; +} - const centerPos = new Map(); - const centerOrder = new Map(); - const clusterRadius = new Map(); - centerPos.set(normKey(latest.centerLogin), { x: 0, y: 0 }); - snapshots.forEach((snap, index) => { - const key = normKey(snap.centerLogin); - centerOrder.set(key, index); - clusterRadius.set(key, historyClusterRadius(snap)); +function stripSecondLevel(model) { + const nodes = Array.isArray(model?.nodes) ? model.nodes : []; + const keep = new Set(nodes.filter((node) => (Number(node?.tier) || 1) < 2).map((node) => normKey(node?.id))); + return { + ...model, + nodes: nodes + .filter((node) => keep.has(normKey(node?.id))) + .map((node) => ({ + ...node, + edgeParents: (Array.isArray(node?.edgeParents) ? node.edgeParents : []) + .filter((edge) => keep.has(normKey(edge?.id))), + })), + }; +} + +async function mapWithConcurrency(items, concurrency, worker) { + const list = Array.isArray(items) ? items : []; + const out = new Array(list.length); + let cursor = 0; + const runners = new Array(Math.min(Math.max(1, concurrency), list.length)).fill(0).map(async () => { + while (cursor < list.length) { + const index = cursor++; + try { out[index] = await worker(list[index], index); } + catch (error) { out[index] = { error }; } + } + }); + await Promise.all(runners); + return out; +} + +async function buildSecondLevelEngineModel(baseModel, getGraph) { + const focusKey = normKey(baseModel?.focusId); + const baseNodes = Array.isArray(baseModel?.nodes) ? baseModel.nodes : []; + const byKey = new Map(); + + baseNodes.forEach((src) => { + const key = normKey(src?.id); + if (!key) return; + const isFocus = key === focusKey; + const normalized = { + ...src, + id: key, + login: src?.login || src?.id || key, + tier: 1, + parentId: isFocus ? '' : focusKey, + edgeParents: isFocus ? [] : [relationEdge(focusKey, src)], + }; + byKey.set(key, normalized); }); - // Новейший центр имеет приоритет. Более старые центры отодвигаем вдоль направления перехода - // настолько далеко, насколько нужно, чтобы окружности их кластеров не пересекались. - for (let i = snapshots.length - 1; i > 0; i -= 1) { - const current = snapshots[i]; - const previous = snapshots[i - 1]; - const currentKey = normKey(current.centerLogin); - const previousKey = normKey(previous.centerLogin); - const curPos = centerPos.get(currentKey) || { x: 0, y: 0 }; - const angle = Number.isFinite(Number(current.transitionAngle)) ? Number(current.transitionAngle) : 0; - const prevR = clusterRadius.get(previousKey) || HISTORY_CENTER_CLEARANCE; - const curR = clusterRadius.get(currentKey) || HISTORY_CENTER_CLEARANCE; - let gap = Math.max(HISTORY_CENTER_GAP_MIN, prevR + curR); - let candidate = null; + const directFriends = [...byKey.values()].filter((node) => ( + normKey(node.id) !== focusKey + && (node.relationType === 'friend' || node.relationType === 'close_friend') + )); - for (let attempt = 0; attempt < 24; attempt += 1) { - candidate = { - x: curPos.x - Math.cos(angle) * gap, - y: curPos.y - Math.sin(angle) * gap, - }; - let collides = false; - for (const [placedKey, placed] of centerPos.entries()) { - const placedR = clusterRadius.get(placedKey) || HISTORY_CENTER_CLEARANCE; - const minDist = prevR + placedR; - if (Math.hypot(candidate.x - placed.x, candidate.y - placed.y) < minDist) { - collides = true; - break; - } + const fetched = await mapWithConcurrency(directFriends, 4, async (parent) => { + const graph = await getGraph(parent.login || parent.id); + const graphModel = buildGraphModel(graph, parent.login || parent.id); + return { parent, model: engineModelFromGraphModel(graphModel) }; + }); + + fetched.forEach((row) => { + if (!row || row.error || !row.model) return; + const parentKey = normKey(row.parent?.id); + const childNodes = Array.isArray(row.model.nodes) ? row.model.nodes : []; + childNodes.forEach((child) => { + const childKey = normKey(child?.id); + if (!childKey || childKey === parentKey || childKey === focusKey) return; + if (child.relationType !== 'friend' && child.relationType !== 'close_friend') return; + const edge = relationEdge(parentKey, child); + const existing = byKey.get(childKey); + if (existing) { + const refs = Array.isArray(existing.edgeParents) ? [...existing.edgeParents] : []; + if (!refs.some((ref) => normKey(ref?.id) === parentKey)) refs.push(edge); + byKey.set(childKey, { ...existing, edgeParents: refs }); + return; } - if (!collides) break; - gap += HISTORY_CENTER_GAP_STEP; - } - centerPos.set(previousKey, candidate || { - x: curPos.x - Math.cos(angle) * gap, - y: curPos.y - Math.sin(angle) * gap, - }); - } - - const nodeMap = new Map(); - const latestPlacementParent = new Map(); - const edgeMap = new Map(); - - // Сначала собираем все узлы/рёбра, не назначая окончательные позиции периферийным пользователям. - snapshots.forEach((snap, snapIndex) => { - const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : []; - const centerKey = normKey(snap.centerLogin); - const center = modelNodes.find((node) => normKey(node?.id) === centerKey) || modelNodes[0]; - if (center) { - nodeMap.set(centerKey, { ...nodeMap.get(centerKey), ...center, keepVisible: true, isHistoryCenter: true }); - } - - const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey); - relations.forEach((node) => { - const key = normKey(node?.id); - if (!key) return; - nodeMap.set(key, { ...nodeMap.get(key), ...node }); - - const a = centerKey; - const b = key; - const edgeKey = a < b ? `${a}|${b}` : `${b}|${a}`; - edgeMap.set(edgeKey, { - a, - b, - relationType: node.relationType || 'contact', - strength: Number(node.strength) || 0.5, - snapIndex, + byKey.set(childKey, { + ...child, + id: childKey, + login: child?.login || child?.id || childKey, + tier: 2, + parentId: parentKey, + alwaysVisible: true, + edgeParents: [edge], }); }); }); - // Все исторические центры сразу считаются занятыми местами. - const occupied = []; - for (const [centerKey, pos] of centerPos.entries()) { - occupied.push({ key: centerKey, x: pos.x, y: pos.y, clearance: HISTORY_CENTER_CLEARANCE }); - } + return { ...baseModel, nodes: [...byKey.values()] }; +} - const collidesAt = (key, x, y, clearance = HISTORY_NODE_CLEARANCE) => occupied.some((row) => { - if (row.key === key) return false; +function buildHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVIOUS) { + const keepPrevious = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Math.trunc(Number(historyDepth) || 0))); + const maxSnapshots = keepPrevious + 1; + const snapshots = (Array.isArray(history) ? history : []).slice(-maxSnapshots); + const latest = snapshots[snapshots.length - 1]; + if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] }; + + const centerOrder = new Map(); + const nodeMap = new Map(); + const latestOwner = new Map(); + const edgeMap = new Map(); + + snapshots.forEach((snap, snapIndex) => { + const centerKey = normKey(snap?.centerLogin); + if (!centerKey) return; + centerOrder.set(centerKey, snapIndex); + const modelNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : []; + modelNodes.forEach((rawNode) => { + const key = normKey(rawNode?.id); + if (!key) return; + const node = { + ...nodeMap.get(key), + ...rawNode, + id: key, + login: rawNode?.login || rawNode?.id || key, + }; + if (key === centerKey) { + node.tier = 1; + node.keepVisible = true; + node.isHistoryCenter = true; + } + nodeMap.set(key, node); + latestOwner.set(key, snapIndex); + + if (key === centerKey) return; + let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : []; + if (!parents.length) parents = [relationEdge(rawNode?.parentId || centerKey, rawNode)]; + parents.forEach((ref) => { + const parentKey = normKey(ref?.id || centerKey); + if (!parentKey || parentKey === key) return; + const a = parentKey < key ? parentKey : key; + const b = parentKey < key ? key : parentKey; + edgeMap.set(`${a}|${b}`, { + parent: parentKey, + child: key, + relationType: String(ref?.relationType || rawNode?.relationType || 'contact'), + strength: Math.max(0, Math.min(1, Number(ref?.strength) || Number(rawNode?.strength) || 0.5)), + snapIndex, + }); + }); + }); + }); + + const occupied = []; + const placed = new Map(); + const clearanceForNode = (node) => (Number(node?.tier) || 1) >= 2 ? HISTORY_TIER2_CLEARANCE : HISTORY_NODE_CLEARANCE; + const collides = (x, y, clearance, rows = occupied, ignoreKey = '') => rows.some((row) => { + if (ignoreKey && row.key === ignoreKey) return false; return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0); }); - // Новые/актуальные круги имеют приоритет: идём от текущего центра к старым. Если пользователь - // встречается повторно, его единственный узел получает позицию именно в наиболее свежем круге. - for (let snapIndex = snapshots.length - 1; snapIndex >= 0; snapIndex -= 1) { - const snap = snapshots[snapIndex]; - const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : []; - const centerKey = normKey(snap.centerLogin); - const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey); - const placements = historyOrbitPlacements(relations.length, snap.centerLogin); - const base = centerPos.get(centerKey) || { x: 0, y: 0 }; + function ownedNodesForSnapshot(snapIndex) { + const centerKey = normKey(snapshots[snapIndex]?.centerLogin); + return [...nodeMap.entries()] + .filter(([key]) => latestOwner.get(key) === snapIndex && key !== centerKey && !centerOrder.has(key)) + .map(([key, node]) => ({ key, node })); + } - relations.forEach((node, index) => { - const key = normKey(node?.id); - if (!key || centerOrder.has(key) || latestPlacementParent.has(key)) return; - const preferred = placements[index] || { radius: HISTORY_ORBIT_FIRST_R, angle: 0 }; + function tryPlaceCluster(snapIndex, center, baseOccupied, commit = false) { + const snap = snapshots[snapIndex]; + const centerKey = normKey(snap?.centerLogin); + const localPlaced = new Map([[centerKey, { ...center }]]); + const rows = baseOccupied.map((row) => ({ ...row })); + if (collides(center.x, center.y, HISTORY_CENTER_CLEARANCE, rows, centerKey)) { + return { ok: false, maxDirectShell: Infinity, idealDirectShell: 0, placed: localPlaced, rows }; + } + rows.push({ key: centerKey, x: center.x, y: center.y, clearance: HISTORY_CENTER_CLEARANCE }); + + const owned = ownedNodesForSnapshot(snapIndex); + const direct = owned.filter(({ node }) => (Number(node?.tier) || 1) < 2); + const deep = owned.filter(({ node }) => (Number(node?.tier) || 1) >= 2); + const directSlots = historyCompactSlots(Math.max(direct.length + 24, 48), `${centerKey}|direct`, HISTORY_DIRECT_SPACING); + let maxDirectShell = 0; + + direct.forEach(({ key, node }) => { let chosen = null; - // Сначала пробуем желаемую орбиту, затем соседние углы, после чего постепенно расширяем радиус. - for (let radialStep = 0; radialStep < 12 && !chosen; radialStep += 1) { - const radius = preferred.radius + radialStep * HISTORY_ORBIT_GAP * 0.55; - const angularSamples = 18 + radialStep * 2; - for (let step = 0; step < angularSamples; step += 1) { - const offsetIndex = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? 1 : -1); - const angle = preferred.angle + offsetIndex * (Math.PI * 2 / angularSamples); - const x = base.x + Math.cos(angle) * radius; - const y = base.y + Math.sin(angle) * radius; - if (!collidesAt(key, x, y, HISTORY_NODE_CLEARANCE)) { - chosen = { x, y }; - break; - } + const clearance = clearanceForNode(node); + for (const slot of directSlots) { + const x = center.x + slot.x; + const y = center.y + slot.y; + if (!collides(x, y, clearance, rows, key)) { + chosen = { x, y, shell: slot.shell }; + break; } } if (!chosen) { - const radius = preferred.radius + 12 * HISTORY_ORBIT_GAP * 0.55; - chosen = { - x: base.x + Math.cos(preferred.angle) * radius, - y: base.y + Math.sin(preferred.angle) * radius, - }; + const angle = historyHash01(`${centerKey}|${key}|fallback`) * Math.PI * 2; + const radius = HISTORY_DIRECT_SPACING * (HISTORY_LAYOUT_MAX_SHELL + 1); + chosen = { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius, shell: HISTORY_LAYOUT_MAX_SHELL + 1 }; } - latestPlacementParent.set(key, { centerKey, ...chosen, snapIndex }); - occupied.push({ key, ...chosen, clearance: HISTORY_NODE_CLEARANCE }); + maxDirectShell = Math.max(maxDirectShell, chosen.shell || 0); + localPlaced.set(key, chosen); + rows.push({ key, x: chosen.x, y: chosen.y, clearance }); }); + + const deepByParent = new Map(); + deep.forEach((row) => { + const parentKey = normKey(row.node?.parentId || row.node?.edgeParents?.[0]?.id || centerKey); + const list = deepByParent.get(parentKey) || []; + list.push(row); + deepByParent.set(parentKey, list); + }); + for (const [parentKey, children] of deepByParent.entries()) { + const parentPos = localPlaced.get(parentKey) || placed.get(parentKey) || center; + const slots = historyCompactSlots(Math.max(children.length + 16, 32), `${centerKey}|${parentKey}|deep`, HISTORY_TIER2_SPACING, 6); + children.forEach(({ key, node }) => { + let chosen = null; + const clearance = clearanceForNode(node); + for (const slot of slots) { + const x = parentPos.x + slot.x; + const y = parentPos.y + slot.y; + if (!collides(x, y, clearance, rows, key)) { + chosen = { x, y, shell: slot.shell }; + break; + } + } + if (!chosen) { + const angle = historyHash01(`${parentKey}|${key}|deep-fallback`) * Math.PI * 2; + const radius = HISTORY_TIER2_SPACING * 7; + chosen = { x: parentPos.x + Math.cos(angle) * radius, y: parentPos.y + Math.sin(angle) * radius, shell: 7 }; + } + localPlaced.set(key, chosen); + rows.push({ key, x: chosen.x, y: chosen.y, clearance }); + }); + } + + const idealDirectShell = historyIdealShell(direct.length); + const ok = maxDirectShell <= Math.max(1, idealDirectShell + 1); + if (commit) { + localPlaced.forEach((pos, key) => placed.set(key, { x: pos.x, y: pos.y })); + occupied.splice(0, occupied.length, ...rows); + } + return { ok, maxDirectShell, idealDirectShell, placed: localPlaced, rows }; } - for (const [centerKey, pos] of centerPos.entries()) { - const node = nodeMap.get(centerKey); - if (!node) continue; - nodeMap.set(centerKey, { - ...node, - layoutX: pos.x, - layoutY: pos.y, - fixedLayout: true, - keepVisible: true, - isHistoryCenter: true, + // Текущий центр всегда в (0,0). Сначала укладываем его актуальный круг максимально компактно. + const latestIndex = snapshots.length - 1; + tryPlaceCluster(latestIndex, { x: 0, y: 0 }, occupied, true); + + // Предыдущие центры добавляем назад по истории. Мы НЕ держим большой фиксированный минимум: + // сначала пробуем близкую посадку в нескольких направлениях вокруг исходного перехода и выбираем + // наиболее компактную общую форму карты. Только если ни один вариант не помещается — увеличиваем gap. + // Так 3–4 небольших круга складываются скорее в облако/квадрат, а не в длинную цепочку. + const angleOffsets = [0, Math.PI / 8, -Math.PI / 8, Math.PI / 4, -Math.PI / 4, Math.PI / 2, -Math.PI / 2]; + const compactnessScore = (rows, desiredAngleOffset = 0) => { + if (!rows.length) return 0; + let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; + rows.forEach((row) => { + const c = Number(row.clearance) || HISTORY_NODE_CLEARANCE; + minX = Math.min(minX, row.x - c * 0.5); + maxX = Math.max(maxX, row.x + c * 0.5); + minY = Math.min(minY, row.y - c * 0.5); + maxY = Math.max(maxY, row.y + c * 0.5); }); + const width = Math.max(1, maxX - minX); + const height = Math.max(1, maxY - minY); + const area = width * height; + const aspectPenalty = Math.abs(Math.log(width / height)); + // Площадь важнее всего; небольшой штраф за вытянутый прямоугольник и сильный уход + // от направления фактического клика сохраняет ощущение истории, но не ценой огромных пустот. + return area * (1 + aspectPenalty * 0.22) + Math.abs(desiredAngleOffset) * 2600; + }; + + for (let snapIndex = latestIndex - 1; snapIndex >= 0; snapIndex -= 1) { + const newer = snapshots[snapIndex + 1]; + const newerKey = normKey(newer?.centerLogin); + const newerPos = placed.get(newerKey) || { x: 0, y: 0 }; + const baseAngle = Number.isFinite(Number(newer?.transitionAngle)) ? Number(newer.transitionAngle) : 0; + let gap = HISTORY_CENTER_GAP_START; + let best = null; + + for (let attempt = 0; attempt < 40; attempt += 1) { + const candidates = []; + angleOffsets.forEach((offset) => { + const angle = baseAngle + offset; + const candidate = { + x: newerPos.x - Math.cos(angle) * gap, + y: newerPos.y - Math.sin(angle) * gap, + }; + const trial = tryPlaceCluster(snapIndex, candidate, occupied, false); + if (!trial.ok) return; + candidates.push({ + candidate, + offset, + score: compactnessScore(trial.rows, offset), + }); + }); + if (candidates.length) { + candidates.sort((a, b) => a.score - b.score); + best = candidates[0]; + break; + } + gap += HISTORY_CENTER_GAP_STEP; + } + + const fallbackAngle = baseAngle + (snapIndex % 2 === 0 ? Math.PI / 8 : -Math.PI / 8); + const candidate = best?.candidate || { + x: newerPos.x - Math.cos(fallbackAngle) * gap, + y: newerPos.y - Math.sin(fallbackAngle) * gap, + }; + tryPlaceCluster(snapIndex, candidate, occupied, true); } - for (const [key, placement] of latestPlacementParent.entries()) { - const node = nodeMap.get(key); - if (!node || centerOrder.has(key)) continue; - nodeMap.set(key, { - ...node, - layoutX: placement.x, - layoutY: placement.y, - fixedLayout: true, - parentId: placement.centerKey, - }); - } + // На случай центра без периферии, который не попал в occupied из-за повреждённого snapshot. + snapshots.forEach((snap) => { + const key = normKey(snap?.centerLogin); + if (!placed.has(key)) placed.set(key, { x: 0, y: 0 }); + }); const edgeParentsByChild = new Map(); const addEdgeParent = (childKey, parentKey, edge) => { if (!childKey || !parentKey || childKey === parentKey) return; const list = edgeParentsByChild.get(childKey) || []; - if (!list.some((row) => row.id === parentKey)) { - list.push({ - id: parentKey, - relationType: edge.relationType, - strength: edge.strength, - shining: edge.shining, - }); + if (!list.some((row) => normKey(row?.id) === parentKey)) { + list.push({ id: parentKey, relationType: edge.relationType, strength: edge.strength }); } edgeParentsByChild.set(childKey, list); }; - for (const edge of edgeMap.values()) { - const aCenterIndex = centerOrder.get(edge.a); - const bCenterIndex = centerOrder.get(edge.b); - if (aCenterIndex !== undefined && bCenterIndex !== undefined) { - if (aCenterIndex > bCenterIndex) addEdgeParent(edge.a, edge.b, edge); - else addEdgeParent(edge.b, edge.a, edge); - continue; - } - if (aCenterIndex !== undefined) addEdgeParent(edge.b, edge.a, edge); - else if (bCenterIndex !== undefined) addEdgeParent(edge.a, edge.b, edge); + if (!nodeMap.has(edge.parent) || !nodeMap.has(edge.child)) continue; + addEdgeParent(edge.child, edge.parent, edge); } - const nodes = [...nodeMap.entries()].map(([key, node]) => ({ - ...node, - id: key, - login: node.login || node.id || key, - tier: 1, - fixedLayout: true, - edgeParents: edgeParentsByChild.get(key) || [], - })); + const nodes = [...nodeMap.entries()].map(([key, node]) => { + const pos = placed.get(key) || { x: 0, y: 0 }; + const isCenter = centerOrder.has(key); + return { + ...node, + id: key, + login: node.login || node.id || key, + tier: isCenter ? 1 : Math.max(1, Number(node?.tier) || 1), + layoutX: pos.x, + layoutY: pos.y, + fixedLayout: true, + keepVisible: isCenter || Boolean(node.keepVisible), + alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), + edgeParents: edgeParentsByChild.get(key) || [], + }; + }); return { focusId: normKey(latest.engineModel.focusId), @@ -486,8 +680,13 @@ export function render({ navigate, route, chrome } = {}) { let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || ''); let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : []; let graphHistory = Array.isArray(persistedGraphHistory) ? [...persistedGraphHistory] : []; + let historyDepth = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Number(persistedHistoryDepth) || 0)); + let x2Enabled = Boolean(persistedX2Enabled); let engine = null; let loadSeq = 0; + const graphCache = new Map(); + let historyChip = null; + let x2Chip = null; // Независимые фильтры карты. Оба выключены = показываем всё. // Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей. @@ -528,6 +727,48 @@ export function render({ navigate, route, chrome } = {}) { persistedCenterLogin = centerLogin; persistedCenterHistory = [...centerHistory]; persistedGraphHistory = [...graphHistory]; + persistedHistoryDepth = historyDepth; + persistedX2Enabled = x2Enabled; + } + + function rebuildEngineFromHistory() { + const engineModel = buildHistoryEngineModel(graphHistory, historyDepth); + ensureEngine(engineModel); + if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate); + } + + function updateHistoryChip() { + if (!(historyChip instanceof HTMLButtonElement)) return; + historyChip.textContent = historyDepth > 0 ? `История ${historyDepth}` : 'История'; + historyChip.classList.toggle('is-active', historyDepth > 0); + historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false'); + historyChip.title = historyDepth > 0 + ? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.` + : 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.'; + } + + function cycleHistoryDepth() { + historyDepth = historyDepth >= HISTORY_MAX_PREVIOUS ? 0 : historyDepth + 1; + const maxSnapshots = historyDepth + 1; + graphHistory = historyDepth > 0 ? graphHistory.slice(-maxSnapshots) : graphHistory.slice(-1); + centerHistory = historyDepth > 0 ? centerHistory.slice(-historyDepth) : []; + updateHistoryChip(); + rebuildEngineFromHistory(); + persistHistory(); + } + + function updateX2Chip() { + if (!(x2Chip instanceof HTMLButtonElement)) return; + x2Chip.classList.toggle('is-active', x2Enabled); + x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false'); + x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.'; + } + + async function toggleX2() { + x2Enabled = !x2Enabled; + updateX2Chip(); + persistedX2Enabled = x2Enabled; + await load(centerLogin, { pushHistory: false }); } function syncLinksUrl(login, { push = false } = {}) { @@ -699,6 +940,7 @@ export function render({ navigate, route, chrome } = {}) { try { const graph = await authService.getUserConnectionsGraph(targetCenter); + graphCache.set(normKey(targetCenter), graph); if (requestId !== loadSeq) return; centerLogin = targetCenter; if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { @@ -707,7 +949,18 @@ export function render({ navigate, route, chrome } = {}) { syncLinksUrl(targetCenter, { push: pushHistory }); const graphModel = buildGraphModel(graph, targetCenter); - const snapshotModel = engineModelFromGraphModel(graphModel); + let snapshotModel = engineModelFromGraphModel(graphModel); + if (x2Enabled) { + snapshotModel = await buildSecondLevelEngineModel(snapshotModel, async (login) => { + const key = normKey(login); + if (graphCache.has(key)) return graphCache.get(key); + const childGraph = await authService.getUserConnectionsGraph(login); + graphCache.set(key, childGraph); + return childGraph; + }); + if (requestId !== loadSeq) return; + } + const snapshot = { centerLogin: targetCenter, engineModel: snapshotModel, @@ -715,14 +968,14 @@ export function render({ navigate, route, chrome } = {}) { }; if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { - // Один и тот же пользователь не хранится как два исторических центра: повторный переход - // переносит его в конец истории, где он становится актуальным центром. + // X2 относится только к ТЕКУЩЕМУ центру. Как только центр уходит в историю, оставляем его + // первый уровень — иначе четыре исторических слоя с друзьями друзей быстро превратятся в кашу. + graphHistory = graphHistory.map((row) => ({ ...row, engineModel: stripSecondLevel(row.engineModel) })); graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter)); graphHistory.push(snapshot); - if (graphHistory.length > HISTORY_MAX_CENTERS) { - graphHistory = graphHistory.slice(-HISTORY_MAX_CENTERS); - centerHistory = centerHistory.slice(-(HISTORY_MAX_CENTERS - 1)); - } + const maxSnapshots = historyDepth + 1; + graphHistory = historyDepth > 0 ? graphHistory.slice(-maxSnapshots) : graphHistory.slice(-1); + centerHistory = historyDepth > 0 ? centerHistory.slice(-historyDepth) : []; } else { const last = graphHistory[graphHistory.length - 1]; if (last && normKey(last.centerLogin) === normKey(targetCenter)) { @@ -733,10 +986,7 @@ export function render({ navigate, route, chrome } = {}) { } } - const engineModel = buildHistoryEngineModel(graphHistory); - ensureEngine(engineModel); - if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate); - + rebuildEngineFromHistory(); persistHistory(); } catch (error) { if (requestId !== loadSeq) return; @@ -810,6 +1060,21 @@ export function render({ navigate, route, chrome } = {}) { filterBar.append(chip); }); + historyChip = document.createElement('button'); + historyChip.type = 'button'; + historyChip.className = 'fg-filter-chip fg-history-chip'; + historyChip.addEventListener('click', cycleHistoryDepth); + filterBar.append(historyChip); + updateHistoryChip(); + + x2Chip = document.createElement('button'); + x2Chip.type = 'button'; + x2Chip.className = 'fg-filter-chip fg-x2-chip'; + x2Chip.textContent = 'X2'; + x2Chip.addEventListener('click', () => { void toggleX2(); }); + filterBar.append(x2Chip); + updateX2Chip(); + chrome?.setTopbar(header); stage.append(board, filterBar); screen.append(stage); diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index edd46228..0daed44a 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -471,6 +471,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL edgeParents: Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [], fixedLayout, keepVisible: Boolean(src.keepVisible), + alwaysVisible: Boolean(src.alwaysVisible), official: Boolean(src.official), deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2, track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко @@ -530,7 +531,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png // на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного). const GLASS_OVERLAY_SRC = '/assets/glass_overlay_faithful.png'; - const OFFICIAL_BADGE_SRC = '/assets/shine-official-badge.svg'; + const OFFICIAL_BADGE_SRC = '/assets/shine-official-badge.svg?v=2'; function buildPngOrb(src, opts) { const o = opts || {}; const wrap = document.createElement('div'); @@ -657,6 +658,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL node.shining = Boolean(src.shining); node.official = Boolean(src.official); node.keepVisible = Boolean(src.keepVisible); + node.alwaysVisible = Boolean(src.alwaysVisible); node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : []; const layoutX = Number(src.layoutX); const layoutY = Number(src.layoutY); @@ -772,6 +774,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL for (const tier of [2, 3]) { for (const n of nodes) { if (n.tier !== tier) continue; + // Реальный X2 из network-view приходит уже с collision-aware фиксированной позицией и должен + // быть виден сразу, без клика/hover по родителю. Лабораторные deep-ветки без fixedLayout + // продолжают работать по старой схеме раскрытия expandP. + if (n.fixedLayout && n.alwaysVisible) { + n.x = n.tx; + n.y = n.ty; + const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY; + const baseSc = tier === 2 ? DEEP2_SCALE : (n.lod === 'full' ? 0.42 : 1); + n.opacity = n.hidden ? 0 : baseOp; + n.scale = baseSc; + n.targetOpacity = n.opacity; + n.targetScale = n.scale; + continue; + } const p = nodeById.get(n.parentId); if (!p) { n.opacity = 0; continue; } const e = p.expandP || 0; // насколько раскрыт родитель @@ -1032,7 +1048,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2; dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`; } - const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми) + const pe = n.alwaysVisible ? 1 : (parent.expandP || 0); // X2 виден сразу; старые deep-ветки — по раскрытию if (n.tier >= 3) { // 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро). if (pe > 0.02) { diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 4736a2a6..3a6c95da 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -203,17 +203,24 @@ /* Маленький знак официального пользователя. Он находится внутри .node-dot, поэтому автоматически масштабируется вместе с аватаркой при focus/zoom/анимациях графа. */ -.fg-official-badge { +/* ВАЖНО: селектор намеренно специфичнее глобального `.node-dot img` из features/network.css, + где обычные фото стартуют с opacity:0 и width/height:100%. Иначе badge тоже наследует эти + правила и становится полностью невидимым. */ +.fg-node .node-dot .fg-official-badge { position: absolute; left: -2%; - bottom: 0; - width: 15%; - height: 15%; - min-width: 8px; - min-height: 8px; + bottom: -1%; + width: 16%; + height: 16%; + min-width: 9px; + min-height: 9px; object-fit: contain; + object-position: center; display: block; - z-index: 5; + opacity: 1; + border-radius: 0; + transition: none; + z-index: 20; pointer-events: none; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42)); } @@ -452,6 +459,20 @@ box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(110, 210, 255, 0.28); } +/* Служебные переключатели карты: история и X2 намеренно чуть компактнее основных фильтров. */ +.fg-history-chip { + padding-left: 11px; + padding-right: 11px; +} + +.fg-x2-chip { + min-width: 36px; + padding-left: 9px; + padding-right: 9px; + font-weight: 750; + letter-spacing: 0.02em; +} + /* Контекстное меню узла (долгое нажатие) — в #modal-root, поверх всего, не масштабируется */ .fg-menu-overlay { position: fixed; From ea0098d7053237fce9d2254f8774b73f0978430d623600e6e73671ee71ca1ca3 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 11 Sep 2026 00:43:39 +0300 Subject: [PATCH 14/30] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=8C=20=D0=B3=D1=80=D0=B0=D1=84=20=D1=81?= =?UTF-8?q?=D0=B2=D1=8F=D0=B7=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/pages/network-view.js | 885 +++++++++++++++-------- shine-UI/js/pages/network/force-graph.js | 45 +- 3 files changed, 616 insertions(+), 316 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index a41defa7..39756fc9 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.13 +client.version=1.12.14 server.version=1.10.4 diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 1cfd0983..44018ee2 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -212,7 +212,6 @@ let persistedHistoryDepth = 4; let persistedX2Enabled = false; const HISTORY_MAX_PREVIOUS = 4; -const HISTORY_CENTER_GAP_START = 108; const HISTORY_CENTER_GAP_STEP = 28; const HISTORY_CENTER_CLEARANCE = 88; const HISTORY_NODE_CLEARANCE = 82; @@ -294,14 +293,6 @@ function historyCompactSlots(total, seed = '', spacing = HISTORY_DIRECT_SPACING, return out; } -function historyIdealShell(count) { - const n = Math.max(0, Math.trunc(Number(count) || 0)); - if (!n) return 0; - let shell = 1; - while (4 * shell * (shell + 1) < n) shell += 1; - return shell; -} - function relationEdge(parentId, node) { return { id: normKey(parentId), @@ -310,21 +301,6 @@ function relationEdge(parentId, node) { }; } -function stripSecondLevel(model) { - const nodes = Array.isArray(model?.nodes) ? model.nodes : []; - const keep = new Set(nodes.filter((node) => (Number(node?.tier) || 1) < 2).map((node) => normKey(node?.id))); - return { - ...model, - nodes: nodes - .filter((node) => keep.has(normKey(node?.id))) - .map((node) => ({ - ...node, - edgeParents: (Array.isArray(node?.edgeParents) ? node.edgeParents : []) - .filter((edge) => keep.has(normKey(edge?.id))), - })), - }; -} - async function mapWithConcurrency(items, concurrency, worker) { const list = Array.isArray(items) ? items : []; const out = new Array(list.length); @@ -343,12 +319,16 @@ async function mapWithConcurrency(items, concurrency, worker) { async function buildSecondLevelEngineModel(baseModel, getGraph) { const focusKey = normKey(baseModel?.focusId); const baseNodes = Array.isArray(baseModel?.nodes) ? baseModel.nodes : []; - const byKey = new Map(); + // ФАЗА 1. Сначала фиксируем ПОЛНЫЙ первый уровень центрального пользователя. + // Его уровень больше никогда не зависит от того, в каком из ответов друзей он встретится позднее. + const byKey = new Map(); + const firstLevelKeys = new Set(); baseNodes.forEach((src) => { const key = normKey(src?.id); if (!key) return; const isFocus = key === focusKey; + if (!isFocus && src?.relationType !== 'friend' && src?.relationType !== 'close_friend') return; const normalized = { ...src, id: key, @@ -358,83 +338,385 @@ async function buildSecondLevelEngineModel(baseModel, getGraph) { edgeParents: isFocus ? [] : [relationEdge(focusKey, src)], }; byKey.set(key, normalized); + if (!isFocus) firstLevelKeys.add(key); }); - const directFriends = [...byKey.values()].filter((node) => ( - normKey(node.id) !== focusKey - && (node.relationType === 'friend' || node.relationType === 'close_friend') - )); + const directFriends = [...firstLevelKeys] + .map((key) => byKey.get(key)) + .filter(Boolean); + // ФАЗА 2. Запрашиваем КАЖДОГО друга первого уровня. Пока все ответы не получены, + // структуру X2 не достраиваем и в движок ничего не отдаём. const fetched = await mapWithConcurrency(directFriends, 4, async (parent) => { const graph = await getGraph(parent.login || parent.id); const graphModel = buildGraphModel(graph, parent.login || parent.id); return { parent, model: engineModelFromGraphModel(graphModel) }; }); + const failedParents = fetched + .map((row, index) => row?.error ? directFriends[index] : null) + .filter(Boolean); + if (failedParents.length) { + const sample = failedParents.slice(0, 3).map((node) => node.login || node.id).join(', '); + throw new Error(`X2: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`); + } + + // ФАЗА 3. Из уже полностью полученных графов собираем кандидатов второго уровня и все рёбра. + // Сначала накапливаем, потом одним проходом присваиваем глубину. depth=1 всегда приоритетнее depth=2. + const secondCandidates = new Map(); + const edgesByChild = new Map(); fetched.forEach((row) => { - if (!row || row.error || !row.model) return; + if (!row || !row.model) return; const parentKey = normKey(row.parent?.id); const childNodes = Array.isArray(row.model.nodes) ? row.model.nodes : []; childNodes.forEach((child) => { const childKey = normKey(child?.id); if (!childKey || childKey === parentKey || childKey === focusKey) return; if (child.relationType !== 'friend' && child.relationType !== 'close_friend') return; - const edge = relationEdge(parentKey, child); - const existing = byKey.get(childKey); - if (existing) { - const refs = Array.isArray(existing.edgeParents) ? [...existing.edgeParents] : []; - if (!refs.some((ref) => normKey(ref?.id) === parentKey)) refs.push(edge); - byKey.set(childKey, { ...existing, edgeParents: refs }); - return; - } - byKey.set(childKey, { - ...child, - id: childKey, - login: child?.login || child?.id || childKey, - tier: 2, - parentId: parentKey, - alwaysVisible: true, - edgeParents: [edge], - }); + + const refs = edgesByChild.get(childKey) || []; + if (!refs.some((ref) => normKey(ref?.id) === parentKey)) refs.push(relationEdge(parentKey, child)); + edgesByChild.set(childKey, refs); + + // Если это прямой друг центра, его не переносим на второй уровень — только добавляем новое ребро. + if (firstLevelKeys.has(childKey)) return; + if (!secondCandidates.has(childKey)) secondCandidates.set(childKey, child); + }); + }); + + // ФАЗА 4. Только теперь собираем окончательные узлы. + firstLevelKeys.forEach((key) => { + const existing = byKey.get(key); + if (!existing) return; + const extra = edgesByChild.get(key) || []; + const refs = [...(Array.isArray(existing.edgeParents) ? existing.edgeParents : [])]; + extra.forEach((edge) => { + if (!refs.some((ref) => normKey(ref?.id) === normKey(edge?.id))) refs.push(edge); + }); + byKey.set(key, { ...existing, tier: 1, edgeParents: refs }); + }); + + secondCandidates.forEach((child, childKey) => { + const refs = edgesByChild.get(childKey) || []; + const parentKey = normKey(refs[0]?.id); + byKey.set(childKey, { + ...child, + id: childKey, + login: child?.login || child?.id || childKey, + tier: 2, + parentId: parentKey, + alwaysVisible: true, + edgeParents: refs, }); }); return { ...baseModel, nodes: [...byKey.values()] }; } -function buildHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVIOUS) { - const keepPrevious = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Math.trunc(Number(historyDepth) || 0))); - const maxSnapshots = keepPrevious + 1; - const snapshots = (Array.isArray(history) ? history : []).slice(-maxSnapshots); - const latest = snapshots[snapshots.length - 1]; - if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] }; +const NETWORK_GRAPH_CACHE_TTL_MS = 2 * 60 * 1000; +const NETWORK_GRAPH_CACHE_MAX = 240; +const networkGraphCache = new Map(); +const networkGraphInflight = new Map(); - const centerOrder = new Map(); +async function getConnectionsGraphCached(login, { force = false, retries = 0 } = {}) { + const clean = normalizeLogin(login); + const key = normKey(clean); + if (!key) throw new Error('LOGIN_REQUIRED'); + const now = Date.now(); + const cached = networkGraphCache.get(key); + if (!force && cached && now - cached.savedAt < NETWORK_GRAPH_CACHE_TTL_MS) return cached.graph; + if (!force && networkGraphInflight.has(key)) return networkGraphInflight.get(key); + + const request = (async () => { + let lastError = null; + for (let attempt = 0; attempt <= Math.max(0, retries); attempt += 1) { + try { + const graph = await authService.getUserConnectionsGraph(clean); + networkGraphCache.delete(key); + networkGraphCache.set(key, { graph, savedAt: Date.now() }); + while (networkGraphCache.size > NETWORK_GRAPH_CACHE_MAX) { + const oldestKey = networkGraphCache.keys().next().value; + if (!oldestKey) break; + networkGraphCache.delete(oldestKey); + } + return graph; + } catch (error) { + lastError = error; + if (attempt < retries) await new Promise((resolve) => window.setTimeout(resolve, 120 * (attempt + 1))); + } + } + throw lastError || new Error('GRAPH_LOAD_FAILED'); + })(); + + networkGraphInflight.set(key, request); + try { + return await request; + } finally { + if (networkGraphInflight.get(key) === request) networkGraphInflight.delete(key); + } +} + +function historyNodeClearance(node) { + return (Number(node?.tier) || 1) >= 2 ? HISTORY_TIER2_CLEARANCE : HISTORY_NODE_CLEARANCE; +} + +function cloneEngineModel(model) { + return { + ...model, + nodes: (Array.isArray(model?.nodes) ? model.nodes : []).map((node) => ({ + ...node, + edgeParents: (Array.isArray(node?.edgeParents) ? node.edgeParents : []).map((edge) => ({ ...edge })), + })), + }; +} + +function modelNodePositions(model) { + const out = new Map(); + (Array.isArray(model?.nodes) ? model.nodes : []).forEach((node) => { + const key = normKey(node?.id); + const x = Number(node?.layoutX); + const y = Number(node?.layoutY); + if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return; + out.set(key, { x, y, clearance: historyNodeClearance(node), node }); + }); + return out; +} + +function translateSnapshot(snapshot, dx, dy) { + const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] }); + model.nodes = model.nodes.map((node) => { + const x = Number(node?.layoutX); + const y = Number(node?.layoutY); + if (!Number.isFinite(x) || !Number.isFinite(y)) return node; + return { ...node, layoutX: x + dx, layoutY: y + dy, fixedLayout: true }; + }); + return { ...snapshot, engineModel: model }; +} + +function placeCompactNodes(nodes, { + center = { x: 0, y: 0 }, + seed = '', + spacing = HISTORY_DIRECT_SPACING, + maxShell = HISTORY_LAYOUT_MAX_SHELL, + occupied = [], +} = {}) { + const rows = occupied.map((row) => ({ ...row })); + const positions = new Map(); + const slots = historyCompactSlots(Math.max(nodes.length + 48, 96), seed, spacing, maxShell); + + nodes.forEach((node, index) => { + const key = normKey(node?.id); + if (!key) return; + const clearance = historyNodeClearance(node); + let chosen = null; + for (const slot of slots) { + const x = center.x + slot.x; + const y = center.y + slot.y; + const collision = rows.some((row) => Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0)); + if (!collision) { + chosen = { x, y }; + break; + } + } + if (!chosen) { + const angle = historyHash01(`${seed}|${key}|fallback`) * Math.PI * 2; + const radius = spacing * (maxShell + 1 + Math.floor(index / 8)); + chosen = { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius }; + } + positions.set(key, chosen); + rows.push({ key, x: chosen.x, y: chosen.y, clearance }); + }); + + return { positions, occupied: rows }; +} + +function layoutFirstLevelEngineModel(model, seed = '', { skipKeys = new Set() } = {}) { + const out = cloneEngineModel(model); + const focusKey = normKey(out?.focusId); + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const focus = nodes.find((node) => normKey(node?.id) === focusKey); + const peers = nodes.filter((node) => { + const key = normKey(node?.id); + return key !== focusKey && !skipKeys.has(key) && (Number(node?.tier) || 1) < 2; + }); + const occupied = [{ key: focusKey, x: 0, y: 0, clearance: HISTORY_CENTER_CLEARANCE }]; + const placed = placeCompactNodes(peers, { center: { x: 0, y: 0 }, seed: `${seed}|tier1`, occupied }); + + out.nodes = nodes.map((node) => { + const key = normKey(node?.id); + if (key === focusKey) { + return { ...node, id: key, layoutX: 0, layoutY: 0, fixedLayout: true, tier: 1, keepVisible: true }; + } + if (skipKeys.has(key)) { + const clean = { ...node, id: key, parentId: normKey(node?.parentId || focusKey) }; + delete clean.layoutX; + delete clean.layoutY; + clean.fixedLayout = false; + return clean; + } + const pos = placed.positions.get(key) || { x: 0, y: 0 }; + return { + ...node, + id: key, + parentId: normKey(node?.parentId || focusKey), + layoutX: pos.x, + layoutY: pos.y, + fixedLayout: true, + }; + }); + return out; +} + +function layoutX2EngineModel(model, seed = '') { + const out = cloneEngineModel(model); + const focusKey = normKey(out?.focusId); + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const direct = nodes.filter((node) => normKey(node?.id) !== focusKey && (Number(node?.tier) || 1) === 1); + const deep = nodes.filter((node) => (Number(node?.tier) || 1) >= 2); + const childCount = new Map(); + deep.forEach((node) => { + const parentKey = normKey(node?.parentId || node?.edgeParents?.[0]?.id); + if (!parentKey) return; + childCount.set(parentKey, (childCount.get(parentKey) || 0) + 1); + }); + + const positions = new Map([[focusKey, { x: 0, y: 0 }]]); + const zones = [{ key: focusKey, x: 0, y: 0, radius: 72 }]; + const candidates = historyCompactSlots(Math.max(240, direct.length * 14), `${seed}|x2parents`, 92, 18); + + direct.forEach((node, index) => { + const key = normKey(node?.id); + const count = childCount.get(key) || 0; + const zoneRadius = Math.max(54, 48 + Math.ceil(Math.sqrt(count)) * 24); + let chosen = null; + for (const slot of candidates) { + const x = slot.x; + const y = slot.y; + const collision = zones.some((zone) => Math.hypot(x - zone.x, y - zone.y) < zoneRadius + zone.radius + 10); + if (!collision) { chosen = { x, y }; break; } + } + if (!chosen) { + const angle = historyHash01(`${seed}|x2parent|${key}`) * Math.PI * 2; + const radius = 180 + index * 34; + chosen = { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }; + } + positions.set(key, chosen); + zones.push({ key, x: chosen.x, y: chosen.y, radius: zoneRadius }); + }); + + const occupied = [{ key: focusKey, x: 0, y: 0, clearance: HISTORY_CENTER_CLEARANCE }]; + direct.forEach((node) => { + const key = normKey(node?.id); + const pos = positions.get(key); + if (pos) occupied.push({ key, x: pos.x, y: pos.y, clearance: HISTORY_NODE_CLEARANCE }); + }); + + const deepByParent = new Map(); + deep.forEach((node) => { + const parentKey = normKey(node?.parentId || node?.edgeParents?.[0]?.id || focusKey); + const rows = deepByParent.get(parentKey) || []; + rows.push(node); + deepByParent.set(parentKey, rows); + }); + + for (const [parentKey, children] of deepByParent.entries()) { + const parentPos = positions.get(parentKey) || { x: 0, y: 0 }; + const placed = placeCompactNodes(children, { + center: parentPos, + seed: `${seed}|x2children|${parentKey}`, + spacing: HISTORY_TIER2_SPACING, + maxShell: 12, + occupied, + }); + placed.positions.forEach((pos, key) => positions.set(key, pos)); + occupied.splice(0, occupied.length, ...placed.occupied); + } + + out.nodes = nodes.map((node) => { + const key = normKey(node?.id); + const pos = positions.get(key) || { x: 0, y: 0 }; + return { + ...node, + id: key, + tier: key === focusKey ? 1 : Math.max(1, Number(node?.tier) || 1), + layoutX: pos.x, + layoutY: pos.y, + fixedLayout: true, + keepVisible: key === focusKey || Boolean(node?.keepVisible), + alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), + }; + }); + return out; +} + +function refreshFirstLevelSnapshot(previousSnapshot, nextEngineModel, seed = '') { + const previousPositions = modelNodePositions(previousSnapshot?.engineModel); + if (!previousPositions.size) return layoutFirstLevelEngineModel(nextEngineModel, seed); + + const out = cloneEngineModel(nextEngineModel); + const focusKey = normKey(out?.focusId); + const keepKeys = new Set((Array.isArray(out.nodes) ? out.nodes : []).map((node) => normKey(node?.id)).filter(Boolean)); + const occupied = []; + const positions = new Map(); + + previousPositions.forEach((row, key) => { + if (!keepKeys.has(key)) return; + positions.set(key, { x: row.x, y: row.y }); + occupied.push({ key, x: row.x, y: row.y, clearance: row.clearance || HISTORY_NODE_CLEARANCE }); + }); + if (!positions.has(focusKey)) { + positions.set(focusKey, { x: 0, y: 0 }); + occupied.push({ key: focusKey, x: 0, y: 0, clearance: HISTORY_CENTER_CLEARANCE }); + } + + const newNodes = (Array.isArray(out.nodes) ? out.nodes : []).filter((node) => { + const key = normKey(node?.id); + return key && key !== focusKey && !positions.has(key) && (Number(node?.tier) || 1) < 2; + }); + const placed = placeCompactNodes(newNodes, { center: positions.get(focusKey) || { x: 0, y: 0 }, seed: `${seed}|refresh`, occupied }); + placed.positions.forEach((pos, key) => positions.set(key, pos)); + + out.nodes = out.nodes.map((node) => { + const key = normKey(node?.id); + const pos = positions.get(key) || { x: 0, y: 0 }; + return { + ...node, + id: key, + parentId: key === focusKey ? '' : normKey(node?.parentId || focusKey), + layoutX: pos.x, + layoutY: pos.y, + fixedLayout: true, + tier: key === focusKey ? 1 : Math.max(1, Number(node?.tier) || 1), + keepVisible: key === focusKey || Boolean(node?.keepVisible), + }; + }); + return out; +} + +function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVIOUS) { + const keepPrevious = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Math.trunc(Number(historyDepth) || 0))); + const snapshots = (Array.isArray(history) ? history : []).slice(-(keepPrevious + 1)); + const latest = snapshots[snapshots.length - 1]; + if (!latest?.engineModel) return { focusId: '', nodes: [] }; + + const centerKeys = new Set(snapshots.map((snap) => normKey(snap?.centerLogin)).filter(Boolean)); + const latestCenterKey = normKey(latest.centerLogin); + const centerNodeByKey = new Map(); const nodeMap = new Map(); - const latestOwner = new Map(); const edgeMap = new Map(); - snapshots.forEach((snap, snapIndex) => { + snapshots.forEach((snap) => { const centerKey = normKey(snap?.centerLogin); - if (!centerKey) return; - centerOrder.set(centerKey, snapIndex); - const modelNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : []; - modelNodes.forEach((rawNode) => { + const snapNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : []; + const ownCenter = snapNodes.find((node) => normKey(node?.id) === centerKey); + if (ownCenter) centerNodeByKey.set(centerKey, { ...ownCenter, isHistoryCenter: true, keepVisible: true, tier: 1 }); + + snapNodes.forEach((rawNode) => { const key = normKey(rawNode?.id); if (!key) return; - const node = { - ...nodeMap.get(key), - ...rawNode, - id: key, - login: rawNode?.login || rawNode?.id || key, - }; - if (key === centerKey) { - node.tier = 1; - node.keepVisible = true; - node.isHistoryCenter = true; - } - nodeMap.set(key, node); - latestOwner.set(key, snapIndex); + // Исторические центры сохраняют позицию собственного кластера. Все остальные общие узлы + // принадлежат самому свежему кластеру, где встретились, и поэтому «переезжают» туда без дубля. + if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) nodeMap.set(key, { ...rawNode, id: key }); if (key === centerKey) return; let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : []; @@ -449,216 +731,115 @@ function buildHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVIOUS) { child: key, relationType: String(ref?.relationType || rawNode?.relationType || 'contact'), strength: Math.max(0, Math.min(1, Number(ref?.strength) || Number(rawNode?.strength) || 0.5)), - snapIndex, }); }); }); }); - const occupied = []; - const placed = new Map(); - const clearanceForNode = (node) => (Number(node?.tier) || 1) >= 2 ? HISTORY_TIER2_CLEARANCE : HISTORY_NODE_CLEARANCE; - const collides = (x, y, clearance, rows = occupied, ignoreKey = '') => rows.some((row) => { - if (ignoreKey && row.key === ignoreKey) return false; - return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0); - }); - - function ownedNodesForSnapshot(snapIndex) { - const centerKey = normKey(snapshots[snapIndex]?.centerLogin); - return [...nodeMap.entries()] - .filter(([key]) => latestOwner.get(key) === snapIndex && key !== centerKey && !centerOrder.has(key)) - .map(([key, node]) => ({ key, node })); - } - - function tryPlaceCluster(snapIndex, center, baseOccupied, commit = false) { - const snap = snapshots[snapIndex]; - const centerKey = normKey(snap?.centerLogin); - const localPlaced = new Map([[centerKey, { ...center }]]); - const rows = baseOccupied.map((row) => ({ ...row })); - if (collides(center.x, center.y, HISTORY_CENTER_CLEARANCE, rows, centerKey)) { - return { ok: false, maxDirectShell: Infinity, idealDirectShell: 0, placed: localPlaced, rows }; - } - rows.push({ key: centerKey, x: center.x, y: center.y, clearance: HISTORY_CENTER_CLEARANCE }); - - const owned = ownedNodesForSnapshot(snapIndex); - const direct = owned.filter(({ node }) => (Number(node?.tier) || 1) < 2); - const deep = owned.filter(({ node }) => (Number(node?.tier) || 1) >= 2); - const directSlots = historyCompactSlots(Math.max(direct.length + 24, 48), `${centerKey}|direct`, HISTORY_DIRECT_SPACING); - let maxDirectShell = 0; - - direct.forEach(({ key, node }) => { - let chosen = null; - const clearance = clearanceForNode(node); - for (const slot of directSlots) { - const x = center.x + slot.x; - const y = center.y + slot.y; - if (!collides(x, y, clearance, rows, key)) { - chosen = { x, y, shell: slot.shell }; - break; - } - } - if (!chosen) { - const angle = historyHash01(`${centerKey}|${key}|fallback`) * Math.PI * 2; - const radius = HISTORY_DIRECT_SPACING * (HISTORY_LAYOUT_MAX_SHELL + 1); - chosen = { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius, shell: HISTORY_LAYOUT_MAX_SHELL + 1 }; - } - maxDirectShell = Math.max(maxDirectShell, chosen.shell || 0); - localPlaced.set(key, chosen); - rows.push({ key, x: chosen.x, y: chosen.y, clearance }); - }); - - const deepByParent = new Map(); - deep.forEach((row) => { - const parentKey = normKey(row.node?.parentId || row.node?.edgeParents?.[0]?.id || centerKey); - const list = deepByParent.get(parentKey) || []; - list.push(row); - deepByParent.set(parentKey, list); - }); - for (const [parentKey, children] of deepByParent.entries()) { - const parentPos = localPlaced.get(parentKey) || placed.get(parentKey) || center; - const slots = historyCompactSlots(Math.max(children.length + 16, 32), `${centerKey}|${parentKey}|deep`, HISTORY_TIER2_SPACING, 6); - children.forEach(({ key, node }) => { - let chosen = null; - const clearance = clearanceForNode(node); - for (const slot of slots) { - const x = parentPos.x + slot.x; - const y = parentPos.y + slot.y; - if (!collides(x, y, clearance, rows, key)) { - chosen = { x, y, shell: slot.shell }; - break; - } - } - if (!chosen) { - const angle = historyHash01(`${parentKey}|${key}|deep-fallback`) * Math.PI * 2; - const radius = HISTORY_TIER2_SPACING * 7; - chosen = { x: parentPos.x + Math.cos(angle) * radius, y: parentPos.y + Math.sin(angle) * radius, shell: 7 }; - } - localPlaced.set(key, chosen); - rows.push({ key, x: chosen.x, y: chosen.y, clearance }); - }); - } - - const idealDirectShell = historyIdealShell(direct.length); - const ok = maxDirectShell <= Math.max(1, idealDirectShell + 1); - if (commit) { - localPlaced.forEach((pos, key) => placed.set(key, { x: pos.x, y: pos.y })); - occupied.splice(0, occupied.length, ...rows); - } - return { ok, maxDirectShell, idealDirectShell, placed: localPlaced, rows }; - } - - // Текущий центр всегда в (0,0). Сначала укладываем его актуальный круг максимально компактно. - const latestIndex = snapshots.length - 1; - tryPlaceCluster(latestIndex, { x: 0, y: 0 }, occupied, true); - - // Предыдущие центры добавляем назад по истории. Мы НЕ держим большой фиксированный минимум: - // сначала пробуем близкую посадку в нескольких направлениях вокруг исходного перехода и выбираем - // наиболее компактную общую форму карты. Только если ни один вариант не помещается — увеличиваем gap. - // Так 3–4 небольших круга складываются скорее в облако/квадрат, а не в длинную цепочку. - const angleOffsets = [0, Math.PI / 8, -Math.PI / 8, Math.PI / 4, -Math.PI / 4, Math.PI / 2, -Math.PI / 2]; - const compactnessScore = (rows, desiredAngleOffset = 0) => { - if (!rows.length) return 0; - let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; - rows.forEach((row) => { - const c = Number(row.clearance) || HISTORY_NODE_CLEARANCE; - minX = Math.min(minX, row.x - c * 0.5); - maxX = Math.max(maxX, row.x + c * 0.5); - minY = Math.min(minY, row.y - c * 0.5); - maxY = Math.max(maxY, row.y + c * 0.5); - }); - const width = Math.max(1, maxX - minX); - const height = Math.max(1, maxY - minY); - const area = width * height; - const aspectPenalty = Math.abs(Math.log(width / height)); - // Площадь важнее всего; небольшой штраф за вытянутый прямоугольник и сильный уход - // от направления фактического клика сохраняет ощущение истории, но не ценой огромных пустот. - return area * (1 + aspectPenalty * 0.22) + Math.abs(desiredAngleOffset) * 2600; - }; - - for (let snapIndex = latestIndex - 1; snapIndex >= 0; snapIndex -= 1) { - const newer = snapshots[snapIndex + 1]; - const newerKey = normKey(newer?.centerLogin); - const newerPos = placed.get(newerKey) || { x: 0, y: 0 }; - const baseAngle = Number.isFinite(Number(newer?.transitionAngle)) ? Number(newer.transitionAngle) : 0; - let gap = HISTORY_CENTER_GAP_START; - let best = null; - - for (let attempt = 0; attempt < 40; attempt += 1) { - const candidates = []; - angleOffsets.forEach((offset) => { - const angle = baseAngle + offset; - const candidate = { - x: newerPos.x - Math.cos(angle) * gap, - y: newerPos.y - Math.sin(angle) * gap, - }; - const trial = tryPlaceCluster(snapIndex, candidate, occupied, false); - if (!trial.ok) return; - candidates.push({ - candidate, - offset, - score: compactnessScore(trial.rows, offset), - }); - }); - if (candidates.length) { - candidates.sort((a, b) => a.score - b.score); - best = candidates[0]; - break; - } - gap += HISTORY_CENTER_GAP_STEP; - } - - const fallbackAngle = baseAngle + (snapIndex % 2 === 0 ? Math.PI / 8 : -Math.PI / 8); - const candidate = best?.candidate || { - x: newerPos.x - Math.cos(fallbackAngle) * gap, - y: newerPos.y - Math.sin(fallbackAngle) * gap, - }; - tryPlaceCluster(snapIndex, candidate, occupied, true); - } - - // На случай центра без периферии, который не попал в occupied из-за повреждённого snapshot. - snapshots.forEach((snap) => { - const key = normKey(snap?.centerLogin); - if (!placed.has(key)) placed.set(key, { x: 0, y: 0 }); + centerNodeByKey.forEach((node, key) => { + if (key !== latestCenterKey) nodeMap.set(key, { ...node, id: key }); }); const edgeParentsByChild = new Map(); - const addEdgeParent = (childKey, parentKey, edge) => { - if (!childKey || !parentKey || childKey === parentKey) return; - const list = edgeParentsByChild.get(childKey) || []; - if (!list.some((row) => normKey(row?.id) === parentKey)) { - list.push({ id: parentKey, relationType: edge.relationType, strength: edge.strength }); - } - edgeParentsByChild.set(childKey, list); - }; for (const edge of edgeMap.values()) { if (!nodeMap.has(edge.parent) || !nodeMap.has(edge.child)) continue; - addEdgeParent(edge.child, edge.parent, edge); + const list = edgeParentsByChild.get(edge.child) || []; + if (!list.some((row) => normKey(row?.id) === edge.parent)) { + list.push({ id: edge.parent, relationType: edge.relationType, strength: edge.strength }); + } + edgeParentsByChild.set(edge.child, list); } - const nodes = [...nodeMap.entries()].map(([key, node]) => { - const pos = placed.get(key) || { x: 0, y: 0 }; - const isCenter = centerOrder.has(key); - return { - ...node, - id: key, - login: node.login || node.id || key, - tier: isCenter ? 1 : Math.max(1, Number(node?.tier) || 1), - layoutX: pos.x, - layoutY: pos.y, - fixedLayout: true, - keepVisible: isCenter || Boolean(node.keepVisible), - alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), - edgeParents: edgeParentsByChild.get(key) || [], - }; - }); + const nodes = [...nodeMap.entries()].map(([key, node]) => ({ + ...node, + id: key, + login: node?.login || node?.id || key, + tier: centerKeys.has(key) ? 1 : Math.max(1, Number(node?.tier) || 1), + fixedLayout: true, + keepVisible: centerKeys.has(key) || Boolean(node?.keepVisible), + alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), + edgeParents: edgeParentsByChild.get(key) || [], + })); return { - focusId: normKey(latest.engineModel.focusId), + focusId: latestCenterKey, nodes, preserveHistory: snapshots.length > 1, }; } +function historyExistingCenterKeys(history) { + return new Set((Array.isArray(history) ? history : []).map((snap) => normKey(snap?.centerLogin)).filter(Boolean)); +} + +function chooseNewClusterCenter(history, newLocalModel, { transitionAngle = 0, transitionX = 0, transitionY = 0 } = {}) { + const currentModel = buildStableHistoryEngineModel(history, HISTORY_MAX_PREVIOUS); + const existing = modelNodePositions(currentModel); + const existingCenters = historyExistingCenterKeys(history); + const focusKey = normKey(newLocalModel?.focusId); + const localPositions = modelNodePositions(newLocalModel); + const movingKeys = new Set([...localPositions.keys()].filter((key) => !existingCenters.has(key) || key === focusKey)); + const blockers = [...existing.entries()] + .filter(([key]) => !movingKeys.has(key)) + .map(([key, row]) => ({ key, ...row })); + + const directDistance = Math.hypot(Number(transitionX) || 0, Number(transitionY) || 0); + let radius = Math.max(104, directDistance + 24); + const baseAngle = Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0; + const offsets = [0, Math.PI / 6, -Math.PI / 6, Math.PI / 3, -Math.PI / 3, Math.PI / 2, -Math.PI / 2, Math.PI]; + + for (let attempt = 0; attempt < 56; attempt += 1) { + let best = null; + offsets.forEach((offset) => { + const angle = baseAngle + offset; + const cx = Math.cos(angle) * radius; + const cy = Math.sin(angle) * radius; + let ok = true; + for (const [key, row] of localPositions.entries()) { + if (!movingKeys.has(key)) continue; + const x = cx + row.x; + const y = cy + row.y; + const hit = blockers.some((blocker) => Math.hypot(x - blocker.x, y - blocker.y) < Math.max(row.clearance || HISTORY_NODE_CLEARANCE, blocker.clearance || HISTORY_NODE_CLEARANCE)); + if (hit) { ok = false; break; } + } + if (!ok) return; + const anglePenalty = Math.abs(offset) * 18; + const score = radius + anglePenalty; + if (!best || score < best.score) best = { x: cx, y: cy, score }; + }); + if (best) return best; + radius += HISTORY_CENTER_GAP_STEP; + } + + return { x: Math.cos(baseAngle) * radius, y: Math.sin(baseAngle) * radius }; +} + +function appendStableSnapshot(history, snapshot, transition = {}) { + let baseHistory = (Array.isArray(history) ? history : []).filter((row) => normKey(row?.centerLogin) !== normKey(snapshot?.centerLogin)); + const existingCenters = historyExistingCenterKeys(baseHistory); + const local = layoutFirstLevelEngineModel(snapshot.engineModel, normKey(snapshot.centerLogin), { skipKeys: existingCenters }); + const candidate = chooseNewClusterCenter(baseHistory, local, transition); + + // Новый центр становится (0,0), поэтому старую карту переносим ЦЕЛИКОМ на противоположный вектор. + // Внутренние координаты старых кластеров не пересчитываются — они остаются визуально теми же блоками. + baseHistory = baseHistory.map((row) => translateSnapshot(row, -candidate.x, -candidate.y)); + const shiftedExisting = modelNodePositions(buildStableHistoryEngineModel(baseHistory, HISTORY_MAX_PREVIOUS)); + + // Если в новом круге встречается уже бывший исторический центр, не затаскиваем его обратно к новому центру: + // он остаётся якорем своего старого кластера, а новая связь просто тянется к нему. + const finalModel = cloneEngineModel(local); + finalModel.nodes = finalModel.nodes.map((node) => { + const key = normKey(node?.id); + if (key !== normKey(finalModel.focusId) && existingCenters.has(key)) { + const old = shiftedExisting.get(key); + if (old) return { ...node, layoutX: old.x, layoutY: old.y, fixedLayout: true, tier: 1, keepVisible: true }; + } + return node; + }); + + return [...baseHistory, { ...snapshot, engineModel: finalModel }]; +} + export function render({ navigate, route, chrome } = {}) { const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; const routeLogin = normalizeLogin(route?.params?.login || ''); @@ -684,7 +865,6 @@ export function render({ navigate, route, chrome } = {}) { let x2Enabled = Boolean(persistedX2Enabled); let engine = null; let loadSeq = 0; - const graphCache = new Map(); let historyChip = null; let x2Chip = null; @@ -732,7 +912,7 @@ export function render({ navigate, route, chrome } = {}) { } function rebuildEngineFromHistory() { - const engineModel = buildHistoryEngineModel(graphHistory, historyDepth); + const engineModel = buildStableHistoryEngineModel(graphHistory, historyDepth); ensureEngine(engineModel); if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate); } @@ -742,9 +922,13 @@ export function render({ navigate, route, chrome } = {}) { historyChip.textContent = historyDepth > 0 ? `История ${historyDepth}` : 'История'; historyChip.classList.toggle('is-active', historyDepth > 0); historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false'); - historyChip.title = historyDepth > 0 - ? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.` - : 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.'; + historyChip.disabled = x2Enabled; + historyChip.setAttribute('aria-disabled', x2Enabled ? 'true' : 'false'); + historyChip.title = x2Enabled + ? 'X2 показывает отдельную карту и временно не использует историю.' + : (historyDepth > 0 + ? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.` + : 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.'); } function cycleHistoryDepth() { @@ -762,13 +946,29 @@ export function render({ navigate, route, chrome } = {}) { x2Chip.classList.toggle('is-active', x2Enabled); x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false'); x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.'; + updateHistoryChip(); } async function toggleX2() { - x2Enabled = !x2Enabled; + const previousHistory = [...graphHistory]; + const previousCenterHistory = [...centerHistory]; + const enabling = !x2Enabled; + x2Enabled = enabling; + // X2 — отдельный режим одной центральной карты. При входе и выходе из него история очищается: + // это не ещё один исторический слой, а полный снимок «центр → все друзья → все друзья друзей». + centerHistory = []; + graphHistory = []; updateX2Chip(); - persistedX2Enabled = x2Enabled; - await load(centerLogin, { pushHistory: false }); + persistHistory(); + await load(centerLogin, { pushHistory: false, resetHistory: true }); + // Если полный X2 не собрался, load выключает флаг. Возвращаем предыдущую обычную карту, + // чтобы сетевой сбой не стирал уже нарисованную историю пользователя. + if (enabling && !x2Enabled) { + graphHistory = previousHistory; + centerHistory = previousCenterHistory; + rebuildEngineFromHistory(); + persistHistory(); + } } function syncLinksUrl(login, { push = false } = {}) { @@ -898,6 +1098,32 @@ export function render({ navigate, route, chrome } = {}) { window.setTimeout(() => inputEl.focus(), 0); } + function persistManualNodePosition(nodeId, point) { + const key = normKey(nodeId); + const x = Number(point?.x); + const y = Number(point?.y); + if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return; + + // Координаты исторических snapshot'ов уже находятся в общей world-системе. Обновляем все + // упоминания пользователя, чтобы следующий setModel/filter/history render не откатил ручной drag. + graphHistory = graphHistory.map((snapshot) => { + const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] }); + let changed = false; + model.nodes = model.nodes.map((node) => { + if (normKey(node?.id) !== key) return node; + changed = true; + return { + ...node, + layoutX: x, + layoutY: y, + fixedLayout: true, + }; + }); + return changed ? { ...snapshot, engineModel: model } : snapshot; + }); + persistHistory(); + } + function ensureEngine(model) { if (engine) { engine.setModel(model); @@ -908,8 +1134,20 @@ export function render({ navigate, route, chrome } = {}) { model, // тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет onNodeTap: (node) => { - const transitionAngle = Math.atan2(Number(node?.y) || 0, Number(node?.x) || 0); - void load(node.login, { pushHistory: true, transitionAngle }); + const transitionX = Number(node?.x) || 0; + const transitionY = Number(node?.y) || 0; + const transitionAngle = Math.atan2(transitionY, transitionX); + if (x2Enabled) { + // Клик внутри X2 начинает новую обычную историю от выбранного человека. + x2Enabled = false; + centerHistory = []; + graphHistory = []; + updateX2Chip(); + persistHistory(); + void load(node.login, { pushHistory: false, resetHistory: true }); + return; + } + void load(node.login, { pushHistory: true, transitionAngle, transitionX, transitionY }); }, // тап по центру — полноценный профиль onCenterTap: (node) => { @@ -930,70 +1168,97 @@ export function render({ navigate, route, chrome } = {}) { ], }); }, + // Drag периферийного аватара — ручная правка текущей карты. Движок уже двигает DOM/рёбра + // в реальном времени; здесь только сохраняем итоговую world-позицию в историю/X2 snapshot. + onNodeMoveEnd: (node, point) => { + persistManualNodePosition(node?.id || node?.login, point); + }, }); } - async function load(nextCenterLogin = '', { pushHistory = false, transitionAngle = 0 } = {}) { + async function load(nextCenterLogin = '', { + pushHistory = false, + transitionAngle = 0, + transitionX = 0, + transitionY = 0, + resetHistory = false, + } = {}) { const requestId = ++loadSeq; const prevCenter = centerLogin; const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login); try { - const graph = await authService.getUserConnectionsGraph(targetCenter); - graphCache.set(normKey(targetCenter), graph); + // Общий module-level cache переживает повторные открытия экрана. Одновременные запросы одного + // логина дедуплицируются; для X2 каждый друг первого уровня дополнительно получает один retry. + const graph = await getConnectionsGraphCached(targetCenter); if (requestId !== loadSeq) return; centerLogin = targetCenter; - if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { - centerHistory.push(prevCenter); - } syncLinksUrl(targetCenter, { push: pushHistory }); const graphModel = buildGraphModel(graph, targetCenter); let snapshotModel = engineModelFromGraphModel(graphModel); + if (x2Enabled) { - snapshotModel = await buildSecondLevelEngineModel(snapshotModel, async (login) => { - const key = normKey(login); - if (graphCache.has(key)) return graphCache.get(key); - const childGraph = await authService.getUserConnectionsGraph(login); - graphCache.set(key, childGraph); - return childGraph; - }); + // СНАЧАЛА полностью собираем второй уровень для КАЖДОГО друга центра, и только после успешного + // завершения всех запросов один раз отдаём цельную X2-модель движку. Частичный X2 не рисуем. + snapshotModel = await buildSecondLevelEngineModel(snapshotModel, (login) => ( + getConnectionsGraphCached(login, { retries: 1 }) + )); if (requestId !== loadSeq) return; - } - - const snapshot = { - centerLogin: targetCenter, - engineModel: snapshotModel, - transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0, - }; - - if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { - // X2 относится только к ТЕКУЩЕМУ центру. Как только центр уходит в историю, оставляем его - // первый уровень — иначе четыре исторических слоя с друзьями друзей быстро превратятся в кашу. - graphHistory = graphHistory.map((row) => ({ ...row, engineModel: stripSecondLevel(row.engineModel) })); - graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter)); - graphHistory.push(snapshot); - const maxSnapshots = historyDepth + 1; - graphHistory = historyDepth > 0 ? graphHistory.slice(-maxSnapshots) : graphHistory.slice(-1); - centerHistory = historyDepth > 0 ? centerHistory.slice(-historyDepth) : []; - } else { - const last = graphHistory[graphHistory.length - 1]; - if (last && normKey(last.centerLogin) === normKey(targetCenter)) { - snapshot.transitionAngle = Number(last.transitionAngle) || 0; - graphHistory[graphHistory.length - 1] = snapshot; + snapshotModel = layoutX2EngineModel(snapshotModel, normKey(targetCenter)); + centerHistory = []; + graphHistory = [{ + centerLogin: targetCenter, + engineModel: snapshotModel, + transitionAngle: 0, + }]; + } else if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { + const rawSnapshot = { + centerLogin: targetCenter, + engineModel: snapshotModel, + transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0, + }; + if (historyDepth > 0) { + graphHistory = appendStableSnapshot(graphHistory, rawSnapshot, { + transitionAngle, + transitionX, + transitionY, + }); + graphHistory = graphHistory.slice(-(historyDepth + 1)); + centerHistory.push(prevCenter); + centerHistory = centerHistory.slice(-historyDepth); } else { - graphHistory = [snapshot]; + graphHistory = [{ ...rawSnapshot, engineModel: layoutFirstLevelEngineModel(snapshotModel, normKey(targetCenter)) }]; + centerHistory = []; } + } else { + const last = !resetHistory ? graphHistory[graphHistory.length - 1] : null; + const stableModel = last && normKey(last.centerLogin) === normKey(targetCenter) + ? refreshFirstLevelSnapshot(last, snapshotModel, normKey(targetCenter)) + : layoutFirstLevelEngineModel(snapshotModel, normKey(targetCenter)); + centerHistory = []; + graphHistory = [{ + centerLogin: targetCenter, + engineModel: stableModel, + transitionAngle: Number(last?.transitionAngle) || 0, + }]; } rebuildEngineFromHistory(); persistHistory(); } catch (error) { if (requestId !== loadSeq) return; + // Если X2 не удалось собрать полностью, не оставляем интерфейс в ложном активном состоянии. + if (x2Enabled) { + x2Enabled = false; + updateX2Chip(); + persistedX2Enabled = false; + } window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`); } } + const searchIconHtml = ` + ${title} + ${hint} + + + `; + return label; +} + +export function render({ navigate, chrome }) { + const screen = document.createElement('section'); + screen.className = 'stack advanced-settings-screen'; + + chrome?.setTopbar(createTopBar({ + title: 'Дополнительные настройки', + back: { label: '←', onClick: () => navigate('settings-view') }, + })); + + const intro = document.createElement('div'); + intro.className = 'card stack advanced-settings-intro'; + intro.innerHTML = ` +

Скрытые возможности SHiNE

+

Этот экран открывается пятью быстрыми нажатиями по логотипу в обычных настройках и имеет постоянный адрес /settings/advanced.

+ `; + + const developerRow = createToggleRow({ + id: 'advanced-developer-tools', + title: 'Настройки разработчика', + hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках.', + checked: isDeveloperToolsEnabled(), + }); + + const filesRow = createToggleRow({ + id: 'advanced-dm-files', + title: 'Передача файлов в личных сообщениях', + hint: 'Разрешить отправку новых зашифрованных файлов через удержание кнопки эмодзи. Полученные ранее файлы останутся доступными.', + checked: isDmFileTransferEnabled(), + }); + + const developerInput = developerRow.querySelector('#advanced-developer-tools'); + const filesInput = filesRow.querySelector('#advanced-dm-files'); + developerInput?.addEventListener('change', () => setDeveloperToolsEnabled(developerInput.checked)); + filesInput?.addEventListener('change', () => setDmFileTransferEnabled(filesInput.checked)); + + screen.append(intro, developerRow, filesRow); + return screen; +} diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index e76e3bfd..67297f0d 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -29,7 +29,13 @@ import { } from '../components/emoji-picker.js?v=202607152130'; import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; import { showToast } from '../services/channels-ux.js'; -import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; +import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; +import { isDmFileTransferEnabled } from '../services/feature-settings.js'; +import { + downloadAndDecryptDmFile, + encryptAndUploadDmFile, + formatDmFileSize, +} from '../services/dm-file-service.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js'; @@ -686,10 +692,10 @@ function renderLog( const bubble = document.createElement('div'); const parsedText = parseDmTechBlocks(String(msg?.text || '')); - const autoKind = parsedText.callSummary ? 'call-tech' : ''; + const autoKind = parsedText.callSummary ? 'call-tech' : (parsedText.fileAttachment ? 'file-tech' : ''); const bubbleKind = String(msg?.kind || autoKind || '').trim(); bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`; - const emojiOnly = !parsedText.callSummary && !parsedText.replyRef && isTwemojiOnlyMessage(parsedText.displayText || ''); + const emojiOnly = !parsedText.callSummary && !parsedText.fileAttachment && !parsedText.replyRef && isTwemojiOnlyMessage(parsedText.displayText || ''); if (emojiOnly) { bubble.classList.add('bubble--emoji-only'); } @@ -734,6 +740,50 @@ function renderLog( bubble.append(callCard); } + if (parsedText.fileAttachment) { + const attachment = parsedText.fileAttachment; + const fileCard = document.createElement('div'); + fileCard.className = 'dm-file-card'; + + const fileIcon = document.createElement('span'); + fileIcon.className = 'dm-file-card__icon'; + fileIcon.textContent = '📎'; + + const fileCopy = document.createElement('span'); + fileCopy.className = 'dm-file-card__copy'; + const fileName = document.createElement('strong'); + fileName.className = 'dm-file-card__name'; + fileName.textContent = attachment.name || 'file'; + const fileMeta = document.createElement('span'); + fileMeta.className = 'dm-file-card__meta'; + fileMeta.textContent = formatDmFileSize(attachment.size); + fileCopy.append(fileName, fileMeta); + + const downloadBtn = document.createElement('button'); + downloadBtn.type = 'button'; + downloadBtn.className = 'ui-button dm-file-card__download'; + downloadBtn.textContent = 'Скачать'; + downloadBtn.addEventListener('click', async (event) => { + event.stopPropagation(); + if (downloadBtn.disabled) return; + downloadBtn.disabled = true; + const previousLabel = downloadBtn.textContent; + downloadBtn.textContent = 'Скачивание…'; + try { + await downloadAndDecryptDmFile(attachment); + showToast('Файл расшифрован и передан браузеру для сохранения', { timeoutMs: 1600 }); + } catch (error) { + showToast(error?.message || 'Не удалось скачать файл', { kind: 'error', timeoutMs: 2400 }); + } finally { + downloadBtn.disabled = false; + downloadBtn.textContent = previousLabel; + } + }); + + fileCard.append(fileIcon, fileCopy, downloadBtn); + bubble.append(fileCard); + } + const textNode = document.createElement('div'); textNode.className = 'bubble-text'; renderMessageText(textNode, parsedText.displayText || ''); @@ -764,7 +814,7 @@ function renderLog( bubble.append(replyBox); } } - if (!parsedText.callSummary) { + if (!parsedText.callSummary && !parsedText.fileAttachment) { bubble.append(textNode); } @@ -1118,7 +1168,9 @@ export function render({ navigate, route, chrome }) { +
+
@@ -1127,6 +1179,9 @@ export function render({ navigate, route, chrome }) { const input = form.elements.message; const emojiSlot = form.querySelector('#chat-emoji-picker-slot'); const emojiToggle = form.querySelector('#chat-emoji-toggle'); + const fileToggle = form.querySelector('#chat-file-toggle'); + const fileInput = form.querySelector('#chat-file-input'); + const fileTransferEnabled = isDmFileTransferEnabled(); const editBanner = form.querySelector('#chat-edit-banner'); const editBannerText = form.querySelector('#chat-edit-banner-text'); const editCancelBtn = form.querySelector('#chat-edit-cancel'); @@ -1135,6 +1190,9 @@ export function render({ navigate, route, chrome }) { let inputFocused = false; let emojiPickerOpen = false; let emojiSelection = null; + let emojiLongPressTimer = null; + let emojiLongPressTriggered = false; + let fileActionVisible = false; const setHistoryLoadingState = (isLoading) => { historyLoader.hidden = !isLoading; @@ -1162,6 +1220,19 @@ export function render({ navigate, route, chrome }) { emojiToggle?.setAttribute('aria-expanded', 'false'); }; + const clearEmojiLongPress = () => { + if (emojiLongPressTimer != null) { + window.clearTimeout(emojiLongPressTimer); + emojiLongPressTimer = null; + } + }; + + const setFileActionVisible = (visible) => { + fileActionVisible = Boolean(visible && fileTransferEnabled); + if (fileToggle) fileToggle.hidden = !fileActionVisible; + syncComposerLayout(); + }; + // На сенсорных устройствах фокус в textarea поднимает клавиатуру поверх пикера — // поэтому при вставке эмодзи фокусируем поле только там, где есть «точный» указатель. const isFinePointer = window.matchMedia?.('(hover: hover) and (pointer: fine)')?.matches ?? true; @@ -1453,6 +1524,117 @@ export function render({ navigate, route, chrome }) { } }; + const sendFileAttachmentMessage = async (attachment) => { + const replying = activeEdit ? null : activeReply; + const fileBlock = buildDmFileTechBlock(attachment); + const replyBlock = replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''; + const fallbackText = `📎 ${String(attachment?.name || 'file')}`; + const finalText = `${replyBlock}${fileBlock}${fallbackText}`; + + // Как и обычное исходящее сообщение, отправка файла сразу закрывает текущий + // визуальный раздел «Новые сообщения», не дожидаясь ответа сервера. + hideUnreadSeparator({ rerender: false }); + + const tempId = addOutgoingPendingMessage(chatId, finalText); + renderChatLog({ scrollMode: 'latest', markAsRead: false }); + scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); + + try { + const result = await authService.sendDirectMessage({ + login: state.session.login, + toLogin: chatId, + text: finalText, + storagePwd: state.session.storagePwdInMemory, + }); + markOutgoingSent(tempId, { + messageKey: result?.outgoingKey || '', + baseKey: result?.baseKey || result?.localBaseKey || '', + deliveryState: result?.deliveryState || 'accepted', + }); + + const localRevisionApplied = await applyLocalRevision({ + localOutgoingBlobB64: result?.localOutgoingBlobB64 || '', + fallbackMessageKey: result?.outgoingKey || '', + fallbackBaseKey: result?.baseKey || result?.localBaseKey || '', + deliveryState: result?.deliveryState || 'accepted', + }); + + if (replying) cancelReplyMode({ restoreDraft: false }); + renderChatLog({ scrollMode: 'latest', markAsRead: false }); + if (localRevisionApplied) notifyUnreadStateUpdated(); + scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); + window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true })); + + addAppLogEntry({ + level: 'info', + source: 'outgoing-dm-file', + message: `Файл отправлен для ${chatId}`, + details: { + toLogin: chatId, + fileId: attachment?.id || '', + fileSize: Number(attachment?.size || 0), + messageId: result?.outgoingKey || '', + replyToBaseKey: replying?.baseKey || '', + }, + }); + } catch (error) { + addChatMessage(chatId, `Ошибка отправки файла: ${error?.message || 'unknown'}`); + addAppLogEntry({ + level: 'warn', + source: 'outgoing-dm-file', + message: 'Файл загружен, но DM с ключом не отправлен', + details: { + toLogin: chatId, + fileId: attachment?.id || '', + error: error?.message || 'unknown', + }, + }); + renderChatLog({ scrollMode: 'latest', markAsRead: false }); + throw error; + } + }; + + const handleSelectedFile = async (file) => { + if (!fileTransferEnabled) { + showToast('Передача файлов отключена в дополнительных настройках.', { kind: 'error', timeoutMs: 1800 }); + return; + } + if (activeEdit) { + showToast('Сначала завершите редактирование сообщения.', { kind: 'error', timeoutMs: 1600 }); + return; + } + if (!file) return; + + const originalLabel = fileToggle?.textContent || '📎'; + if (fileToggle) { + fileToggle.disabled = true; + fileToggle.textContent = '…'; + fileToggle.setAttribute('aria-label', 'Шифрование и загрузка файла'); + } + showToast(`Шифрую и загружаю «${String(file.name || 'file')}»…`, { timeoutMs: 1600 }); + + try { + const attachment = await encryptAndUploadDmFile({ + file, + login: state.session.login, + sessionId: state.session.sessionId, + wsUrl: authService.serverUrl, + }); + await sendFileAttachmentMessage(attachment); + setFileActionVisible(false); + showToast('Файл отправлен.', { timeoutMs: 1200 }); + } catch (error) { + showToast(error?.message || 'Не удалось отправить файл', { kind: 'error', timeoutMs: 2600 }); + } finally { + if (fileToggle) { + fileToggle.disabled = false; + fileToggle.textContent = originalLabel; + fileToggle.setAttribute('aria-label', 'Прикрепить файл'); + } + if (fileInput) fileInput.value = ''; + } + }; + const handleOpenActions = (msg, event) => { const parsed = parseDmTechBlocks(String(msg?.text || '')); const readState = resolveEffectiveReadState(getChatMessages(chatId), msg); @@ -1465,7 +1647,7 @@ export function render({ navigate, route, chrome }) { : '', canReply: true, showReadAloud: isTextToSpeechReady, - canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary, + canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary && !parsed?.fileAttachment, canDelete: ( (msg?.from === 'out' && Number(msg?.messageType || 0) === 2) || (msg?.from === 'in' && Number(msg?.messageType || 0) === 1) @@ -1577,9 +1759,38 @@ export function render({ navigate, route, chrome }) { emojiToggle?.setAttribute('aria-expanded', 'false'); emojiToggle?.addEventListener('pointerdown', (event) => { rememberEmojiSelection(); + emojiLongPressTriggered = false; + clearEmojiLongPress(); + if (fileTransferEnabled) { + emojiLongPressTimer = window.setTimeout(() => { + emojiLongPressTimer = null; + emojiLongPressTriggered = true; + closeEmojiPicker(); + setFileActionVisible(true); + }, 520); + } + try { + emojiToggle.setPointerCapture?.(event.pointerId); + } catch { + // Pointer capture is optional; long press still works without it. + } event.preventDefault(); }); - emojiToggle?.addEventListener('click', () => { + emojiToggle?.addEventListener('pointerup', clearEmojiLongPress); + emojiToggle?.addEventListener('pointercancel', clearEmojiLongPress); + emojiToggle?.addEventListener('pointerleave', (event) => { + if (event.buttons) clearEmojiLongPress(); + }); + emojiToggle?.addEventListener('contextmenu', (event) => { + if (fileTransferEnabled) event.preventDefault(); + }); + emojiToggle?.addEventListener('click', (event) => { + if (emojiLongPressTriggered) { + emojiLongPressTriggered = false; + event.preventDefault(); + return; + } + setFileActionVisible(false); emojiPickerOpen = !emojiPickerOpen; emojiSlot.hidden = !emojiPickerOpen; emojiToggle.setAttribute('aria-expanded', String(emojiPickerOpen)); @@ -1587,9 +1798,18 @@ export function render({ navigate, route, chrome }) { // открытии пикера прячем клавиатуру, чтобы поле ввода оставалось видно. if (emojiPickerOpen && !isFinePointer) input?.blur(); }); + fileToggle?.addEventListener('click', () => { + if (!fileTransferEnabled || fileToggle.disabled) return; + fileInput?.click(); + }); + fileInput?.addEventListener('change', () => { + const file = fileInput.files?.[0] || null; + if (file) void handleSelectedFile(file); + }); screen.addEventListener('pointerdown', (event) => { - if (!emojiPickerOpen || form.contains(event.target)) return; - closeEmojiPicker(); + if (form.contains(event.target)) return; + if (emojiPickerOpen) closeEmojiPicker(); + if (fileActionVisible) setFileActionVisible(false); }); form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => { event.preventDefault(); @@ -1658,6 +1878,7 @@ export function render({ navigate, route, chrome }) { screen.cleanup = () => { hideUnreadSeparator({ rerender: false }); stopAllTwemojiAnimations(); + clearEmojiLongPress(); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh); boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll); diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index 8955cfff..20ad280e 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -1,5 +1,6 @@ import { createTopBar } from '../components/topbar.js'; import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js'; +import { isDeveloperToolsEnabled } from '../services/feature-settings.js'; export const pageMeta = { id: 'settings-view', title: 'Настройки' }; @@ -35,6 +36,24 @@ export function render({navigate, chrome}) { back: { label: '←', onClick: () => navigate('profile-view') }, })); + const logoButton = document.createElement('button'); + logoButton.type = 'button'; + logoButton.className = 'settings-shine-logo-button'; + logoButton.setAttribute('aria-label', 'Логотип SHiNE'); + logoButton.title = 'SHiNE'; + logoButton.innerHTML = ''; + + let logoTapCount = 0; + let lastLogoTapAt = 0; + logoButton.addEventListener('click', () => { + const now = Date.now(); + logoTapCount = now - lastLogoTapAt <= 900 ? logoTapCount + 1 : 1; + lastLogoTapAt = now; + if (logoTapCount < 5) return; + logoTapCount = 0; + navigate('advanced-settings-view'); + }); + const card = document.createElement('div'); card.className = 'card stack'; card.innerHTML = ` @@ -93,61 +112,64 @@ export function render({navigate, chrome}) { } }); - const versionCard = document.createElement('div'); - versionCard.className = 'card stack'; + screen.append(logoButton, card); - const title = document.createElement('p'); - title.className = 'field-label'; - title.textContent = 'Версии'; + if (isDeveloperToolsEnabled()) { + const versionCard = document.createElement('div'); + versionCard.className = 'card stack'; - const clientVersion = document.createElement('p'); - clientVersion.className = 'meta-muted'; - clientVersion.textContent = `Клиент: ${formatVersionForUi(window.__SHINE_CLIENT_VERSION__)}`; + const title = document.createElement('p'); + title.className = 'field-label'; + title.textContent = 'Версии'; - const uiBuild = document.createElement('p'); - uiBuild.className = 'meta-muted'; - uiBuild.textContent = `Сборка UI: ${formatVersionForUi(window.__SHINE_BUILD_HASH__)}`; + const clientVersion = document.createElement('p'); + clientVersion.className = 'meta-muted'; + clientVersion.textContent = `Клиент: ${formatVersionForUi(window.__SHINE_CLIENT_VERSION__)}`; - const serverVersion = document.createElement('p'); - serverVersion.className = 'meta-muted'; - serverVersion.textContent = 'Сервер: загружается...'; + const uiBuild = document.createElement('p'); + uiBuild.className = 'meta-muted'; + uiBuild.textContent = `Сборка UI: ${formatVersionForUi(window.__SHINE_BUILD_HASH__)}`; - versionCard.append(title, clientVersion, uiBuild, serverVersion); + const serverVersion = document.createElement('p'); + serverVersion.className = 'meta-muted'; + serverVersion.textContent = 'Сервер: загружается...'; - const developerCard = document.createElement('div'); - developerCard.className = 'card stack'; - developerCard.innerHTML = ` - - `; - developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view')); + versionCard.append(title, clientVersion, uiBuild, serverVersion); - void (async () => { - try { - let value = ''; + const developerCard = document.createElement('div'); + developerCard.className = 'card stack'; + developerCard.innerHTML = ` + + `; + developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view')); + + void (async () => { try { - const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000); - value = String(pingResp?.payload?.serverVersion || pingResp?.serverVersion || '').trim(); - } catch { - // fallback below - } + let value = ''; + try { + const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000); + value = String(pingResp?.payload?.serverVersion || pingResp?.serverVersion || '').trim(); + } catch { + // fallback below + } - if (!value) { - const infoResp = await authService.ws.request('GetServerInfo', {}); - value = String(infoResp?.payload?.version || '').trim(); + if (!value) { + const infoResp = await authService.ws.request('GetServerInfo', {}); + value = String(infoResp?.payload?.version || '').trim(); + } + if (!isDisposed) serverVersion.textContent = `Сервер: ${formatVersionForUi(value)}`; + } catch { + if (!isDisposed) { + serverVersion.textContent = 'Сервер: недоступно'; + } } - if (!isDisposed) serverVersion.textContent = `Сервер: ${formatVersionForUi(value)}`; - } catch { - if (!isDisposed) { - serverVersion.textContent = 'Сервер: недоступно'; - } - } - })(); + })(); + + screen.append(versionCard, developerCard); + } screen.cleanup = () => { isDisposed = true; }; - screen.append(card); - screen.append(versionCard); - screen.append(developerCard); return screen; } diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index d4b87b0d..35c7d02f 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -36,6 +36,7 @@ const PRETTY_PATHS = new Map([ ['server-settings-view', 'settings/servers'], ['arweave-uploads-view', 'settings/arweave-uploads'], ['developer-settings-view', 'settings/developer'], + ['advanced-settings-view', 'settings/advanced'], ['trusted-device-login-settings-view', 'settings/device-login'], ['language-view', 'settings/language'], ['app-log-view', 'settings/app-log'], @@ -322,6 +323,7 @@ export function parseRouteFromPath(pathname = '') { if (sub === 'servers') return { pageId: 'server-settings-view', params: {} }; if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} }; if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} }; + if (sub === 'advanced') return { pageId: 'advanced-settings-view', params: {} }; if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} }; if (sub === 'language') return { pageId: 'language-view', params: {} }; if (sub === 'app-log') return { pageId: 'app-log-view', params: {} }; @@ -447,6 +449,7 @@ export function resolveToolbarActive(pageId) { pageId === 'settings-view' || pageId === 'access-servers-view' || pageId === 'developer-settings-view' || + pageId === 'advanced-settings-view' || pageId === 'server-settings-view' || pageId === 'arweave-uploads-view' || pageId === 'remote-addblock-session-view' || diff --git a/shine-UI/js/services/dm-file-service.js b/shine-UI/js/services/dm-file-service.js new file mode 100644 index 00000000..39202df2 --- /dev/null +++ b/shine-UI/js/services/dm-file-service.js @@ -0,0 +1,204 @@ +import { + base64UrlToBytes, + bytesToBase58, + bytesToBase64Url, + decryptBytesAesGcm, + encryptBytesAesGcm, + importPkcs8Ed25519, + randomBytes, + sha256Bytes, + signBase64, +} from './crypto-utils.js'; +import { loadSessionMaterial } from './key-vault.js'; + +export const DM_FILE_MAX_SOURCE_BYTES = 50 * 1024 * 1024; + +function normalizeFileName(value = '') { + const cleaned = String(value || 'file') + .replace(/[\\/\u0000-\u001f\u007f]/g, '_') + .trim(); + return (cleaned || 'file').slice(0, 180); +} + +function wsUrlToHttpBase(wsUrl = '') { + const parsed = new URL(String(wsUrl || ''), window.location.href); + if (parsed.protocol === 'wss:') parsed.protocol = 'https:'; + else if (parsed.protocol === 'ws:') parsed.protocol = 'http:'; + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('Не удалось определить HTTP-адрес сервера SHiNE'); + } + parsed.pathname = '/'; + parsed.search = ''; + parsed.hash = ''; + return parsed.origin; +} + +function uploadPreimage({ sessionId, fileId, encryptedSize, timeMs }) { + return `DM_FILE_UPLOAD_V1:${sessionId}:${fileId}:${Number(encryptedSize)}:${Number(timeMs)}`; +} + +async function readErrorMessage(response) { + try { + const raw = String(await response.text()).trim(); + if (!raw) return ''; + try { + const payload = JSON.parse(raw); + return String(payload?.message || payload?.error || raw).trim(); + } catch { + return raw; + } + } catch { + return ''; + } +} + +export function formatDmFileSize(bytes = 0) { + const value = Math.max(0, Number(bytes || 0)); + if (value < 1024) return `${value} Б`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} КБ`; + return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`; +} + +export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } = {}) { + if (!file || typeof file.arrayBuffer !== 'function') throw new Error('Файл не выбран'); + const cleanLogin = String(login || '').trim(); + const cleanSessionId = String(sessionId || '').trim(); + if (!cleanLogin || !cleanSessionId) throw new Error('Нет активной пользовательской сессии'); + if (Number(file.size || 0) > DM_FILE_MAX_SOURCE_BYTES) { + throw new Error(`Файл слишком большой. Текущий лимит — ${formatDmFileSize(DM_FILE_MAX_SOURCE_BYTES)}.`); + } + + const sessionMaterial = await loadSessionMaterial(cleanLogin); + if (!sessionMaterial?.sessionPrivPkcs8) { + throw new Error('На устройстве нет сохранённого session key для загрузки файла'); + } + if (sessionMaterial.sessionId && String(sessionMaterial.sessionId) !== cleanSessionId) { + throw new Error('Сохранённый session key относится к другой сессии'); + } + + const sourceBytes = new Uint8Array(await file.arrayBuffer()); + const fileKey = randomBytes(32); + const iv = randomBytes(12); + let encryptedBytes; + try { + encryptedBytes = await encryptBytesAesGcm(sourceBytes, fileKey, iv); + } finally { + sourceBytes.fill(0); + } + + try { + const fileHash = await sha256Bytes(encryptedBytes); + const fileId = bytesToBase58(fileHash); + const serverBase = wsUrlToHttpBase(wsUrl); + const fileUrl = `${serverBase}/dm-files/${fileId}`; + const timeMs = Date.now(); + const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8); + const signatureB64 = await signBase64(privateKey, uploadPreimage({ + sessionId: cleanSessionId, + fileId, + encryptedSize: encryptedBytes.byteLength, + timeMs, + })); + + let response; + try { + response = await fetch(fileUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Shine-Session-Id': cleanSessionId, + 'X-Shine-Time-Ms': String(timeMs), + 'X-Shine-Content-Length': String(encryptedBytes.byteLength), + 'X-Shine-Signature': signatureB64, + }, + body: encryptedBytes, + cache: 'no-store', + }); + } catch (error) { + throw new Error(`Не удалось загрузить файл на сервер отправителя: ${error?.message || 'network error'}`); + } + + if (!response.ok) { + const detail = await readErrorMessage(response); + throw new Error(detail || `Сервер отклонил файл (HTTP ${response.status})`); + } + + return { + version: 1, + id: fileId, + url: fileUrl, + keyB64Url: bytesToBase64Url(fileKey), + ivB64Url: bytesToBase64Url(iv), + name: normalizeFileName(file.name), + mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream', + size: Number(file.size || 0), + encryptedSize: encryptedBytes.byteLength, + }; + } finally { + fileKey.fill(0); + iv.fill(0); + encryptedBytes?.fill(0); + } +} + +export async function downloadAndDecryptDmFile(attachment = {}) { + const fileId = String(attachment?.id || '').trim(); + const fileUrl = String(attachment?.url || '').trim(); + const keyB64Url = String(attachment?.keyB64Url || '').trim(); + const ivB64Url = String(attachment?.ivB64Url || '').trim(); + if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) { + throw new Error('В сообщении не хватает данных для расшифровки файла'); + } + + let response; + try { + response = await fetch(fileUrl, { method: 'GET', cache: 'no-store' }); + } catch (error) { + throw new Error(`Не удалось скачать зашифрованный файл: ${error?.message || 'network error'}`); + } + if (!response.ok) { + throw new Error(response.status === 404 ? 'Файл больше не найден на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`); + } + + const encryptedBytes = new Uint8Array(await response.arrayBuffer()); + const actualId = bytesToBase58(await sha256Bytes(encryptedBytes)); + if (actualId !== fileId) { + encryptedBytes.fill(0); + throw new Error('SHA-256 файла не совпал: зашифрованный файл повреждён или подменён'); + } + + const keyBytes = base64UrlToBytes(keyB64Url); + const ivBytes = base64UrlToBytes(ivB64Url); + let plainBytes; + try { + plainBytes = await decryptBytesAesGcm(encryptedBytes, keyBytes, ivBytes); + } catch { + throw new Error('Не удалось расшифровать файл: ключ или содержимое повреждены'); + } finally { + encryptedBytes.fill(0); + keyBytes.fill(0); + ivBytes.fill(0); + } + + const expectedSize = Number(attachment?.size || 0); + if (expectedSize >= 0 && plainBytes.byteLength !== expectedSize) { + plainBytes.fill(0); + throw new Error('Размер расшифрованного файла не совпал с сообщением'); + } + + const blob = new Blob([plainBytes], { + type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream', + }); + plainBytes.fill(0); + + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = normalizeFileName(attachment?.name || 'file'); + anchor.rel = 'noopener'; + anchor.style.display = 'none'; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); +} diff --git a/shine-UI/js/services/dm-tech-blocks.js b/shine-UI/js/services/dm-tech-blocks.js index 10b2008e..9c906aef 100644 --- a/shine-UI/js/services/dm-tech-blocks.js +++ b/shine-UI/js/services/dm-tech-blocks.js @@ -8,6 +8,7 @@ function defaultParsed(rawText = '') { blocks: [], replyRef: null, callSummary: null, + fileAttachment: null, }; } @@ -87,6 +88,51 @@ export function buildDmReplyTechBlock({ baseKey = '' } = {}) { return ``; } +function decodeFileField(value = '') { + try { + return decodeURIComponent(String(value || '')); + } catch { + return String(value || ''); + } +} + +function normalizeFileAttachment(fields = {}) { + const id = String(fields.id || '').trim(); + const url = decodeFileField(fields.url || '').trim(); + const keyB64Url = String(fields.key || '').trim(); + const ivB64Url = String(fields.iv || '').trim(); + const name = decodeFileField(fields.name || '').trim() || 'file'; + const mime = decodeFileField(fields.mime || '').trim() || 'application/octet-stream'; + const size = Number(fields.size || 0); + const encryptedSize = Number(fields.encsize || 0); + if (!id || !url || !keyB64Url || !ivB64Url || !Number.isFinite(size) || size < 0) return null; + return { + version: Number(fields.v || 0), + id, + url, + keyB64Url, + ivB64Url, + name, + mime, + size, + encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0, + }; +} + +export function buildDmFileTechBlock(attachment = {}) { + const id = String(attachment?.id || '').trim(); + const url = String(attachment?.url || '').trim(); + const keyB64Url = String(attachment?.keyB64Url || '').trim(); + const ivB64Url = String(attachment?.ivB64Url || '').trim(); + const size = Math.max(0, Math.floor(Number(attachment?.size || 0))); + const encryptedSize = Math.max(0, Math.floor(Number(attachment?.encryptedSize || 0))); + if (!id || !url || !keyB64Url || !ivB64Url) throw new Error('Не хватает данных для технического блока файла'); + const name = encodeURIComponent(String(attachment?.name || 'file')); + const mime = encodeURIComponent(String(attachment?.mime || 'application/octet-stream')); + const encodedUrl = encodeURIComponent(url); + return ``; +} + export function parseDmTechBlocks(rawText = '') { const text = String(rawText || ''); if (!hasTechPrefix(text)) return defaultParsed(text); @@ -95,6 +141,7 @@ export function parseDmTechBlocks(rawText = '') { let cursor = 0; let replyRef = null; let callSummary = null; + let fileAttachment = null; while (hasTechPrefix(text, cursor)) { const end = text.indexOf('>', cursor); @@ -142,6 +189,8 @@ export function parseDmTechBlocks(rawText = '') { reason: String(fields.reason || '').trim().toLowerCase(), }; } + } else if (kind === 'file' && !fileAttachment) { + fileAttachment = normalizeFileAttachment(fields); } cursor = end + 1; @@ -157,5 +206,6 @@ export function parseDmTechBlocks(rawText = '') { blocks, replyRef, callSummary, + fileAttachment, }; } diff --git a/shine-UI/js/services/feature-settings.js b/shine-UI/js/services/feature-settings.js new file mode 100644 index 00000000..0b2598f8 --- /dev/null +++ b/shine-UI/js/services/feature-settings.js @@ -0,0 +1,43 @@ +const STORAGE_KEYS = Object.freeze({ + developerTools: 'shine-feature-developer-tools-v1', + dmFileTransfer: 'shine-feature-dm-file-transfer-v1', +}); + +function readBoolean(key, defaultValue = true) { + try { + const raw = localStorage.getItem(key); + if (raw == null) return Boolean(defaultValue); + return raw !== '0' && raw !== 'false'; + } catch { + return Boolean(defaultValue); + } +} + +function writeBoolean(key, enabled) { + const value = Boolean(enabled); + try { + localStorage.setItem(key, value ? '1' : '0'); + } catch { + // В приватном режиме/при запрете storage оставляем значение только на уровне default. + } + window.dispatchEvent(new CustomEvent('shine-feature-settings-updated', { + detail: { key, enabled: value }, + })); + return value; +} + +export function isDeveloperToolsEnabled() { + return readBoolean(STORAGE_KEYS.developerTools, true); +} + +export function setDeveloperToolsEnabled(enabled) { + return writeBoolean(STORAGE_KEYS.developerTools, enabled); +} + +export function isDmFileTransferEnabled() { + return readBoolean(STORAGE_KEYS.dmFileTransfer, true); +} + +export function setDmFileTransferEnabled(enabled) { + return writeBoolean(STORAGE_KEYS.dmFileTransfer, enabled); +} diff --git a/shine-UI/styles/features/chat.css b/shine-UI/styles/features/chat.css index ff70d858..0c9ad973 100644 --- a/shine-UI/styles/features/chat.css +++ b/shine-UI/styles/features/chat.css @@ -1275,3 +1275,101 @@ text-shadow: 0 0 5px rgba(92, 190, 255, 0.72), 0 0 12px rgba(72, 145, 255, 0.34); filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)); } + +/* ===== Зашифрованные вложения в личных сообщениях ===== */ +.dm-file-btn { + position: absolute; + right: 45px; + bottom: 52px; + z-index: 6; + min-width: 43px; + width: 43px; + height: 43px; + padding: 0; + border-radius: 14px; + font-size: 21px; + line-height: 1; + background: rgba(13, 28, 54, 0.94); + border: 1px solid rgba(183, 207, 242, 0.28); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255,255,255,.08); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +.dm-file-btn[hidden] { + display: none; +} + +.dm-file-btn:disabled { + opacity: 0.7; + cursor: progress; +} + +.dm-file-card { + min-width: min(285px, 72vw); + max-width: min(330px, 76vw); + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; +} + +.dm-file-card__icon { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 12px; + background: rgba(255, 255, 255, 0.08); + font-size: 19px; +} + +.dm-file-card__copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.dm-file-card__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 680; +} + +.dm-file-card__meta { + font-size: 11px; + color: rgba(205, 219, 242, 0.67); +} + +.dm-file-card__download { + min-height: 34px; + padding: 0 10px; + border-radius: 11px; + font-size: 12px; + font-weight: 650; + white-space: nowrap; +} + +.dm-file-card__download:disabled { + opacity: 0.65; + cursor: progress; +} + +@media (max-width: 390px) { + .dm-screen .dm-file-card { + min-width: min(264px, 76vw); + grid-template-columns: 32px minmax(0, 1fr); + } + + .dm-screen .dm-file-card__download { + grid-column: 2; + justify-self: start; + } +} + +.dm-chat-input--multiline .dm-file-btn { + right: 0; + bottom: 104px; +} diff --git a/shine-UI/styles/features/messages.css b/shine-UI/styles/features/messages.css index 775f01fb..cf74f0e7 100644 --- a/shine-UI/styles/features/messages.css +++ b/shine-UI/styles/features/messages.css @@ -404,6 +404,7 @@ button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); } .dm-actions-col { + position: relative; display: grid; grid-template-columns: repeat(2, 46px); grid-template-rows: 46px; diff --git a/shine-UI/styles/features/settings.css b/shine-UI/styles/features/settings.css index 62af738f..114283df 100644 --- a/shine-UI/styles/features/settings.css +++ b/shine-UI/styles/features/settings.css @@ -270,3 +270,113 @@ } .language-choice-actions .shine-btn { height: 50px; } + +/* ===== Скрытые расширенные настройки ===== */ +.settings-shine-logo-button { + width: min(72vw, 280px); + min-height: 118px; + margin: 2px auto 10px; + padding: 6px 12px; + display: grid; + place-items: center; + border: 0; + background: transparent; + box-shadow: none; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +.settings-shine-logo-button:hover, +.settings-shine-logo-button:focus, +.settings-shine-logo-button:active { + border: 0; + background: transparent; + box-shadow: none; + transform: none; +} + +.settings-shine-logo-button:focus-visible { + outline: 2px solid rgba(167, 205, 255, 0.7); + outline-offset: 4px; + border-radius: 22px; +} + +.settings-shine-logo { + display: block; + width: 100%; + max-height: 126px; + object-fit: contain; + pointer-events: none; + filter: drop-shadow(0 12px 30px rgba(73, 126, 216, 0.16)); +} + +.settings-feature-toggle-row { + min-height: 76px; + padding: 15px 16px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + cursor: pointer; +} + +.settings-feature-toggle-copy { + min-width: 0; + display: grid; + gap: 5px; +} + +.settings-feature-toggle-title { + font-size: 15px; + font-weight: 700; + color: rgba(246, 250, 255, 0.96); +} + +.settings-feature-toggle-hint { + font-size: 12px; + line-height: 1.4; + color: rgba(194, 210, 236, 0.68); +} + +.settings-feature-switch { + appearance: none; + -webkit-appearance: none; + width: 48px; + height: 28px; + margin: 0; + padding: 2px; + border: 1px solid rgba(189, 207, 238, 0.26); + border-radius: 999px; + background: rgba(8, 17, 34, 0.72); + box-shadow: inset 0 2px 7px rgba(0, 0, 0, 0.28); + cursor: pointer; + transition: background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease; +} + +.settings-feature-switch::after { + content: ''; + display: block; + width: 22px; + height: 22px; + border-radius: 50%; + background: rgba(235, 243, 255, 0.9); + box-shadow: 0 2px 7px rgba(0, 0, 0, 0.28); + transition: transform 150ms ease; +} + +.settings-feature-switch:checked { + border-color: rgba(86, 174, 255, 0.6); + background: rgba(38, 132, 237, 0.48); + box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 0 16px rgba(58, 148, 255, 0.12); +} + +.settings-feature-switch:checked::after { + transform: translateX(20px); + background: #ffffff; +} + +.settings-feature-switch:focus-visible { + outline: 2px solid rgba(190, 220, 255, 0.82); + outline-offset: 3px; +} From ef068eca81e175e457afe866411007c7ddeae7dc1d9310a019d07afeb8cb0ff8 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 11 Sep 2026 14:33:21 +0300 Subject: [PATCH 16/30] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=8C=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=B0=D1=80=D1=85=D0=B8=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JSON/messages/SignedMessagesRealtime.java | 2 +- .../ws_protocol/JSON/push/WebPushSender.java | 3 +- .../main/java/server/files/DmFileServlet.java | 60 +- .../src/main/resources/application.properties | 4 +- VERSION.properties | 4 +- bundle.sh | 14 +- docs/API/18_DM_File_Storage_API.md | 24 +- docs/Personal_Messages/README.md | 1 + docs/Personal_Messages/Протокол_DM_v1.md | 16 + .../Технические_вставки_DM_v1.md | 23 + docs/Personal_Messages/Файлы_DM_v2.md | 124 ++ docs/Personal_Messages/Формат_DM_v1.md | 13 + docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md | 1553 +++++++++++++++++ shine-UI/firebase-messaging-sw.js | 2 +- shine-UI/js/pages/advanced-settings-view.js | 4 +- shine-UI/js/pages/chat-view.js | 909 +++++++++- shine-UI/js/services/crypto-utils.js | 12 +- shine-UI/js/services/dm-file-service.js | 710 +++++++- shine-UI/js/services/dm-tech-blocks.js | 67 +- shine-UI/js/services/feature-settings.js | 2 +- shine-UI/js/services/torrent-v2-service.js | 234 +++ shine-UI/styles/features/chat.css | 300 ++++ 22 files changed, 3865 insertions(+), 216 deletions(-) create mode 100644 docs/Personal_Messages/Файлы_DM_v2.md create mode 100644 docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md create mode 100644 shine-UI/js/services/torrent-v2-service.js diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java index 76bd9306..a3f50099 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java @@ -147,7 +147,7 @@ public final class SignedMessagesRealtime { if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { return false; } - String text = "Вам пришло новое личное сообщение от " + message.getFromLogin() + "."; + String text = "Новое сообщение от " + message.getFromLogin(); String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}"; return WebPushSender.sendBase64Payload( session.getPushEndpoint(), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java index 676e34cb..de71bc0f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java @@ -3,6 +3,7 @@ package server.logic.ws_protocol.JSON.push; import nl.martijndwars.webpush.Notification; import nl.martijndwars.webpush.PushService; import nl.martijndwars.webpush.Subscription; +import nl.martijndwars.webpush.Urgency; import org.jose4j.lang.JoseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -61,7 +62,7 @@ public final class WebPushSender { endpoint, new Subscription.Keys(p256dhKey, authKey) ); - Notification notification = new Notification(subscription, payloadB64); + Notification notification = new Notification(subscription, payloadB64, Urgency.HIGH); var response = service().send(notification); int code = response.getStatusLine().getStatusCode(); return code >= 200 && code < 300; diff --git a/SHiNE-server/src/main/java/server/files/DmFileServlet.java b/SHiNE-server/src/main/java/server/files/DmFileServlet.java index 3c5f2ea4..f6758a23 100644 --- a/SHiNE-server/src/main/java/server/files/DmFileServlet.java +++ b/SHiNE-server/src/main/java/server/files/DmFileServlet.java @@ -102,15 +102,20 @@ public final class DmFileServlet extends HttpServlet { return; } - // Content-addressed объект неизменяем. Повторная корректно подписанная загрузка того же - // ciphertext безопасна и просто возвращает существующий объект. + // Content-addressed объект неизменяем. Нельзя доверять одному только имени файла: + // перед ответом «уже есть» повторно проверяем реальный SHA-256 сохранённых байтов. + // Если файл на диске повреждён/подменён, удаляем его и принимаем корректную загрузку заново. if (Files.isRegularFile(target)) { - if (Files.size(target) != auth.declaredLength) { - writeError(resp, HttpServletResponse.SC_CONFLICT, "FILE_ID_CONFLICT", "Файл с таким hash уже существует с другим размером"); + StoredObjectCheck stored = verifyStoredObject(target, fileId); + if (stored.valid()) { + if (stored.size() != auth.declaredLength) { + writeError(resp, HttpServletResponse.SC_CONFLICT, "FILE_ID_CONFLICT", "Файл с таким hash уже существует с другим размером"); + return; + } + writeUploadOk(resp, fileId, auth.declaredLength, true); return; } - writeUploadOk(resp, fileId, auth.declaredLength, true); - return; + Files.deleteIfExists(target); } Path temp = Files.createTempFile(storageDir, ".dm-upload-", ".tmp"); @@ -153,7 +158,19 @@ public final class DmFileServlet extends HttpServlet { } keepTemp = true; // temp уже перемещён, удалять нечего } catch (FileAlreadyExistsException race) { - alreadyExists = true; + StoredObjectCheck stored = verifyStoredObject(target, fileId); + if (stored.valid() && stored.size() == actualLength) { + alreadyExists = true; + } else { + // Даже в редкой гонке не подтверждаем объект, пока его реальные байты не прошли hash-проверку. + Files.deleteIfExists(target); + try { + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temp, target); + } + keepTemp = true; + } } writeUploadOk(resp, fileId, actualLength, alreadyExists); @@ -188,7 +205,15 @@ public final class DmFileServlet extends HttpServlet { return; } - long size = Files.size(target); + StoredObjectCheck stored = verifyStoredObject(target, fileId); + if (!stored.valid()) { + // Повреждённый content-addressed объект нельзя подтверждать через HEAD и нельзя отдавать через GET. + Files.deleteIfExists(target); + writeError(resp, HttpServletResponse.SC_NOT_FOUND, "FILE_NOT_FOUND", "Файл не найден"); + return; + } + + long size = stored.size(); resp.setStatus(HttpServletResponse.SC_OK); resp.setContentType("application/octet-stream"); resp.setHeader("Content-Length", Long.toString(size)); @@ -203,6 +228,25 @@ public final class DmFileServlet extends HttpServlet { } } + + private static StoredObjectCheck verifyStoredObject(Path target, String expectedFileId) throws IOException { + MessageDigest digest = sha256Digest(); + long size = 0L; + byte[] buffer = new byte[64 * 1024]; + try (InputStream in = Files.newInputStream(target)) { + int read; + while ((read = in.read(buffer)) >= 0) { + if (read == 0) continue; + size += read; + digest.update(buffer, 0, read); + } + } + String actualFileId = toBase58(digest.digest()); + return new StoredObjectCheck(Objects.equals(expectedFileId, actualFileId), size); + } + + private record StoredObjectCheck(boolean valid, long size) {} + private UploadAuth readAndVerifyUploadAuth(HttpServletRequest req, String fileId) throws AuthFailure { String sessionId = requiredHeader(req, HEADER_SESSION_ID); String timeRaw = requiredHeader(req, HEADER_TIME_MS); diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 6e5fc2ba..8c79392e 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -60,7 +60,9 @@ server.ui.buildHash= # На диске лежит только AES-GCM ciphertext. Имя файла = Base58(SHA-256(ciphertext)). dm.files.enabled=true dm.files.storageDir=data/dm-files -# 50 MiB исходного файла + 16 bytes AES-GCM authentication tag. +# Лимит одного immutable ciphertext-объекта, НЕ всего файла. +# DM file v2 режет файл на 1 MiB части, поэтому общий размер файла этим параметром не ограничен. +# Значение 50 MiB сохранено для совместимости с DM file v1. dm.files.maxBytes=52428816 webpush.vapid.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI diff --git a/VERSION.properties b/VERSION.properties index 1f7dd8ea..b375809a 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.14 -server.version=1.10.5 +client.version=1.12.15 +server.version=1.10.6 diff --git a/bundle.sh b/bundle.sh index e3d1dc27..431aecad 100644 --- a/bundle.sh +++ b/bundle.sh @@ -13,7 +13,13 @@ set -Eeuo pipefail ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT" -OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}" +DEFAULT_OUT=0 +if [[ $# -gt 0 ]]; then + OUT="$1" +else + DEFAULT_OUT=1 + OUT="SHiNE-bundle-$(date +%Y.%m.%d-%H.%M.%S).zip" +fi case "$OUT" in /*) ;; *) OUT="$ROOT/$OUT" ;; @@ -169,7 +175,11 @@ if [[ ! -s "$SAFE_LIST" ]]; then exit 3 fi -rm -f -- "$OUT" +if (( DEFAULT_OUT == 1 )); then + rm -f -- "$ROOT"/SHiNE-bundle-*.zip +else + rm -f -- "$OUT" +fi ( cd "$ROOT" diff --git a/docs/API/18_DM_File_Storage_API.md b/docs/API/18_DM_File_Storage_API.md index ee722303..0e4070ef 100644 --- a/docs/API/18_DM_File_Storage_API.md +++ b/docs/API/18_DM_File_Storage_API.md @@ -25,7 +25,7 @@ dm.files.storageDir=data/dm-files dm.files.maxBytes=52428816 ``` -`dm.files.maxBytes` ограничивает размер ciphertext. Текущий официальный UI ограничивает исходный файл 50 MiB; AES-GCM добавляет 16-byte authentication tag. +`dm.files.maxBytes` ограничивает размер одного ciphertext-объекта. Для legacy DM file v1 это фактически ограничивало целый файл. В DM file v2 файл состоит из 1-MiB ciphertext-объектов, поэтому общий размер логического файла этим параметром не ограничивается. ## 3. PUT `/dm-files/{fileId}` @@ -57,7 +57,7 @@ DM_FILE_UPLOAD_V1:{sessionId}:{fileId}:{encryptedSize}:{timeMs} Сервер потоково пишет временный файл, одновременно считает SHA-256, проверяет фактический размер и только после успешной проверки атомарно перемещает объект под именем `{fileId}`. -Повторная корректно подписанная загрузка уже существующего immutable объекта идемпотентна. +Повторная корректно подписанная загрузка уже существующего immutable объекта идемпотентна. Перед ответом `alreadyExists=true` сервер повторно вычисляет `SHA-256` уже сохранённого объекта и сверяет его с `fileId`; одного совпадения имени файла на диске недостаточно. Если объект повреждён или подменён, сервер удаляет некорректную копию и принимает корректный `PUT` заново. Успешный ответ: @@ -84,16 +84,30 @@ GET не требует пользовательской сессии: `fileId` ## 5. HEAD и OPTIONS -- `HEAD /dm-files/{fileId}` возвращает метаданные ciphertext без тела; +- `HEAD /dm-files/{fileId}` возвращает метаданные ciphertext без тела только после повторной проверки `Base58(SHA-256(ciphertext)) == fileId`; официальный UI использует этот запрос перед `PUT`, чтобы не отправлять уже существующие байты повторно; - `OPTIONS /dm-files/*` обслуживает CORS preflight для браузерного PUT. ## 6. Reverse proxy Caddy/Nginx должен проксировать `/dm-files/*` в тот же Jetty, что обслуживает `/ws`. Route должен находиться до SPA fallback. -## 7. Ограничения v1 +## 7. Ограничения legacy v1 -- файл перед загрузкой целиком читается в память браузера, поэтому UI ограничен 50 MiB; +- legacy-файл перед загрузкой целиком читается в память браузера и ограничен 50 MiB; +- новые отправки официального UI используют v2 и этого ограничения не имеют; - удаления/TTL/garbage collection пока нет; - если ciphertext уже загружен, а отправка E2EE DM затем не удалась, объект может остаться orphan-файлом; - resumable/chunked upload не входит в v1. + +## 8. DM file v2: большие файлы + +Начиная с UI v2 один логический файл не загружается одним HTTP-объектом. Клиент режет plaintext на `1 MiB` части и каждый AES-GCM ciphertext-кусок загружает отдельным обычным `PUT /dm-files/{fileId}`. + +Поэтому `dm.files.maxBytes` — это лимит **одного immutable HTTP-объекта**, а не всего пользовательского файла. При стандартном `chunkSize=1 MiB` общий размер файла этим параметром не ограничивается. + +Манифест также хранится через тот же immutable API: + +- encrypted manifest page — до 256 chunk descriptors; +- encrypted root manifest — ссылки на страницы + BitTorrent v2 root metadata. + +Никаких новых доверенных серверных операций для v2 не требуется: сервер по-прежнему только проверяет подпись PUT, длину и `Base58(SHA-256(ciphertext))`. diff --git a/docs/Personal_Messages/README.md b/docs/Personal_Messages/README.md index 7814b5a8..1c7f5f21 100644 --- a/docs/Personal_Messages/README.md +++ b/docs/Personal_Messages/README.md @@ -9,6 +9,7 @@ - `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — доставка на единственный access-сервер, retry-воркер и UI-статусы - `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `` вставок внутри plaintext DM после расшифровки +- `docs/Personal_Messages/Файлы_DM_v2.md` — большие chunked-вложения, голосовые и BitTorrent v2 SHA-256/Merkle metadata - `docs/API/18_DM_File_Storage_API.md` — HTTP-хранилище зашифрованных файлов DM на access-сервере отправителя Исторический устаревший документ сохранён отдельно: diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 2cebdf2a..5430673b 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -247,3 +247,19 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает Отключение функции «Передача файлов» в локальных дополнительных настройках запрещает только отправку новых файлов; ранее полученные `` остаются скачиваемыми. Хранение ciphertext и HTTP-контракт описаны в `docs/API/18_DM_File_Storage_API.md`. + +## 16. Дедупликация файлов и пересылка сообщений (2026-09-11) + +Для HTTP-хранилища DM-файлов действует усиленное правило content-addressed хранения: + +- перед загрузкой каждого ciphertext-объекта официальный UI делает `HEAD /dm-files/{fileId}`; +- если сервер подтверждает объект с тем же `fileId` и размером, `PUT` с байтами повторно не выполняется; +- сервер перед ответом на `HEAD`, `GET` и перед `alreadyExists=true` сам пересчитывает `SHA-256` сохранённого объекта и сверяет его с `fileId`; +- повреждённый или подменённый объект не считается существующим и не выдаётся клиенту; при следующем корректном `PUT` он может быть записан заново. + +Пересылка личного сообщения не вводит новый тип DM и не добавляет признак «переслано». UI создаёт обычное новое контентное сообщение `type=1/2` выбранному собеседнику: + +- для обычного текста переносится отображаемый текст сообщения; +- исходный `` не переносится, поэтому новое сообщение не остаётся ответом на сообщение из старого чата; +- для сообщения с файлами повторно используются существующие ``-описатели, поэтому ciphertext не шифруется и не загружается повторно; +- сервер и получатель видят пересланное сообщение как обычное новое сообщение без служебной пометки об источнике. diff --git a/docs/Personal_Messages/Технические_вставки_DM_v1.md b/docs/Personal_Messages/Технические_вставки_DM_v1.md index a75585cf..69739762 100644 --- a/docs/Personal_Messages/Технические_вставки_DM_v1.md +++ b/docs/Personal_Messages/Технические_вставки_DM_v1.md @@ -172,3 +172,26 @@ fromLogin|toLogin|timeMs|nonce - сервер не обязан понимать этот формат; - будущие клиенты могут добавлять новые `kind`; - клиенты, которые распознают SHiNE-вставки, должны скрывать неизвестные блоки целиком, если они стоят в начале и корректно закрыты. + +## 10. Расширение `file` v2 + +Клиент обязан продолжать читать `v=1`. Новые chunked-вложения отправляются так: + +```text + +``` + +Дополнительные поля v2: + +- `id` / `url` указывают не на весь файл, а на зашифрованный root manifest; +- `ivp` — случайный 4-byte IV prefix, из которого детерминированно строятся уникальные IV chunks/pages/root; +- `chunk` — plaintext chunk size, сейчас `1048576`; +- `chunks` — число частей; +- `th` — BitTorrent v2 SHA-256 infohash; +- `pr` — BitTorrent v2 pieces root; +- `kind=file|voice`; +- `dur` — длительность voice в миллисекундах, для обычного файла `0`. + +В одном plaintext DM разрешено несколько последовательных `` блоков. Официальный UI собирает их в один список вложений и скрывает fallback-текст. + +Подробный формат chunk encryption, manifest pages и BitTorrent v2 hashing: `Файлы_DM_v2.md`. diff --git a/docs/Personal_Messages/Файлы_DM_v2.md b/docs/Personal_Messages/Файлы_DM_v2.md new file mode 100644 index 00000000..1e3fe64e --- /dev/null +++ b/docs/Personal_Messages/Файлы_DM_v2.md @@ -0,0 +1,124 @@ +# Файлы и голосовые DM v2: chunked AES-GCM + BitTorrent v2 hashes + +## Цели + +DM file v2 убирает ограничение размера исходного файла, не требует держать файл целиком в RAM и сохраняет серверную модель «сервер видит только ciphertext». + +Основные свойства: + +- plaintext режется на куски по `1 MiB`; +- каждый кусок независимо шифруется `AES-256-GCM` одним случайным ключом файла, но с уникальным 96-bit IV; +- каждый ciphertext-кусок хранится как immutable объект `Base58(SHA-256(ciphertext))`; +- список кусков хранится в зашифрованных страницах манифеста по 256 записей; +- корневой манифест тоже зашифрован и content-addressed; +- E2EE DM содержит только ссылку на корневой манифест, AES-key, IV-prefix и пользовательские метаданные; +- один DM может содержать до 10 `` блоков; +- `kind=voice` использует тот же формат хранения и отдельный UI проигрывателя. + +## IV и domain separation + +На один файл создаётся случайный 4-byte `ivPrefix`. + +12-byte IV строится как: + +```text +ivPrefix[4] || uint64_be(token) +``` + +Диапазоны `token` разделены: + +- chunks: `0 .. 2^63-1`; +- manifest pages: `2^63 + pageIndex`; +- root manifest: `0xffffffffffffffff`. + +AAD также содержит домен (`chunk`, `page`, `root`), prefix и индекс. Поэтому перестановка ciphertext-кусков не проходит AES-GCM authentication. + +## Manifest pages + +Каждая страница после расшифровки содержит до 256 записей: + +```json +{ + "v": 2, + "page": 0, + "chunks": [ + { + "i": 0, + "id": "Base58(SHA-256(ciphertext))", + "ps": 1048576, + "es": 1048592, + "ph": "BitTorrent-v2-piece-hash-base64url" + } + ] +} +``` + +Страницы сами AES-GCM зашифрованы и загружаются в `/dm-files/{id}`. + +## Root manifest + +После расшифровки: + +```json +{ + "v": 2, + "scheme": "SHINE-DM-CHUNKED-AES-256-GCM", + "chunkSize": 1048576, + "fileSize": 123456789, + "chunkCount": 118, + "pages": [{"id":"...","count":118,"encryptedSize":12345}], + "torrent": { + "metaVersion": 2, + "blockLength": 16384, + "pieceLength": 1048576, + "piecesRoot": "...", + "infoHash": "..." + } +} +``` + +Имя файла и MIME в manifest не пишутся. Они остаются внутри E2EE DM. + +## BitTorrent v2 совместимость + +Хеширование соответствует BEP 52: + +- базовый hash block: `16 KiB`; +- SHA-256; +- piece length: `1 MiB`; +- Merkle padding leaf = 32 zero bytes; +- `pieces root` вычисляется по правилам BitTorrent v2; +- `infoHash` = `SHA-256(bencode(info dictionary))`; +- piece-layer hash каждого 1-MiB куска хранится в зашифрованной manifest page. + +Из `name`, `size`, `piecesRoot` и списка `ph` можно построить tracker-less `.torrent` v2 без повторного хеширования исходного файла. + +Это совместимость метаданных и проверки контента. HTTP `/dm-files` пока не является BitTorrent peer transport: для настоящего P2P потребуется отдельный seeding/peer слой. + +## Скачивание + +Получатель: + +1. получает и проверяет ciphertext root manifest по Base58(SHA-256); +2. расшифровывает root manifest; +3. по очереди получает manifest pages; +4. получает каждый chunk с сервера отправителя; +5. проверяет content address ciphertext; +6. расшифровывает chunk локально; +7. проверяет BitTorrent-v2 piece hash; +8. пишет plaintext на диск; +9. в конце проверяет итоговый `pieces root`. + +В браузерах с File System Access API plaintext пишется на диск по частям и целиком в RAM не собирается. В остальных браузерах остаётся Blob fallback без искусственного лимита размера, но фактический предел зависит от памяти браузера. + +## Голосовые + +`MediaRecorder` пишет `Opus/WebM`, `Opus/Ogg` или поддерживаемый браузером audio MIME. После завершения запись проходит тот же DM file v2 pipeline. + +Технический блок отличается полями: + +```text +kind=voice;dur= +``` + +Получатель видит player с Play/Pause, прогрессом и длительностью. Аудио расшифровывается только на клиенте. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index d67306f7..88e170d5 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -380,3 +380,16 @@ UI-примечание (байтовый формат не меняет): ра Для контентных `type=1/2` технический блок `` является частью обычного plaintext, который затем попадает в уже существующий зашифрованный `body`. Сам ciphertext внешнего файла в `SHiNE_DM` не включается. Поэтому подписи контейнера, `baseKey`, `revisionTimeMs`, `reencryptedAtMs`, алгоритм E2EE DM и правила парности входящей/исходящей копий остаются прежними. + +## 16. Примечание о пересылке сообщений (2026-09-11) + +Пересылка не меняет байтовый формат `SHiNE_DM` и не добавляет отдельный `messageType` или флаг forward/repost. + +Клиент формирует новый обычный plaintext для `type=1/2`: + +- текстовая часть копируется как новое сообщение; +- `` исходного сообщения удаляется; +- валидные `` могут быть скопированы без изменения, чтобы новое сообщение ссылалось на тот же уже загруженный зашифрованный объект; +- информация о том, из какого чата или сообщения выполнена пересылка, в контейнер не добавляется. + +Таким образом подпись, `baseKey`, шифрование и структура контейнера остаются полностью прежними: меняется только содержимое нового plaintext перед стандартной отправкой. diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md new file mode 100644 index 00000000..eb4db25a --- /dev/null +++ b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md @@ -0,0 +1,1553 @@ +# SHiNE Archive Protocol v1.0 + +## Полная спецификация серверной архивации SHiNE-блоков в Arweave и фиксации головы архива в Solana + +**Статус:** согласованная спецификация v1.0 +**Язык:** русский +**Magic:** `SHINE-ARCHIVE` +**Версия протокола:** `1.0` +**Endian:** big-endian для всех фиксированных целых чисел + +--- + +# 1. Назначение + +Этот документ описывает механизм, по которому SHiNE-сервер периодически: + +1. синхронизирует обычные SHiNE-блоки существующим механизмом; +2. определяет новые локальные блоки, ещё не попавшие в архив; +3. фиксирует неизменяемую дельту; +4. группирует все новые записи одной `blockchain_name` в один блок пользователя внутри большого архивного блока; +5. добавляет к каждому такому пользовательскому блоку одну ссылку на предыдущий пользовательский блок этой же `blockchain_name`; +6. формирует один большой бинарный блок `SHINE-ARCHIVE`; +7. загружает его в Arweave; +8. ждёт закрепления/подтверждения Arweave-транзакции; +9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; +10. только после Solana `finalized` переводит локальные курсоры на новую точку. + +На первом этапе предполагается один архивный Raspberry-сервер, но формат изначально не должен запрещать появление других архиваторов. + +--- + +# 2. Основные понятия + +## 2.1. Исходный SHiNE-блок / запись + +Это существующие в серверной БД оригинальные `block_bytes`, относящиеся к конкретной `blockchain_name`. + +Они уже содержат пользовательские данные и существующую криптографию SHiNE. + +Архиватор: + +- не изменяет их; +- не переподписывает их; +- не переводит их в Base64; +- не пересобирает их содержимое. + +## 2.2. `blockchain_name` + +В архивном формате основной идентификатор пользовательской цепочки — именно `blockchain_name`. + +Отдельно хранить `login` для пользовательского блока не требуется. + +`blockchain_name` уже однозначно идентифицирует конкретную пользовательскую blockchain и логически включает пользовательскую идентичность + номер/вариант цепочки. + +## 2.3. Большой архивный блок + +Один файл/объект `SHINE-ARCHIVE`, который сервер формирует за один цикл архивной публикации. + +Он содержит: + +- header; +- список предыдущих больших архивных блоков; +- по одному `UserBlockchainChunk` для каждой `blockchain_name`, у которой есть новые записи; +- footer; +- SHA-256 большого блока; +- подпись сервера, который этот большой блок сформировал и закрыл. + +Один большой архивный блок после публикации соответствует одной Arweave-транзакции. + +## 2.4. `UserBlockchainChunk` + +В одном большом архивном блоке для одной `blockchain_name` существует максимум один такой chunk. + +Если за текущую дельту у пользователя/цепочки накопилось 1, 3, 5 или 20 записей, все они помещаются внутрь одного `UserBlockchainChunk`. + +Навигационная ссылка одна на весь chunk. + +--- + +# 3. Главная модель пользовательской истории + +Допустим `alice-001` имела записи: + +```text +BigBlock 70: + AliceChunk = 2 записи + +BigBlock 81: + AliceChunk = 5 записей + +BigBlock 95: + AliceChunk = 3 записи + +BigBlock 100: + AliceChunk = 4 записи +``` + +Тогда история `alice-001` выглядит: + +```text +BigBlock 100 + AliceChunk + 4 записи + | + +--> PreviousBlockchainChunkRef + | + v +BigBlock 95 + AliceChunk + 3 записи + | + +--> PreviousBlockchainChunkRef + | + v +BigBlock 81 + AliceChunk + 5 записей + | + v +BigBlock 70 +``` + +Навигация идёт пачками записей одной blockchain, а не по одной записи за раз. + +--- + +# 4. Сервер сам добавляет навигацию + +Пользователь не обязан знать: + +- в какой большой архивный блок попадут его записи; +- какой будет Arweave TX ID; +- какие будут offsets; +- где находится предыдущий chunk. + +Все эти данные сервер добавляет при формировании большого блока. + +Исходные подписанные пользовательские `block_bytes` остаются неизменными. + +`PreviousBlockchainChunkRef` является внешней серверной архивной метаинформацией и защищается hash + подписью всего большого архивного блока. + +--- + +# 5. Необходимые ключи архивного сервера + +Архивный Raspberry/server хранит: + +## 5.1. SHiNE root private key + +Используется: + +- для подписания новой версии User PDA при обычном `update_user_pda`; +- в v1.0 — для подписи закрытого большого архивного блока. + +## 5.2. SHiNE client private key + +Используется как Solana transaction signer / fee payer в соответствии с текущей логикой `shine_users`. + +## 5.3. Arweave JWK / private wallet key + +Используется для загрузки и оплаты Arweave-транзакции. + +## 5.4. Blockchain private key + +Для архивной публикации не требуется. + +Архиватор не создаёт новые пользовательские blockchain-блоки. + +--- + +# 6. Периодичность + +Частота архивной публикации конфигурируется отдельно от существующей межсерверной синхронизации. + +Пример: + +```properties +archive.publish.enabled=true +archive.publish.intervalMinutes=720 +archive.publish.initialDelayMinutes=15 +``` + +`720` минут = раз в 12 часов. + +--- + +# 7. Состояние, относительно которого считается дельта + +Нужно различать: + +1. уже окончательно опубликованное состояние; +2. текущий замороженный job, который ещё проходит Arweave/Solana. + +Нельзя считать дельту от живой головы во время уже запущенной публикации. + +--- + +# 8. Таблица `archive_chain_cursor` + +Хранит окончательно подтверждённое архивное состояние для каждой `blockchain_name`. + +```sql +CREATE TABLE archive_chain_cursor ( + blockchain_name TEXT PRIMARY KEY, + + last_archived_source_block_number BIGINT NOT NULL, + last_archived_source_block_hash BYTEA NOT NULL, + + last_archive_big_block_number BIGINT, + last_archive_big_block_hash BYTEA, + + last_chunk_offset BIGINT, + last_chunk_size BIGINT, + + updated_at_ms BIGINT NOT NULL +); +``` + +Назначение: + +- `last_archived_source_block_number` — до какого исходного SHiNE-блока этой blockchain всё успешно опубликовано; +- `last_archived_source_block_hash` — hash этого исходного блока; +- `last_archive_big_block_number` и `last_archive_big_block_hash` — в каком большом `SHINE-ARCHIVE` находится последний chunk этой blockchain; +- `last_chunk_offset` и `last_chunk_size` — точное положение последнего chunk внутри большого блока. + +Этого достаточно, чтобы следующая публикация создала один backlink на предыдущий chunk. + +--- + +# 9. Отдельный индекс по каждой записи не нужен + +Поскольку все новые записи одной `blockchain_name` агрегируются в один `UserBlockchainChunk`, не нужно хранить offsets каждой отдельной записи в истории. + +Для следующей публикации достаточно помнить: + +```text +предыдущий big block +предыдущий big block hash +chunk offset +chunk size +``` + +--- + +# 10. Таблица `archive_publish_job` + +Хранит crash-safe состояние одной архивной публикации. + +```sql +CREATE TABLE archive_publish_job ( + id BIGSERIAL PRIMARY KEY, + + big_block_number BIGINT NOT NULL, + status TEXT NOT NULL, + + created_at_ms BIGINT NOT NULL, + + local_archive_path TEXT, + + archive_hash BYTEA, + arweave_tx_id BYTEA, + + arweave_confirmations INTEGER, + solana_signature TEXT, + + error_text TEXT, + updated_at_ms BIGINT NOT NULL +); +``` + +Рекомендуемые статусы: + +```text +SNAPSHOT_CREATED +FILE_BUILT +ARWEAVE_UPLOADED +ARWEAVE_CONFIRMED +SOLANA_SUBMITTED +SOLANA_FINALIZED +CURSORS_COMMITTED +FAILED +``` + +Завершённые rows этой таблицы одновременно дают локальную историю больших блоков: + +```text +big_block_number +archive_hash +arweave_tx_id +``` + +По ним строится FULL-список предыдущих больших блоков следующей публикации. + +--- + +# 11. Таблица `archive_publish_job_chain` + +Хранит frozen range каждой blockchain в текущем job и координаты создаваемого chunk. + +```sql +CREATE TABLE archive_publish_job_chain ( + job_id BIGINT NOT NULL, + blockchain_name TEXT NOT NULL, + + from_source_block_number BIGINT NOT NULL, + to_source_block_number BIGINT NOT NULL, + + previous_source_block_hash BYTEA NOT NULL, + last_source_block_hash BYTEA NOT NULL, + + previous_archive_big_block_number BIGINT, + previous_archive_big_block_hash BYTEA, + previous_chunk_offset BIGINT, + previous_chunk_size BIGINT, + + new_chunk_offset BIGINT, + new_chunk_size BIGINT, + + PRIMARY KEY (job_id, blockchain_name) +); +``` + +--- + +# 12. Заморозка дельты + +Пример: + +```text +локально: +alice-001 = source block 180 +bob-001 = source block 94 + +опубликовано: +alice-001 = 160 +bob-001 = 90 +``` + +Новый job фиксирует: + +```text +alice-001: 161..180 +bob-001: 91..94 +``` + +Если во время загрузки появятся: + +```text +alice-001 181..185 +bob-001 95..97 +``` + +они попадут только в следующий job. + +--- + +# 13. Проверка курсора перед публикацией + +Перед созданием frozen range сервер проверяет: + +```text +local hash(last_archived_source_block_number) +== +archive_chain_cursor.last_archived_source_block_hash +``` + +При несовпадении публикация этой chain останавливается до resync/fork handling. + +--- + +# 14. Ограничение размера большого блока + +Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. + +Поэтому один большой archive-файл MUST быть меньше `4 GiB`. + +Рекомендуемый запас: + +```properties +archive.maxFileBytes=4000000000 +``` + +Если данных больше, один scheduler-run формирует несколько последовательных больших блоков. + +--- + +# 15. Magic и версия + +Файл начинается ASCII: + +```text +SHINE-ARCHIVE +``` + +Размер: 13 байт. + +Далее: + +```text +u8 version_major +u8 version_minor +``` + +Для v1.0: + +```text +1 +0 +``` + +--- + +# 16. Общий layout большого блока + +```text ++------------------------------------------+ +| FIXED HEADER | ++------------------------------------------+ +| creator_login | ++------------------------------------------+ +| PREVIOUS BIG BLOCK REFERENCES | +| ref[0] | +| ref[1] | +| ... | ++------------------------------------------+ +| UserBlockchainChunk #1 | +| UserBlockchainChunk #2 | +| ... | ++------------------------------------------+ +| FOOTER: closer_login | +| block_hash | +| signature | ++------------------------------------------+ +``` + +--- + +# 17. Fixed Header v1.0 + +Все integers — big-endian. + +| Поле | Тип | Размер | +|---|---:|---:| +| magic | bytes[13] | 13 | +| version_major | u8 | 1 | +| version_minor | u8 | 1 | +| header_size | u32 | 4 | +| big_block_number | u32 | 4 | +| created_at_ms | u64 | 8 | +| creator_login_length | u8 | 1 | +| references_mode | u8 | 1 | +| references_count | u32 | 4 | +| reference_entry_size | u16 | 2 | +| parent_reference_index | u32 | 4 | +| blockchain_chunks_count | u32 | 4 | +| total_user_records_count | u32 | 4 | + +После fixed header немедленно идут `creator_login` bytes. + +`header_size` — byte offset первого `PreviousBigBlockReference`. + +--- + +# 18. `creator_login` + +SHiNE login пользователя/сервера, который сформировал большой блок. + +```text +u8 creator_login_length +bytes[N] creator_login UTF-8 +``` + +Этот login находится в начале файла, поэтому его можно узнать без скачивания payload. + +--- + +# 19. `big_block_number` + +```text +u32 +``` + +Рекомендуемая нумерация: + +```text +genesis = 0 +next = 1 +next = 2 +... +``` + +--- + +# 20. `created_at_ms` + +```text +u64 +``` + +Unix Epoch UTC milliseconds. + +Это время заморозки содержимого big block. + +--- + +# 21. Режим ссылок на предыдущие большие блоки + +```text +references_mode u8 +``` + +Значения: + +```text +0 = FULL +1 = PARTIAL, зарезервировано на будущее +``` + +Writer v1.0 MUST использовать `FULL`. + +--- + +# 22. FULL previous-block table + +Новый big block содержит ссылки на ВСЕ предыдущие большие блоки своей ветки. + +Пример: + +```text +BigBlock #365 + +References: +#0 +#1 +#2 +... +#364 +``` + +При одном big block в день через год таблица занимает примерно 24.8 KiB. + +--- + +# 23. `PreviousBigBlockReference` + +Фиксированный размер: 68 байт. + +| Поле | Тип | Размер | +|---|---:|---:| +| big_block_number | u32 | 4 | +| big_block_hash | bytes[32] | 32 | +| arweave_tx_id | bytes[32] | 32 | + +Семантика: + +```text +big_block_number = логический номер +big_block_hash = криптографическая идентичность +arweave_tx_id = где скачать блок +``` + +--- + +# 24. `references_count` + +Количество entries в таблице ссылок. + +```text +u32 +``` + +--- + +# 25. `reference_entry_size` + +```text +u16 +``` + +Для v1.0: + +```text +68 +``` + +Поле оставлено для будущего расширения entry. + +--- + +# 26. `parent_reference_index` + +```text +u32 +``` + +Индекс непосредственного родителя внутри reference table. + +Для genesis: + +```text +0xFFFFFFFF +``` + +В простой FULL-линейной ветке обычно это последний entry. + +--- + +# 27. Быстрое скачивание начала блока + +Клиент сначала скачивает fixed header + creator login. + +Из него узнаёт: + +```text +header_size +references_count +reference_entry_size +``` + +Затем скачивает только reference table и уже имеет адреса/hash всех предыдущих больших блоков. + +Payload пользователей можно пока не скачивать. + +--- + +# 28. `blockchain_chunks_count` + +```text +u32 +``` + +Количество `UserBlockchainChunk` в текущем big block. + +Одна `blockchain_name` встречается максимум один раз. + +--- + +# 29. `total_user_records_count` + +```text +u32 +``` + +Общее количество исходных SHiNE-записей во всех chunks. + +--- + +# 30. Порядок chunks и records + +Для детерминированной сериализации chunks SHOULD сортироваться по `blockchain_name ASC`. + +Внутри chunk исходные записи MUST идти по возрастанию номера исходного SHiNE-блока. + +--- + +# 31. Формат `UserBlockchainChunk` + +```text +u32 chunk_size + +u8 blockchain_name_length +bytes[N] blockchain_name UTF-8 + +u32 records_count + +repeat records_count: + u32 record_size + bytes[M] raw_record_bytes + +u32 previous_big_block_ref +u32 previous_chunk_offset +u32 previous_chunk_size +``` + +--- + +# 32. `chunk_size` + +```text +u32 +``` + +Полный размер chunk от первого байта `chunk_size` до последнего байта `previous_chunk_size` включительно. + +Это позволяет: + +- быстро пропустить chunk; +- скачать его Range-запросом; +- сохранить его точные координаты для следующего backlink. + +--- + +# 33. `blockchain_name` внутри chunk + +Хранится только `blockchain_name`. + +Отдельный `login` не нужен. + +--- + +# 34. `records_count` + +```text +u32 +``` + +Если за период у `alice-001` появилось 7 записей: + +```text +records_count = 7 +``` + +Все 7 находятся в одном chunk. + +--- + +# 35. Raw records + +Каждая исходная запись сериализуется: + +```text +u32 record_size +bytes[record_size] raw_record_bytes +``` + +`raw_record_bytes` — оригинальные данные SHiNE из БД. + +Сервер не меняет их содержимое. + +--- + +# 36. Один backlink на весь chunk + +После всех raw records находится ровно один `PreviousBlockchainChunkRef`. + +Он относится ко всей пачке записей blockchain. + +Он не повторяется у отдельных записей. + +--- + +# 37. `PreviousBlockchainChunkRef` + +| Поле | Тип | Размер | +|---|---:|---:| +| previous_big_block_ref | u32 | 4 | +| previous_chunk_offset | u32 | 4 | +| previous_chunk_size | u32 | 4 | + +Итого 12 байт на весь chunk. + +--- + +# 38. `previous_big_block_ref` + +Индекс в `PreviousBigBlockReferences[]` текущего big block. + +Он указывает на тот предыдущий big block, где находится предыдущий chunk этой же `blockchain_name`. + +Это именно reference index, а не номер блока. + +--- + +# 39. `previous_chunk_offset` + +```text +u32 +``` + +Byte offset от начала предыдущего `SHINE-ARCHIVE` до первого байта предыдущего chunk этой blockchain. + +Первый байт chunk — поле `chunk_size`. + +--- + +# 40. `previous_chunk_size` + +```text +u32 +``` + +Полный размер предыдущего chunk. + +Поэтому клиент может Range-запросом забрать сразу всю предыдущую пачку записей пользователя. + +--- + +# 41. Первая публикация blockchain + +Если у данной `blockchain_name` нет предыдущего архивного chunk: + +```text +previous_big_block_ref = 0xFFFFFFFF +previous_chunk_offset = 0 +previous_chunk_size = 0 +``` + +--- + +# 42. Пример backlink-цепочки + +В `BigBlock #100` есть `alice-001` chunk из 4 записей. + +Его backlink: + +```text +previous_big_block_ref = 94 +previous_chunk_offset = 8123 +previous_chunk_size = 1770 +``` + +`ref[94]` содержит: + +```text +big_block_number = 95 +big_block_hash = ... +arweave_tx_id = ... +``` + +Клиент: + +1. получает TX big block #95; +2. проверяет hash; +3. делает Range на `8123 .. 8123+1770`; +4. получает весь предыдущий `alice-001` chunk; +5. читает его записи; +6. берёт следующий backlink. + +--- + +# 43. Почему TX ID не хранится в каждом chunk + +TX ID и hash предыдущих больших блоков уже один раз записаны в общей reference table. + +Поэтому в chunk достаточно компактного `u32 previous_big_block_ref`. + +--- + +# 44. Footer большого блока + +После последнего chunk: + +```text +u8 closer_login_length +bytes[N] closer_login UTF-8 + +bytes[32] block_hash +bytes[64] closer_signature +``` + +--- + +# 45. `closer_login` + +Login пользователя/сервера, который сформировал и закрыл big block. + +В v1.0: + +```text +closer_login MUST == creator_login +``` + +Login повторяется намеренно: + +- header даёт автора сразу; +- footer явно фиксирует того, кто закрыл блок; +- hash покрывает footer login; +- verifier проверяет совпадение. + +--- + +# 46. `block_hash` + +```text +bytes[32] +SHA-256 +``` + +Хэшируется всё от первого байта magic до последнего байта `closer_login` включительно. + +Не входят: + +```text +block_hash +closer_signature +``` + +Псевдокод: + +```text +hash_input = + fixed_header + + creator_login + + references + + all UserBlockchainChunks + + closer_login_length + + closer_login + +block_hash = SHA256(hash_input) +``` + +--- + +# 47. `closer_signature` + +```text +bytes[64] +Ed25519 +``` + +Подписывается: + +```text +ASCII("SHINE-ARCHIVE-V1") || block_hash +``` + +Для v1.0 используется root private key аккаунта `creator_login`. + +--- + +# 48. Проверка подписи + +Verifier: + +1. читает `creator_login`; +2. проверяет `creator_login == closer_login`; +3. получает root public key аккаунта из User PDA / исторического состояния Solana; +4. пересчитывает `block_hash`; +5. проверяет Ed25519 signature. + +Старые архивы должны оставаться проверяемыми после ротации root key, поэтому исторические PDA-состояния нужно сохранять/уметь получать. + +--- + +# 49. Собственный Arweave TX ID текущего блока + +Текущий файл не может заранее знать свой будущий TX ID. + +Поэтому собственный TX ID: + +- не входит в сам текущий файл; +- сохраняется после upload; +- записывается в Solana PDA; +- появляется как previous-block reference уже в следующем big block. + +--- + +# 50. User PDA block type `100` + +Archive head хранится прямо в существующем User PDA publisher-а. + +Формат: + +```text +u8 block_type = 100 +u8 block_version = 0 +bytes[32] archive_tx_id +bytes[32] archive_hash +``` + +Итого 66 байт. + +`archive_hash` = `block_hash` последнего успешно опубликованного big block. + +--- + +# 51. Обновление Solana + +Отдельная новая Solana instruction не требуется. + +Используется существующий `update_user_pda`. + +Нужно расширить существующие serializer/parser/codec так, чтобы block type 100: + +- читался; +- записывался; +- сохранялся обычными обновлениями; +- мог быть изменён архивным сервером обычным update User PDA. + +--- + +# 52. Полный порядок публикации + +```text +1. Существующая server-to-server sync независимо обновляет локальные blockchains. + +2. Scheduler запускает archive job. + +3. Проверяется отсутствие другого активного archive job. + +4. Читаются archive_chain_cursor. + +5. Читаются текущие локальные blockchain heads. + +6. Для каждой blockchain вычисляется frozen delta. + +7. Создаётся archive_publish_job. + +8. Создаются archive_publish_job_chain rows. + +9. Загружается история всех успешно finalized previous big blocks. + +10. Формируется fixed header. + +11. Записывается creator_login. + +12. Записывается FULL PreviousBigBlockReferences. + +13. Для каждой blockchain_name: + - читается frozen range; + - создаётся ровно один UserBlockchainChunk; + - внутрь кладутся все новые records; + - добавляется один PreviousBlockchainChunkRef; + - сохраняются new_chunk_offset/new_chunk_size. + +14. Записывается closer_login. + +15. Вычисляется SHA-256 block_hash. + +16. Создаётся Ed25519 closer_signature. + +17. Файл закрывается. + +18. Файл загружается в Arweave. + +19. TX ID сохраняется в archive_publish_job. + +20. Сервер ждёт требуемое число Arweave confirmations. + +21. Обычным update_user_pda обновляется block type 100: + archive_tx_id + archive_hash + +22. Сервер ждёт Solana finalized. + +23. В одной DB transaction обновляются archive_chain_cursor. + +24. Job получает CURSORS_COMMITTED. +``` + +--- + +# 53. Cursor commit + +Для каждой blockchain из job: + +```text +last_archived_source_block_number = to_source_block_number +last_archived_source_block_hash = last_source_block_hash + +last_archive_big_block_number = current big_block_number +last_archive_big_block_hash = current block_hash + +last_chunk_offset = new_chunk_offset +last_chunk_size = new_chunk_size +``` + +Следующая публикация сразу знает, куда должен вести backlink. + +--- + +# 54. Arweave service + +Старый `TestFreeAvatarArweaveService` больше не нужен как avatar-specific сервис. + +Его следует переделать/переименовать, например в: + +```text +ArweaveArchiveService +``` + +Он должен: + +- использовать Arweave JWK; +- подписывать и оплачивать upload; +- поддерживать большие/chunked uploads; +- возвращать TX ID; +- ждать подтверждения; +- опрашивать confirmations. + +Рекомендуемые настройки: + +```properties +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 +``` + +--- + +# 55. Solana finalization + +После обычного `update_user_pda` сервер должен дождаться: + +```text +finalized +``` + +Только после этого разрешается сдвинуть локальные курсоры. + +--- + +# 56. Crash recovery + +## После `SNAPSHOT_CREATED` + +Возобновить тот же frozen job. + +## После `FILE_BUILT` + +Использовать тот же локальный файл. + +## После `ARWEAVE_UPLOADED` + +Не загружать второй раз. Продолжить по сохранённому TX ID. + +## После `ARWEAVE_CONFIRMED` + +Продолжить Solana update. + +## После `SOLANA_SUBMITTED` + +Сначала проверить transaction / текущий PDA. + +## После `SOLANA_FINALIZED`, но до cursor commit + +Проверить: + +```text +PDA.archive_tx_id == expected_tx +PDA.archive_hash == expected_hash +``` + +После этого безопасно выполнить cursor commit. + +--- + +# 57. Нет новых данных + +Если новых записей нет ни в одной blockchain: + +```text +не создавать пустой SHINE-ARCHIVE +не загружать Arweave +не обновлять Solana +``` + +--- + +# 58. FULL references как стартовая стратегия + +v1.0 сознательно хранит ВСЕ previous big blocks в каждом новом big block. + +Плюсы: + +- актуальный блок сразу даёт карту всей предыдущей истории; +- не нужно идти parent-by-parent; +- backlink может использовать компактный ref index; +- reader очень простой. + +При одном big block в сутки: + +```text +1 год: ~24.8 KiB +5 лет: ~121 KiB +20 лет: ~485 KiB +``` + +--- + +# 59. PARTIAL references + +`references_mode = 1` зарезервирован на будущее. + +v1.0 writer его не реализует. + +В будущем PARTIAL может означать: + +- только реально используемые previous blocks; +- последние N blocks; +- checkpoint references; +- смешанную стратегию. + +--- + +# 60. Чтение истории одной blockchain + +Имея актуальный chunk `alice-001`, клиент: + +1. читает все records chunk; +2. читает `PreviousBlockchainChunkRef`; +3. если ref = `0xFFFFFFFF`, история закончилась; +4. иначе берёт соответствующий `PreviousBigBlockReference`; +5. получает TX ID + hash previous big block; +6. Range-запросом читает `previous_chunk_offset/previous_chunk_size`; +7. получает сразу всю предыдущую пачку записей `alice-001`; +8. повторяет. + +Так можно читать историю конкретной blockchain, не скачивая чужие chunks. + +--- + +# 61. Дубликаты + +Если исходная SHiNE-запись уже есть у получателя, она пропускается/дедуплицируется стандартной логикой. + +--- + +# 62. Конфликты исходной blockchain + +Архивная ветка сама по себе не является пользовательским конфликтом. + +Конфликт возникает, когда одна `blockchain_name` имеет два несовместимых валидно подписанных продолжения одной предыдущей точки. + +Для v1 безопасно: + +```text +сохранить обе ветки +пометить blockchain CONFLICTED +остановить автоматическое продолжение +``` + +Механизм разрешения fork можно добавить позже. + +--- + +# 63. Авторство большого блока + +Автор указан дважды: + +## Header + +```text +creator_login +``` + +## Footer + +```text +closer_login +block_hash +closer_signature +``` + +Для v1.0: + +```text +creator_login == closer_login +``` + +Это означает: именно этот SHiNE-аккаунт сформировал и криптографически закрыл big block. + +--- + +# 64. Подпись сервера не заменяет подписи пользователей + +`closer_signature` доказывает авторство большого archive block. + +Каждая пользовательская raw record всё равно проверяется существующей криптографией SHiNE. + +--- + +# 65. Независимость от Arweave + +Arweave TX ID — location. + +SHA-256 — identity. + +Поэтому архивы можно перенести на другой storage и всё равно проверять. + +--- + +# 66. Block type 100 в Solana — текущая голова + +User PDA publisher-а хранит только: + +```text +current archive TX ID +current archive SHA-256 +``` + +Детальная история находится внутри big blocks. + +--- + +# 67. Startup preflight + +Перед запуском archive publisher сервер SHOULD проверить: + +```text +derivePublic(root_private) == UserPDA.root_public_key +``` + +и: + +```text +derivePublic(client_private) == UserPDA.client_key +``` + +При несовпадении archive publisher не запускается. + +--- + +# 68. Рекомендуемые настройки + +```properties +archive.publish.enabled=false +archive.publish.intervalMinutes=720 +archive.publish.initialDelayMinutes=15 + +archive.maxFileBytes=4000000000 + +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 + +archive.solana.rootKeyPath=/opt/shine/secrets/root.key +archive.solana.clientKeyPath=/opt/shine/secrets/client.key +archive.solana.commitment=finalized +``` + +--- + +# 69. Требование к `AppConfig` + +Archive settings должны читаться из: + +- Java system properties; +- environment variables; +- `application.properties`. + +`getInt()` и `getBoolean()` должны использовать общую логику `getParam()`. + +--- + +# 70. Что переиспользуется из текущего сервера + +Существующие механизмы: + +```text +BlockchainStateDAO.listAll() +BlocksDAO.listRangeByNumber(...) +``` + +дают необходимые heads и raw block ranges. + +Используются существующие: + +```text +block_bytes +block_hash +block_signature +blockchain_name +block_number +``` + +Server-to-server sync в v1.0 не меняется. + +--- + +# 71. Блокировки + +Можно использовать существующий blockchain lock только на коротком этапе: + +```text +проверить cursor hash +прочитать frozen range +проверить конечный hash +сохранить/сериализовать frozen data +``` + +Lock не удерживается во время Arweave/Solana ожиданий. + +--- + +# 72. Реализационный checklist + +## Database + +- [ ] `archive_chain_cursor` +- [ ] `archive_publish_job` +- [ ] `archive_publish_job_chain` +- [ ] schema migration +- [ ] crash recovery + +## Archive writer + +- [ ] magic `SHINE-ARCHIVE` +- [ ] major/minor version +- [ ] fixed header +- [ ] creator login +- [ ] FULL previous big block table +- [ ] one chunk per `blockchain_name` +- [ ] many raw records inside one chunk +- [ ] one backlink per chunk +- [ ] closer login +- [ ] SHA-256 +- [ ] Ed25519 signature +- [ ] max file size < 4 GiB + +## Arweave + +- [ ] rename/refactor `TestFreeAvatarArweaveService` +- [ ] remove avatar-specific logic +- [ ] large/chunked upload +- [ ] confirmation polling + +## Solana + +- [ ] add PDA block type `100` +- [ ] update Rust codec +- [ ] update Java codec +- [ ] update JS codec/UI writer +- [ ] use ordinary `update_user_pda` +- [ ] server-side transaction writer +- [ ] root signature +- [ ] client fee payer +- [ ] wait for `finalized` + +## Scheduler + +- [ ] `archive.publish.enabled` +- [ ] `archive.publish.intervalMinutes` +- [ ] `archive.publish.initialDelayMinutes` +- [ ] no concurrent jobs +- [ ] split files > max size +- [ ] skip when no new blocks + +--- + +# 73. Итоговая бинарная схема v1.0 + +```text +SHINE-ARCHIVE +version_major u8 +version_minor u8 + +header_size u32 +big_block_number u32 +created_at_ms u64 + +creator_login_length u8 + +references_mode u8 +references_count u32 +reference_entry_size u16 +parent_reference_index u32 + +blockchain_chunks_count u32 +total_user_records_count u32 + +creator_login bytes + + +PreviousBigBlockReference[references_count]: + + big_block_number u32 + big_block_hash [32] + arweave_tx_id [32] + + +UserBlockchainChunk[blockchain_chunks_count]: + + chunk_size u32 + + blockchain_name_length u8 + blockchain_name bytes + + records_count u32 + + repeat records_count: + record_size u32 + raw_record_bytes + + previous_big_block_ref u32 + previous_chunk_offset u32 + previous_chunk_size u32 + + +FOOTER: + + closer_login_length u8 + closer_login bytes + + block_hash [32] + closer_signature [64] +``` + +--- + +# 74. Основная архитектура одним рисунком + +```text +SOLANA USER PDA + | + | block type 100 + | TX + HASH + v +BigBlock #100 + | + +-- Header + | creator = server-A + | + +-- References + | #0 -> BigBlock #0 / hash / TX + | #1 -> BigBlock #1 / hash / TX + | ... + | #99 -> BigBlock #99 / hash / TX + | + +-- alice-001 chunk + | records x4 + | | + | +--> BigBlock #95 / AliceChunk + | + +-- bob-001 chunk + | records x2 + | | + | +--> BigBlock #98 / BobChunk + | + +-- kate-002 chunk + | records x10 + | | + | +--> BigBlock #74 / KateChunk + | + +-- Footer + closer = server-A + SHA-256 + Ed25519 signature +``` + +--- + +# 75. Итоговые решения v1.0 + +- один большой блок = одна архивная публикация; +- в начале лежит полный список всех предыдущих больших блоков; +- каждый previous block reference содержит `number + SHA-256 + Arweave TX ID`; +- одна `blockchain_name` встречается в большом блоке максимум одним chunk; +- все новые записи этой blockchain объединяются в этот chunk; +- chunk имеет одну ссылку на предыдущий chunk этой же blockchain; +- backlink состоит из `previous big block ref + chunk offset + chunk size`; +- сервер сам создаёт backlink; +- пользовательские raw records не меняются; +- login сервера есть в header и footer; +- большой блок закрывается SHA-256 + Ed25519 signature сервера; +- Solana User PDA block `100` хранит текущий TX ID + archive hash; +- PDA обновляется обычным `update_user_pda`; +- Arweave используется как долговременное хранилище; +- публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. diff --git a/shine-UI/firebase-messaging-sw.js b/shine-UI/firebase-messaging-sw.js index 855678ee..47df1f1f 100644 --- a/shine-UI/firebase-messaging-sw.js +++ b/shine-UI/firebase-messaging-sw.js @@ -131,7 +131,7 @@ self.addEventListener('push', (event) => { const notifyPromise = shouldNotify ? self.registration.showNotification(notificationTitle, { - body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'), + body: body || (fromLogin ? `Новое сообщение от ${fromLogin}` : 'Новое сообщение'), tag: callId || (kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message'), renotify: true, requireInteraction: kind === 'incoming_call', diff --git a/shine-UI/js/pages/advanced-settings-view.js b/shine-UI/js/pages/advanced-settings-view.js index 814b0fda..56319582 100644 --- a/shine-UI/js/pages/advanced-settings-view.js +++ b/shine-UI/js/pages/advanced-settings-view.js @@ -41,14 +41,14 @@ export function render({ navigate, chrome }) { const developerRow = createToggleRow({ id: 'advanced-developer-tools', title: 'Настройки разработчика', - hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках.', + hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках. По умолчанию выключено.', checked: isDeveloperToolsEnabled(), }); const filesRow = createToggleRow({ id: 'advanced-dm-files', title: 'Передача файлов в личных сообщениях', - hint: 'Разрешить отправку новых зашифрованных файлов через удержание кнопки эмодзи. Полученные ранее файлы останутся доступными.', + hint: 'Разрешить зашифрованные файлы любого размера, несколько файлов за сообщение и голосовые. Полученные ранее файлы останутся доступными.', checked: isDmFileTransferEnabled(), }); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 67297f0d..fa1429b2 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -32,9 +32,11 @@ import { showToast } from '../services/channels-ux.js'; import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; import { isDmFileTransferEnabled } from '../services/feature-settings.js'; import { + decryptDmFileToBlob, downloadAndDecryptDmFile, encryptAndUploadDmFile, formatDmFileSize, + DM_MAX_ATTACHMENTS_PER_MESSAGE, } from '../services/dm-file-service.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js'; @@ -56,6 +58,7 @@ const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очи function menuIconSvg(name) { const paths = { reply: '', + forward: '', copy: '', read: '', edit: '', @@ -293,16 +296,214 @@ function openDeleteChatConfirmModal({ contactName = '', relationType = 'none', o }); } +function buildForwardedDmText(message = {}) { + const parsed = parseDmTechBlocks(String(message?.text || '')); + const attachments = Array.isArray(parsed?.fileAttachments) ? parsed.fileAttachments : []; + const attachmentBlocks = attachments.map((attachment) => buildDmFileTechBlock(attachment)).join(''); + const visibleText = sanitizeUserDmTextForSend(String(parsed?.displayText || parsed?.visibleText || '').trim()); + if (attachmentBlocks) { + const fallback = visibleText || (attachments.length === 1 + ? `📎 ${String(attachments[0]?.name || 'file')}` + : `📎 Файлы: ${attachments.length}`); + return `${attachmentBlocks}${fallback}`; + } + return visibleText; +} + +function clipForwardPreview(text = '', maxLen = 130) { + const normalized = String(text || '').replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxLen) return normalized; + return `${normalized.slice(0, Math.max(1, maxLen - 1))}…`; +} + +async function openForwardMessageDialog({ message, sourceChatId = '', onConfirm } = {}) { + const root = document.getElementById('modal-root'); + if (!root) return; + + const parsed = parseDmTechBlocks(String(message?.text || '')); + const preview = clipForwardPreview(parsed?.displayText || parsed?.visibleText || 'Вложение'); + root.innerHTML = ` + + `; + + const close = () => { root.innerHTML = ''; }; + root.querySelector('#chat-forward-close')?.addEventListener('click', close); + root.querySelector('#chat-forward-modal')?.addEventListener('click', (event) => { + if (event.target?.id === 'chat-forward-modal') close(); + }); + + const previewEl = root.querySelector('#chat-forward-preview'); + if (previewEl) { + previewEl.textContent = preview || (parsed?.fileAttachments?.length ? 'Вложение' : 'Сообщение'); + } + + const list = root.querySelector('#chat-forward-list'); + if (!list) return; + + try { + const payload = await authService.listContacts(); + if (!root.querySelector('#chat-forward-modal')) return; + const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : []; + const byLogin = new Map(); + dialogs.forEach((dialog) => { + const peerLogin = normalizeDmChatId(dialog?.peerLogin); + if (!peerLogin) return; + byLogin.set(peerLogin.toLowerCase(), { + peerLogin, + firstName: String(dialog?.firstName || '').trim(), + lastName: String(dialog?.lastName || '').trim(), + lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0), + }); + }); + Object.keys(state.chats || {}).forEach((chatKey) => { + const peerLogin = normalizeDmChatId(chatKey); + if (!peerLogin) return; + const key = peerLogin.toLowerCase(); + if (!byLogin.has(key)) byLogin.set(key, { peerLogin, firstName: '', lastName: '', lastMessageTimeMs: 0 }); + }); + const sourceLogin = normalizeDmChatId(sourceChatId); + if (sourceLogin && !byLogin.has(sourceLogin.toLowerCase())) { + byLogin.set(sourceLogin.toLowerCase(), { peerLogin: sourceLogin, firstName: '', lastName: '', lastMessageTimeMs: 0 }); + } + + const rows = Array.from(byLogin.values()).sort((a, b) => { + const timeDiff = Number(b.lastMessageTimeMs || 0) - Number(a.lastMessageTimeMs || 0); + if (timeDiff) return timeDiff; + return a.peerLogin.localeCompare(b.peerLogin, 'ru'); + }); + list.replaceChildren(); + if (!rows.length) { + const empty = document.createElement('div'); + empty.className = 'meta-muted dm-forward-loading'; + empty.textContent = 'Нет доступных чатов'; + list.append(empty); + return; + } + + rows.forEach((item) => { + const fullName = [item.firstName, item.lastName].filter(Boolean).join(' '); + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'ui-button dm-forward-chat-row'; + + const avatar = renderUserAvatar({ + login: item.peerLogin, + firstName: item.firstName, + lastName: item.lastName, + size: 'md', + className: 'dm-forward-avatar', + title: item.peerLogin, + }); + const copy = document.createElement('span'); + copy.className = 'dm-forward-chat-copy'; + const name = document.createElement('strong'); + name.textContent = fullName || item.peerLogin; + const login = document.createElement('span'); + login.className = 'meta-muted'; + login.textContent = `@${item.peerLogin}`; + copy.append(name, login); + const arrow = document.createElement('span'); + arrow.className = 'dm-forward-chat-arrow'; + arrow.textContent = '›'; + row.append(avatar, copy, arrow); + + row.addEventListener('click', () => { + const card = root.querySelector('.dm-forward-card'); + if (!card) return; + card.innerHTML = ''; + + const confirmTitle = document.createElement('h3'); + confirmTitle.className = 'modal-title'; + confirmTitle.textContent = 'Переслать сюда?'; + const recipient = document.createElement('div'); + recipient.className = 'dm-forward-confirm-peer'; + recipient.append( + renderUserAvatar({ login: item.peerLogin, firstName: item.firstName, lastName: item.lastName, size: 'lg' }), + ); + const recipientCopy = document.createElement('div'); + const recipientName = document.createElement('strong'); + recipientName.textContent = fullName || item.peerLogin; + const recipientLogin = document.createElement('div'); + recipientLogin.className = 'meta-muted'; + recipientLogin.textContent = `@${item.peerLogin}`; + recipientCopy.append(recipientName, recipientLogin); + recipient.append(recipientCopy); + + const confirmPreview = document.createElement('div'); + confirmPreview.className = 'dm-forward-preview dm-forward-preview--confirm'; + confirmPreview.textContent = preview || (parsed?.fileAttachments?.length ? 'Вложение' : 'Сообщение'); + + const actions = document.createElement('div'); + actions.className = 'form-actions-grid dm-forward-confirm-actions'; + const back = document.createElement('button'); + back.type = 'button'; + back.className = 'secondary-btn'; + back.textContent = 'Назад'; + const confirm = document.createElement('button'); + confirm.type = 'button'; + confirm.className = 'primary-btn'; + confirm.textContent = 'Переслать'; + actions.append(back, confirm); + card.append(confirmTitle, recipient, confirmPreview, actions); + + back.addEventListener('click', () => { + close(); + void openForwardMessageDialog({ message, sourceChatId, onConfirm }); + }); + confirm.addEventListener('click', async () => { + if (confirm.disabled) return; + confirm.disabled = true; + back.disabled = true; + confirm.textContent = 'Отправляем…'; + try { + await onConfirm?.(item.peerLogin); + close(); + showToast(`Сообщение переслано в чат @${item.peerLogin}`, { timeoutMs: 1500 }); + } catch (error) { + confirm.disabled = false; + back.disabled = false; + confirm.textContent = 'Переслать'; + showToast(error?.message || 'Не удалось переслать сообщение', { kind: 'error', timeoutMs: 2400 }); + } + }); + }); + list.append(row); + }); + } catch (error) { + list.replaceChildren(); + const fail = document.createElement('div'); + fail.className = 'meta-muted dm-forward-loading'; + fail.textContent = `Не удалось загрузить чаты: ${error?.message || 'unknown'}`; + list.append(fail); + } +} + function openMessageActionsMenu({ anchorX = 0, anchorY = 0, messageText = '', infoText = '', canReply = false, + canForward = true, showReadAloud = true, canEdit = false, canDelete = false, onReply, + onForward, onReadAloud, onEdit, onDelete, @@ -315,6 +516,7 @@ function openMessageActionsMenu({
+ - +
- + - +
`; @@ -1181,6 +1539,10 @@ export function render({ navigate, route, chrome }) { const emojiToggle = form.querySelector('#chat-emoji-toggle'); const fileToggle = form.querySelector('#chat-file-toggle'); const fileInput = form.querySelector('#chat-file-input'); + const sendBtn = form.querySelector('.dm-send-btn'); + const voiceStatus = form.querySelector('#chat-voice-recording-status'); + const voiceTime = form.querySelector('#chat-voice-recording-time'); + const voiceHint = form.querySelector('#chat-voice-recording-hint'); const fileTransferEnabled = isDmFileTransferEnabled(); const editBanner = form.querySelector('#chat-edit-banner'); const editBannerText = form.querySelector('#chat-edit-banner-text'); @@ -1193,6 +1555,18 @@ export function render({ navigate, route, chrome }) { let emojiLongPressTimer = null; let emojiLongPressTriggered = false; let fileActionVisible = false; + let voiceRecorder = null; + let voiceStream = null; + let voiceChunks = []; + let voiceStartedAt = 0; + let voiceTimer = null; + let voiceLocked = false; + let voicePointerHeld = false; + let voiceReleaseRequested = false; + let voiceStartY = 0; + let voicePointerId = null; + let voiceBusy = false; + let suppressVoiceClick = false; const setHistoryLoadingState = (isLoading) => { historyLoader.hidden = !isLoading; @@ -1233,6 +1607,217 @@ export function render({ navigate, route, chrome }) { syncComposerLayout(); }; + + const sendIconMarkup = (mode) => { + if (mode === 'send') { + return ''; + } + if (mode === 'stop') { + return ''; + } + if (mode === 'busy') { + return '…'; + } + return ''; + }; + + const currentVoiceDurationMs = () => voiceStartedAt > 0 ? Math.max(0, Date.now() - voiceStartedAt) : 0; + + const syncVoiceTimer = () => { + if (voiceTime) voiceTime.textContent = formatVoiceDuration(currentVoiceDurationMs()); + }; + + const clearVoiceTimer = () => { + if (voiceTimer != null) { + window.clearInterval(voiceTimer); + voiceTimer = null; + } + }; + + const stopVoiceStreamTracks = () => { + try { + voiceStream?.getTracks?.().forEach((track) => track.stop()); + } catch { + // ignore microphone cleanup failures + } + voiceStream = null; + }; + + const updateSendButtonMode = () => { + if (!sendBtn) return; + const recording = Boolean(voiceRecorder && voiceRecorder.state !== 'inactive'); + let mode = 'voice'; + if (voiceBusy) mode = 'busy'; + else if (recording && voiceLocked) mode = 'stop'; + else if (recording) mode = 'voice'; + else if (activeEdit || String(input?.value || '').trim()) mode = 'send'; + + sendBtn.dataset.mode = mode; + sendBtn.innerHTML = sendIconMarkup(mode); + sendBtn.classList.toggle('is-recording', recording); + sendBtn.classList.toggle('is-locked', recording && voiceLocked); + sendBtn.disabled = voiceBusy || (mode === 'voice' && !recording && !fileTransferEnabled); + if (mode === 'send') { + sendBtn.title = 'Отправить'; + sendBtn.setAttribute('aria-label', 'Отправить'); + } else if (mode === 'stop') { + sendBtn.title = 'Остановить и отправить голосовое'; + sendBtn.setAttribute('aria-label', 'Остановить и отправить голосовое'); + } else if (mode === 'busy') { + sendBtn.title = 'Шифрование и отправка голосового'; + sendBtn.setAttribute('aria-label', 'Шифрование и отправка голосового'); + } else { + sendBtn.title = 'Удерживайте для записи голосового'; + sendBtn.setAttribute('aria-label', 'Записать голосовое сообщение'); + } + form.classList.toggle('dm-chat-input--recording', recording); + if (voiceStatus) voiceStatus.hidden = !recording; + if (input) input.hidden = recording; + if (emojiToggle) emojiToggle.hidden = recording; + if (fileToggle && recording) fileToggle.hidden = true; + if (!recording && voiceHint) voiceHint.textContent = '↑ проведите вверх, чтобы закрепить'; + syncVoiceTimer(); + }; + + const selectVoiceMimeType = () => { + const candidates = [ + 'audio/webm;codecs=opus', + 'audio/ogg;codecs=opus', + 'audio/mp4', + 'audio/webm', + ]; + return candidates.find((mime) => globalThis.MediaRecorder?.isTypeSupported?.(mime)) || ''; + }; + + const voiceExtension = (mime = '') => { + const value = String(mime || '').toLowerCase(); + if (value.includes('ogg')) return 'ogg'; + if (value.includes('mp4') || value.includes('m4a')) return 'm4a'; + return 'webm'; + }; + + const stopVoiceRecording = ({ send = true } = {}) => { + voicePointerHeld = false; + voiceReleaseRequested = true; + if (!voiceRecorder || voiceRecorder.state === 'inactive') return; + voiceRecorder.__shineShouldSend = Boolean(send); + try { + voiceRecorder.stop(); + } catch { + stopVoiceStreamTracks(); + } + }; + + const startVoiceRecording = async ({ pointerY = 0, pointerId = null } = {}) => { + if (voiceBusy || (voiceRecorder && voiceRecorder.state !== 'inactive')) return; + if (!fileTransferEnabled) { + showToast('Передача файлов и голосовых отключена в дополнительных настройках.', { kind: 'error', timeoutMs: 1900 }); + return; + } + if (activeEdit) { + showToast('Сначала завершите редактирование сообщения.', { kind: 'error', timeoutMs: 1600 }); + return; + } + if (!globalThis.MediaRecorder || !navigator.mediaDevices?.getUserMedia) { + showToast('Этот браузер не поддерживает запись голосовых сообщений.', { kind: 'error', timeoutMs: 2400 }); + return; + } + + voicePointerHeld = true; + voiceReleaseRequested = false; + voiceLocked = false; + voiceStartY = Number(pointerY || 0); + voicePointerId = pointerId; + suppressVoiceClick = false; + + try { + closeEmojiPicker(); + setFileActionVisible(false); + voiceStream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + video: false, + }); + const mimeType = selectVoiceMimeType(); + const recorder = mimeType + ? new MediaRecorder(voiceStream, { mimeType }) + : new MediaRecorder(voiceStream); + voiceRecorder = recorder; + voiceChunks = []; + recorder.__shineShouldSend = true; + recorder.addEventListener('dataavailable', (event) => { + if (event.data?.size > 0) voiceChunks.push(event.data); + }); + recorder.addEventListener('error', (event) => { + showToast(event?.error?.message || 'Ошибка записи микрофона', { kind: 'error', timeoutMs: 2200 }); + }); + recorder.addEventListener('stop', async () => { + const shouldSend = recorder.__shineShouldSend !== false; + const durationMs = currentVoiceDurationMs(); + const chunks = voiceChunks.slice(); + voiceChunks = []; + voiceRecorder = null; + voiceLocked = false; + voiceStartedAt = 0; + clearVoiceTimer(); + stopVoiceStreamTracks(); + voiceBusy = shouldSend && chunks.some((chunk) => chunk.size > 0); + updateSendButtonMode(); + + if (!voiceBusy) return; + try { + const mime = String(recorder.mimeType || mimeType || chunks[0]?.type || 'audio/webm'); + const blob = new Blob(chunks, { type: mime }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const file = new File([blob], `voice-${timestamp}.${voiceExtension(mime)}`, { type: mime, lastModified: Date.now() }); + const attachment = await encryptAndUploadDmFile({ + file, + login: state.session.login, + sessionId: state.session.sessionId, + wsUrl: authService.serverUrl, + kind: 'voice', + durationMs, + onProgress: ({ processedBytes, totalBytes }) => { + if (!totalBytes) return; + const percent = Math.round((processedBytes / totalBytes) * 100); + if (voiceHint) voiceHint.textContent = `Шифрование и загрузка ${Math.min(100, percent)}%`; + }, + }); + await sendFileAttachmentMessage([attachment]); + showToast('Голосовое сообщение отправлено.', { timeoutMs: 1200 }); + } catch (error) { + showToast(error?.message || 'Не удалось отправить голосовое сообщение', { kind: 'error', timeoutMs: 2800 }); + } finally { + voiceBusy = false; + updateSendButtonMode(); + } + }, { once: true }); + + voiceStartedAt = Date.now(); + recorder.start(1000); + voiceTimer = window.setInterval(syncVoiceTimer, 250); + updateSendButtonMode(); + if (voiceReleaseRequested && !voiceLocked) stopVoiceRecording({ send: true }); + } catch (error) { + voicePointerHeld = false; + voiceReleaseRequested = false; + voiceLocked = false; + voiceRecorder = null; + voiceStartedAt = 0; + clearVoiceTimer(); + stopVoiceStreamTracks(); + updateSendButtonMode(); + const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError'; + showToast( + denied ? 'Нужно разрешить SHiNE доступ к микрофону.' : (error?.message || 'Не удалось начать запись'), + { kind: 'error', timeoutMs: 2600 }, + ); + } + }; + // На сенсорных устройствах фокус в textarea поднимает клавиатуру поверх пикера — // поэтому при вставке эмодзи фокусируем поле только там, где есть «точный» указатель. const isFinePointer = window.matchMedia?.('(hover: hover) and (pointer: fine)')?.matches ?? true; @@ -1255,6 +1840,7 @@ export function render({ navigate, route, chrome }) { } autoResizeComposer(input); syncComposerLayout(); + updateSendButtonMode(); }; if (emojiSlot) { @@ -1298,6 +1884,7 @@ export function render({ navigate, route, chrome }) { syncComposerLayout(); focusInputToEnd(); } + updateSendButtonMode(); }; const cancelReplyMode = ({ restoreDraft = true } = {}) => { @@ -1309,6 +1896,7 @@ export function render({ navigate, route, chrome }) { syncComposerLayout(); focusInputToEnd(); } + updateSendButtonMode(); }; const startEditMode = (msg) => { @@ -1332,6 +1920,7 @@ export function render({ navigate, route, chrome }) { syncComposerLayout(); focusInputToEnd(); } + updateSendButtonMode(); }; const startReplyMode = (msg) => { @@ -1508,6 +2097,7 @@ export function render({ navigate, route, chrome }) { input.value = text; autoResizeComposer(input); syncComposerLayout(); + updateSendButtonMode(); focusInputToEnd(); } addChatMessage(chatId, `${activeEdit ? 'Ошибка изменения' : 'Ошибка отправки'}: ${e.message || 'unknown'}`); @@ -1524,17 +2114,24 @@ export function render({ navigate, route, chrome }) { } }; - const sendFileAttachmentMessage = async (attachment) => { + const sendFileAttachmentMessage = async (attachmentsInput) => { + const attachments = (Array.isArray(attachmentsInput) ? attachmentsInput : [attachmentsInput]).filter(Boolean); + if (!attachments.length) return; + if (attachments.length > DM_MAX_ATTACHMENTS_PER_MESSAGE) { + throw new Error(`За одно сообщение можно отправить максимум ${DM_MAX_ATTACHMENTS_PER_MESSAGE} файлов`); + } const replying = activeEdit ? null : activeReply; - const fileBlock = buildDmFileTechBlock(attachment); + const fileBlocks = attachments.map((attachment) => buildDmFileTechBlock(attachment)).join(''); const replyBlock = replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''; - const fallbackText = `📎 ${String(attachment?.name || 'file')}`; - const finalText = `${replyBlock}${fileBlock}${fallbackText}`; + const onlyVoice = attachments.length === 1 && attachments[0]?.kind === 'voice'; + const fallbackText = onlyVoice + ? '🎙 Голосовое сообщение' + : (attachments.length === 1 + ? `📎 ${String(attachments[0]?.name || 'file')}` + : `📎 Файлы: ${attachments.length}`); + const finalText = `${replyBlock}${fileBlocks}${fallbackText}`; - // Как и обычное исходящее сообщение, отправка файла сразу закрывает текущий - // визуальный раздел «Новые сообщения», не дожидаясь ответа сервера. hideUnreadSeparator({ rerender: false }); - const tempId = addOutgoingPendingMessage(chatId, finalText); renderChatLog({ scrollMode: 'latest', markAsRead: false }); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); @@ -1567,25 +2164,26 @@ export function render({ navigate, route, chrome }) { addAppLogEntry({ level: 'info', - source: 'outgoing-dm-file', - message: `Файл отправлен для ${chatId}`, + source: onlyVoice ? 'outgoing-dm-voice' : 'outgoing-dm-file', + message: onlyVoice ? `Голосовое отправлено для ${chatId}` : `Вложения отправлены для ${chatId}`, details: { toLogin: chatId, - fileId: attachment?.id || '', - fileSize: Number(attachment?.size || 0), + attachmentCount: attachments.length, + fileIds: attachments.map((attachment) => attachment?.id || ''), + totalFileSize: attachments.reduce((sum, attachment) => sum + Number(attachment?.size || 0), 0), messageId: result?.outgoingKey || '', replyToBaseKey: replying?.baseKey || '', }, }); } catch (error) { - addChatMessage(chatId, `Ошибка отправки файла: ${error?.message || 'unknown'}`); + addChatMessage(chatId, `Ошибка отправки вложения: ${error?.message || 'unknown'}`); addAppLogEntry({ level: 'warn', - source: 'outgoing-dm-file', - message: 'Файл загружен, но DM с ключом не отправлен', + source: onlyVoice ? 'outgoing-dm-voice' : 'outgoing-dm-file', + message: 'Зашифрованные данные загружены, но DM с ключами не отправлен', details: { toLogin: chatId, - fileId: attachment?.id || '', + fileIds: attachments.map((attachment) => attachment?.id || ''), error: error?.message || 'unknown', }, }); @@ -1594,7 +2192,7 @@ export function render({ navigate, route, chrome }) { } }; - const handleSelectedFile = async (file) => { + const handleSelectedFiles = async (fileList) => { if (!fileTransferEnabled) { showToast('Передача файлов отключена в дополнительных настройках.', { kind: 'error', timeoutMs: 1800 }); return; @@ -1603,38 +2201,101 @@ export function render({ navigate, route, chrome }) { showToast('Сначала завершите редактирование сообщения.', { kind: 'error', timeoutMs: 1600 }); return; } - if (!file) return; + const allFiles = Array.from(fileList || []).filter(Boolean); + if (!allFiles.length) return; + if (allFiles.length > DM_MAX_ATTACHMENTS_PER_MESSAGE) { + showToast(`Выберите не больше ${DM_MAX_ATTACHMENTS_PER_MESSAGE} файлов за одно сообщение.`, { kind: 'error', timeoutMs: 2200 }); + return; + } const originalLabel = fileToggle?.textContent || '📎'; if (fileToggle) { fileToggle.disabled = true; fileToggle.textContent = '…'; - fileToggle.setAttribute('aria-label', 'Шифрование и загрузка файла'); + fileToggle.setAttribute('aria-label', 'Шифрование и загрузка файлов'); } - showToast(`Шифрую и загружаю «${String(file.name || 'file')}»…`, { timeoutMs: 1600 }); + const attachments = []; try { - const attachment = await encryptAndUploadDmFile({ - file, - login: state.session.login, - sessionId: state.session.sessionId, - wsUrl: authService.serverUrl, - }); - await sendFileAttachmentMessage(attachment); + for (let index = 0; index < allFiles.length; index += 1) { + const file = allFiles[index]; + showToast(`Файл ${index + 1}/${allFiles.length}: «${String(file.name || 'file')}»`, { timeoutMs: 1200 }); + const attachment = await encryptAndUploadDmFile({ + file, + login: state.session.login, + sessionId: state.session.sessionId, + wsUrl: authService.serverUrl, + onProgress: ({ processedBytes, totalBytes }) => { + if (!fileToggle || !totalBytes) return; + const percent = Math.min(100, Math.max(0, Math.round((processedBytes / totalBytes) * 100))); + fileToggle.title = `${index + 1}/${allFiles.length} · ${percent}%`; + }, + }); + attachments.push(attachment); + } + await sendFileAttachmentMessage(attachments); setFileActionVisible(false); - showToast('Файл отправлен.', { timeoutMs: 1200 }); + showToast(attachments.length === 1 ? 'Файл отправлен.' : `Отправлено файлов: ${attachments.length}.`, { timeoutMs: 1300 }); } catch (error) { - showToast(error?.message || 'Не удалось отправить файл', { kind: 'error', timeoutMs: 2600 }); + showToast(error?.message || 'Не удалось отправить файлы', { kind: 'error', timeoutMs: 2800 }); } finally { if (fileToggle) { fileToggle.disabled = false; fileToggle.textContent = originalLabel; - fileToggle.setAttribute('aria-label', 'Прикрепить файл'); + fileToggle.title = 'Прикрепить файлы'; + fileToggle.setAttribute('aria-label', 'Прикрепить файлы'); } if (fileInput) fileInput.value = ''; } }; + const forwardMessageToChat = async (msg, targetLogin) => { + const targetChatId = normalizeDmChatId(targetLogin); + if (!targetChatId) throw new Error('Не выбран чат для пересылки'); + const finalText = buildForwardedDmText(msg); + if (!String(finalText || '').trim()) throw new Error('В этом сообщении нечего пересылать'); + + const tempId = addOutgoingPendingMessage(targetChatId, finalText); + if (targetChatId === chatId) { + hideUnreadSeparator({ rerender: false }); + renderChatLog({ scrollMode: 'latest', markAsRead: false }); + scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); + } + + try { + const result = await authService.sendDirectMessage({ + login: state.session.login, + toLogin: targetChatId, + text: finalText, + storagePwd: state.session.storagePwdInMemory, + }); + markOutgoingSent(tempId, { + messageKey: result?.outgoingKey || '', + baseKey: result?.baseKey || result?.localBaseKey || '', + deliveryState: result?.deliveryState || 'accepted', + }); + if (targetChatId === chatId) { + renderChatLog({ scrollMode: 'latest', markAsRead: false }); + scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); + } + addAppLogEntry({ + level: 'info', + source: 'outgoing-dm-forward', + message: `Сообщение переслано для ${targetChatId}`, + details: { + toLogin: targetChatId, + sourceChatId: chatId, + messageId: result?.outgoingKey || '', + }, + }); + } catch (error) { + // Оставляем pending-сообщение в локальной истории так же, как обычная отправка, + // а пользователю возвращаем ошибку для повторной попытки. + if (targetChatId === chatId) renderChatLog({ scrollMode: 'latest', markAsRead: false }); + throw error; + } + }; + const handleOpenActions = (msg, event) => { const parsed = parseDmTechBlocks(String(msg?.text || '')); const readState = resolveEffectiveReadState(getChatMessages(chatId), msg); @@ -1646,8 +2307,9 @@ export function render({ navigate, route, chrome }) { ? `Прочитано: ${formatMessageTime(readState.readAtMs)}` : '', canReply: true, + canForward: true, showReadAloud: isTextToSpeechReady, - canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary && !parsed?.fileAttachment, + canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary && !(parsed?.fileAttachments?.length || parsed?.fileAttachment), canDelete: ( (msg?.from === 'out' && Number(msg?.messageType || 0) === 2) || (msg?.from === 'in' && Number(msg?.messageType || 0) === 1) @@ -1655,6 +2317,13 @@ export function render({ navigate, route, chrome }) { onReply: async () => { startReplyMode(msg); }, + onForward: async () => { + await openForwardMessageDialog({ + message: msg, + sourceChatId: chatId, + onConfirm: async (targetLogin) => forwardMessageToChat(msg, targetLogin), + }); + }, onReadAloud: async () => handleReadAloud(msg), onEdit: async () => { startEditMode(msg); @@ -1738,10 +2407,12 @@ export function render({ navigate, route, chrome }) { autoResizeComposer(input); syncComposerLayout(); + updateSendButtonMode(); input?.addEventListener('input', () => { rememberEmojiSelection(); autoResizeComposer(input); syncComposerLayout(); + updateSendButtonMode(); }); input?.addEventListener('select', rememberEmojiSelection); input?.addEventListener('keyup', rememberEmojiSelection); @@ -1803,20 +2474,84 @@ export function render({ navigate, route, chrome }) { fileInput?.click(); }); fileInput?.addEventListener('change', () => { - const file = fileInput.files?.[0] || null; - if (file) void handleSelectedFile(file); + const files = Array.from(fileInput.files || []); + if (files.length) void handleSelectedFiles(files); }); screen.addEventListener('pointerdown', (event) => { if (form.contains(event.target)) return; if (emojiPickerOpen) closeEmojiPicker(); if (fileActionVisible) setFileActionVisible(false); }); - form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => { + + sendBtn?.addEventListener('pointerdown', (event) => { + const mode = String(sendBtn.dataset.mode || 'voice'); + if (mode === 'busy') { + event.preventDefault(); + return; + } + if (mode === 'send') { + try { + input?.focus({ preventScroll: true }); + } catch { + input?.focus(); + } + return; + } + if (mode === 'stop') { + event.preventDefault(); + suppressVoiceClick = true; + stopVoiceRecording({ send: true }); + return; + } event.preventDefault(); - try { - input?.focus({ preventScroll: true }); - } catch { - input?.focus(); + voicePointerHeld = true; + voiceReleaseRequested = false; + voiceStartY = Number(event.clientY || 0); + voicePointerId = event.pointerId; + try { sendBtn.setPointerCapture?.(event.pointerId); } catch { /* optional */ } + void startVoiceRecording({ pointerY: voiceStartY, pointerId: event.pointerId }); + }); + + sendBtn?.addEventListener('pointermove', (event) => { + if (!voicePointerHeld || voiceLocked || voicePointerId !== event.pointerId) return; + if (!voiceRecorder || voiceRecorder.state === 'inactive') return; + if (Number(event.clientY || 0) <= voiceStartY - 64) { + voiceLocked = true; + voiceReleaseRequested = false; + if (voiceHint) voiceHint.textContent = 'Запись закреплена · нажмите ■, чтобы отправить'; + updateSendButtonMode(); + try { navigator.vibrate?.(18); } catch { /* ignore */ } + } + }); + + const finishVoicePointerGesture = (event) => { + if (!voicePointerHeld && voicePointerId == null) return; + if (voicePointerId != null && event.pointerId !== voicePointerId) return; + voicePointerHeld = false; + voiceReleaseRequested = true; + try { sendBtn?.releasePointerCapture?.(event.pointerId); } catch { /* optional */ } + voicePointerId = null; + if (!voiceLocked) { + suppressVoiceClick = true; + stopVoiceRecording({ send: true }); + } + }; + sendBtn?.addEventListener('pointerup', finishVoicePointerGesture); + sendBtn?.addEventListener('pointercancel', finishVoicePointerGesture); + sendBtn?.addEventListener('contextmenu', (event) => { + if (String(sendBtn.dataset.mode || '') !== 'send') event.preventDefault(); + }); + sendBtn?.addEventListener('click', (event) => { + event.preventDefault(); + if (suppressVoiceClick) { + suppressVoiceClick = false; + return; + } + const mode = String(sendBtn.dataset.mode || 'voice'); + if (mode === 'send') { + form.requestSubmit(); + } else if (mode === 'stop') { + stopVoiceRecording({ send: true }); } }); @@ -1827,6 +2562,7 @@ export function render({ navigate, route, chrome }) { input.value = ''; autoResizeComposer(input); syncComposerLayout(); + updateSendButtonMode(); closeEmojiPicker(); await sendTextMessage(text); focusInputToEnd(); @@ -1879,6 +2615,9 @@ export function render({ navigate, route, chrome }) { hideUnreadSeparator({ rerender: false }); stopAllTwemojiAnimations(); clearEmojiLongPress(); + try { stopVoiceRecording({ send: false }); } catch { /* ignore */ } + clearVoiceTimer(); + stopVoiceStreamTracks(); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh); boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll); diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index b5bb051e..00355976 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -289,15 +289,19 @@ export async function importAesKeyRaw(keyBytes, usages = ['encrypt', 'decrypt']) return getSubtleApi().importKey('raw', keyBytes, { name: 'AES-GCM' }, false, usages); } -export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes) { +export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes, additionalData = null) { const key = await importAesKeyRaw(keyBytes, ['encrypt']); - const cipher = await getSubtleApi().encrypt({ name: 'AES-GCM', iv: ivBytes }, key, plainBytes); + const algorithm = { name: 'AES-GCM', iv: ivBytes }; + if (additionalData) algorithm.additionalData = additionalData; + const cipher = await getSubtleApi().encrypt(algorithm, key, plainBytes); return new Uint8Array(cipher); } -export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes) { +export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes, additionalData = null) { const key = await importAesKeyRaw(keyBytes, ['decrypt']); - const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes); + const algorithm = { name: 'AES-GCM', iv: ivBytes }; + if (additionalData) algorithm.additionalData = additionalData; + const plain = await getSubtleApi().decrypt(algorithm, key, cipherBytes); return new Uint8Array(plain); } diff --git a/shine-UI/js/services/dm-file-service.js b/shine-UI/js/services/dm-file-service.js index 39202df2..11448176 100644 --- a/shine-UI/js/services/dm-file-service.js +++ b/shine-UI/js/services/dm-file-service.js @@ -8,10 +8,28 @@ import { randomBytes, sha256Bytes, signBase64, + utf8Bytes, } from './crypto-utils.js'; import { loadSessionMaterial } from './key-vault.js'; +import { + TORRENT_V2_BLOCK_BYTES, + TORRENT_V2_PIECE_BYTES, + TorrentV2PieceAccumulator, + buildTorrentV2MetainfoBytes, + computeTorrentV2InfoHash, + computeTorrentV2PieceRoot, +} from './torrent-v2-service.js'; -export const DM_FILE_MAX_SOURCE_BYTES = 50 * 1024 * 1024; +export const DM_FILE_CHUNK_BYTES = TORRENT_V2_PIECE_BYTES; +export const DM_FILE_MANIFEST_PAGE_CHUNKS = 256; +export const DM_MAX_ATTACHMENTS_PER_MESSAGE = 10; + +const MANIFEST_VERSION = 2; +const MANIFEST_SCHEME = 'SHINE-DM-CHUNKED-AES-256-GCM'; +const ROOT_IV_TOKEN = 0xffffffffffffffffn; +const PAGE_IV_BASE = 0x8000000000000000n; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); function normalizeFileName(value = '') { const cleaned = String(value || 'file') @@ -52,22 +70,63 @@ async function readErrorMessage(response) { } } -export function formatDmFileSize(bytes = 0) { - const value = Math.max(0, Number(bytes || 0)); - if (value < 1024) return `${value} Б`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} КБ`; - return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`; +function objectUrl(serverBase, id) { + return `${String(serverBase || '').replace(/\/$/, '')}/dm-files/${encodeURIComponent(String(id || ''))}`; } -export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } = {}) { - if (!file || typeof file.arrayBuffer !== 'function') throw new Error('Файл не выбран'); +function objectBaseFromUrl(url = '') { + const parsed = new URL(String(url || ''), window.location.href); + return parsed.origin; +} + +function makeIv(prefix4, token) { + if (!(prefix4 instanceof Uint8Array) || prefix4.byteLength !== 4) { + throw new Error('Некорректный IV prefix файла'); + } + const iv = new Uint8Array(12); + iv.set(prefix4, 0); + new DataView(iv.buffer).setBigUint64(4, BigInt(token), false); + return iv; +} + +function chunkIv(prefix4, index) { + return makeIv(prefix4, BigInt(index)); +} + +function pageIv(prefix4, index) { + return makeIv(prefix4, PAGE_IV_BASE + BigInt(index)); +} + +function rootIv(prefix4) { + return makeIv(prefix4, ROOT_IV_TOKEN); +} + +function makeAad(prefix4, domain, index = 0) { + return utf8Bytes(`SHINE-DM-FILE-V2:${bytesToBase64Url(prefix4)}:${domain}:${index}`); +} + +function chunkAad(prefix4, index) { + return makeAad(prefix4, 'chunk', index); +} + +function pageAad(prefix4, index) { + return makeAad(prefix4, 'page', index); +} + +function rootAad(prefix4) { + return makeAad(prefix4, 'root', 0); +} + +function assertSafeIndex(index, label = 'index') { + const n = Number(index); + if (!Number.isSafeInteger(n) || n < 0) throw new Error(`Некорректный ${label}`); + return n; +} + +async function createUploadContext({ login, sessionId, wsUrl }) { const cleanLogin = String(login || '').trim(); const cleanSessionId = String(sessionId || '').trim(); if (!cleanLogin || !cleanSessionId) throw new Error('Нет активной пользовательской сессии'); - if (Number(file.size || 0) > DM_FILE_MAX_SOURCE_BYTES) { - throw new Error(`Файл слишком большой. Текущий лимит — ${formatDmFileSize(DM_FILE_MAX_SOURCE_BYTES)}.`); - } - const sessionMaterial = await loadSessionMaterial(cleanLogin); if (!sessionMaterial?.sessionPrivPkcs8) { throw new Error('На устройстве нет сохранённого session key для загрузки файла'); @@ -75,98 +134,455 @@ export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } = if (sessionMaterial.sessionId && String(sessionMaterial.sessionId) !== cleanSessionId) { throw new Error('Сохранённый session key относится к другой сессии'); } + return { + sessionId: cleanSessionId, + serverBase: wsUrlToHttpBase(wsUrl), + privateKey: await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8), + }; +} - const sourceBytes = new Uint8Array(await file.arrayBuffer()); - const fileKey = randomBytes(32); - const iv = randomBytes(12); - let encryptedBytes; +async function uploadEncryptedObject(bytes, context) { + const payload = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || 0); + const fileId = bytesToBase58(await sha256Bytes(payload)); + const url = objectUrl(context.serverBase, fileId); + + // Быстрая дедупликация: если сервер уже подтверждает content-addressed объект, + // не отправляем его байты повторно. HEAD на сервере сам перепроверяет SHA-256 файла. try { - encryptedBytes = await encryptBytesAesGcm(sourceBytes, fileKey, iv); - } finally { - sourceBytes.fill(0); + const existing = await fetch(url, { method: 'HEAD', cache: 'no-store' }); + if (existing.ok) { + const storedSize = Number(existing.headers.get('Content-Length') || -1); + const etag = String(existing.headers.get('ETag') || '').replace(/^"|"$/g, ''); + if (storedSize === payload.byteLength && (!etag || etag === fileId)) { + return { id: fileId, url, encryptedSize: payload.byteLength, alreadyExists: true }; + } + } + } catch { + // Старый сервер или временная ошибка HEAD не должны ломать загрузку: PUT остаётся источником истины. } + const timeMs = Date.now(); + const signatureB64 = await signBase64(context.privateKey, uploadPreimage({ + sessionId: context.sessionId, + fileId, + encryptedSize: payload.byteLength, + timeMs, + })); + let response; try { - const fileHash = await sha256Bytes(encryptedBytes); - const fileId = bytesToBase58(fileHash); - const serverBase = wsUrlToHttpBase(wsUrl); - const fileUrl = `${serverBase}/dm-files/${fileId}`; - const timeMs = Date.now(); - const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8); - const signatureB64 = await signBase64(privateKey, uploadPreimage({ - sessionId: cleanSessionId, - fileId, - encryptedSize: encryptedBytes.byteLength, - timeMs, - })); + response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Shine-Session-Id': context.sessionId, + 'X-Shine-Time-Ms': String(timeMs), + 'X-Shine-Content-Length': String(payload.byteLength), + 'X-Shine-Signature': signatureB64, + }, + body: payload, + cache: 'no-store', + }); + } catch (error) { + throw new Error(`Не удалось загрузить часть файла на сервер отправителя: ${error?.message || 'network error'}`); + } + if (!response.ok) { + const detail = await readErrorMessage(response); + throw new Error(detail || `Сервер отклонил часть файла (HTTP ${response.status})`); + } + let alreadyExists = false; + try { + const payloadJson = await response.clone().json(); + alreadyExists = Boolean(payloadJson?.alreadyExists); + } catch { + // Ответ старого сервера может не содержать JSON-флаг; успешного HTTP достаточно. + } + return { id: fileId, url, encryptedSize: payload.byteLength, alreadyExists }; +} - let response; - try { - response = await fetch(fileUrl, { - method: 'PUT', - headers: { - 'Content-Type': 'application/octet-stream', - 'X-Shine-Session-Id': cleanSessionId, - 'X-Shine-Time-Ms': String(timeMs), - 'X-Shine-Content-Length': String(encryptedBytes.byteLength), - 'X-Shine-Signature': signatureB64, - }, - body: encryptedBytes, - cache: 'no-store', - }); - } catch (error) { - throw new Error(`Не удалось загрузить файл на сервер отправителя: ${error?.message || 'network error'}`); - } +async function fetchVerifiedObject(url, expectedId) { + let response; + try { + response = await fetch(url, { method: 'GET', cache: 'no-store' }); + } catch (error) { + throw new Error(`Не удалось скачать зашифрованные данные: ${error?.message || 'network error'}`); + } + if (!response.ok) { + throw new Error(response.status === 404 ? 'Часть файла больше не найдена на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + const actualId = bytesToBase58(await sha256Bytes(bytes)); + if (actualId !== String(expectedId || '')) { + bytes.fill(0); + throw new Error('SHA-256 зашифрованной части не совпал: данные повреждены или подменены'); + } + return bytes; +} - if (!response.ok) { - const detail = await readErrorMessage(response); - throw new Error(detail || `Сервер отклонил файл (HTTP ${response.status})`); - } +function encodeJson(value) { + return encoder.encode(JSON.stringify(value)); +} +function decodeJson(bytes) { + return JSON.parse(decoder.decode(bytes)); +} + +async function encryptAndUploadManifestPage({ descriptors, pageIndex, fileKey, ivPrefix, uploadContext }) { + const plain = encodeJson({ v: MANIFEST_VERSION, page: pageIndex, chunks: descriptors }); + const iv = pageIv(ivPrefix, pageIndex); + const aad = pageAad(ivPrefix, pageIndex); + let encrypted; + try { + encrypted = await encryptBytesAesGcm(plain, fileKey, iv, aad); + const stored = await uploadEncryptedObject(encrypted, uploadContext); return { - version: 1, - id: fileId, - url: fileUrl, - keyB64Url: bytesToBase64Url(fileKey), - ivB64Url: bytesToBase64Url(iv), - name: normalizeFileName(file.name), - mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream', - size: Number(file.size || 0), - encryptedSize: encryptedBytes.byteLength, + id: stored.id, + count: descriptors.length, + encryptedSize: stored.encryptedSize, }; } finally { - fileKey.fill(0); + plain.fill(0); iv.fill(0); - encryptedBytes?.fill(0); + aad.fill(0); + encrypted?.fill(0); } } -export async function downloadAndDecryptDmFile(attachment = {}) { +export function formatDmFileSize(bytes = 0) { + const value = Math.max(0, Number(bytes || 0)); + if (value < 1024) return `${value} Б`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} КБ`; + if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`; + return `${(value / (1024 * 1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 * 1024 ? 1 : 2)} ГБ`; +} + +/** + * V2 uploader: no whole-file size limit. Only one 1 MiB plaintext piece is held in + * memory at a time. Every piece is independently AES-256-GCM encrypted and stored + * as Base58(SHA-256(ciphertext)). + */ +export async function encryptAndUploadDmFile({ + file, + login, + sessionId, + wsUrl, + kind = 'file', + durationMs = 0, + onProgress = null, +} = {}) { + if (!file || typeof file.slice !== 'function') throw new Error('Файл не выбран'); + const uploadContext = await createUploadContext({ login, sessionId, wsUrl }); + const fileKey = randomBytes(32); + const ivPrefix = randomBytes(4); + const fileSize = Math.max(0, Number(file.size || 0)); + const chunkCount = Math.ceil(fileSize / DM_FILE_CHUNK_BYTES); + const pageRefs = []; + let pageDescriptors = []; + let pageIndex = 0; + const pieceAccumulator = new TorrentV2PieceAccumulator(); + let singlePieceRoot = null; + let completedBytes = 0; + + try { + for (let index = 0; index < chunkCount; index += 1) { + const start = index * DM_FILE_CHUNK_BYTES; + const end = Math.min(fileSize, start + DM_FILE_CHUNK_BYTES); + const plain = new Uint8Array(await file.slice(start, end).arrayBuffer()); + const iv = chunkIv(ivPrefix, index); + const aad = chunkAad(ivPrefix, index); + let encrypted = null; + try { + const pieceRoot = await computeTorrentV2PieceRoot(plain, { + padToPieceLength: fileSize > TORRENT_V2_PIECE_BYTES, + }); + if (!pieceRoot) throw new Error('Не удалось вычислить torrent-v2 hash части'); + if (chunkCount <= 1) singlePieceRoot = pieceRoot.slice(); + else await pieceAccumulator.addPieceRoot(pieceRoot); + + encrypted = await encryptBytesAesGcm(plain, fileKey, iv, aad); + const stored = await uploadEncryptedObject(encrypted, uploadContext); + pageDescriptors.push({ + i: index, + id: stored.id, + ps: plain.byteLength, + es: encrypted.byteLength, + ph: bytesToBase64Url(pieceRoot), + }); + completedBytes += plain.byteLength; + onProgress?.({ + phase: 'chunks', + chunkIndex: index, + chunkCount, + processedBytes: completedBytes, + totalBytes: fileSize, + }); + } finally { + plain.fill(0); + iv.fill(0); + aad.fill(0); + encrypted?.fill(0); + } + + if (pageDescriptors.length >= DM_FILE_MANIFEST_PAGE_CHUNKS || index === chunkCount - 1) { + const pageRef = await encryptAndUploadManifestPage({ + descriptors: pageDescriptors, + pageIndex, + fileKey, + ivPrefix, + uploadContext, + }); + pageRefs.push(pageRef); + pageDescriptors = []; + pageIndex += 1; + } + } + + const piecesRoot = fileSize <= 0 + ? null + : (chunkCount <= 1 ? singlePieceRoot : await pieceAccumulator.finalize()); + const torrentInfo = await computeTorrentV2InfoHash({ + name: normalizeFileName(file.name), + size: fileSize, + piecesRoot, + }); + + const rootManifest = { + v: MANIFEST_VERSION, + scheme: MANIFEST_SCHEME, + chunkSize: DM_FILE_CHUNK_BYTES, + fileSize, + chunkCount, + pages: pageRefs, + torrent: { + metaVersion: 2, + blockLength: TORRENT_V2_BLOCK_BYTES, + pieceLength: TORRENT_V2_PIECE_BYTES, + piecesRoot: piecesRoot ? bytesToBase64Url(piecesRoot) : '', + infoHash: torrentInfo.infoHashB64Url, + }, + }; + const rootPlain = encodeJson(rootManifest); + const iv = rootIv(ivPrefix); + const aad = rootAad(ivPrefix); + let rootEncrypted = null; + try { + rootEncrypted = await encryptBytesAesGcm(rootPlain, fileKey, iv, aad); + const storedRoot = await uploadEncryptedObject(rootEncrypted, uploadContext); + onProgress?.({ phase: 'manifest', processedBytes: fileSize, totalBytes: fileSize, chunkCount }); + return { + version: 2, + id: storedRoot.id, + url: storedRoot.url, + keyB64Url: bytesToBase64Url(fileKey), + ivPrefixB64Url: bytesToBase64Url(ivPrefix), + name: normalizeFileName(file.name), + mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream', + size: fileSize, + encryptedSize: pageRefs.reduce((sum, item) => sum + Number(item.encryptedSize || 0), 0) + storedRoot.encryptedSize, + chunkSize: DM_FILE_CHUNK_BYTES, + chunkCount, + torrentV2InfoHashB64Url: torrentInfo.infoHashB64Url, + torrentV2PiecesRootB64Url: piecesRoot ? bytesToBase64Url(piecesRoot) : '', + kind: String(kind || 'file') === 'voice' ? 'voice' : 'file', + durationMs: Math.max(0, Math.floor(Number(durationMs || 0))), + }; + } finally { + rootPlain.fill(0); + iv.fill(0); + aad.fill(0); + rootEncrypted?.fill(0); + } + } finally { + fileKey.fill(0); + ivPrefix.fill(0); + singlePieceRoot?.fill(0); + } +} + +async function loadV2RootManifest(attachment) { + const id = String(attachment?.id || '').trim(); + const url = String(attachment?.url || '').trim(); + const key = base64UrlToBytes(String(attachment?.keyB64Url || '')); + const ivPrefix = base64UrlToBytes(String(attachment?.ivPrefixB64Url || '')); + if (!id || !url || key.byteLength !== 32 || ivPrefix.byteLength !== 4) { + key.fill(0); + ivPrefix.fill(0); + throw new Error('В сообщении не хватает данных для расшифровки chunked-файла'); + } + const encrypted = await fetchVerifiedObject(url, id); + const iv = rootIv(ivPrefix); + const aad = rootAad(ivPrefix); + let plain; + try { + plain = await decryptBytesAesGcm(encrypted, key, iv, aad); + const manifest = decodeJson(plain); + if (Number(manifest?.v) !== MANIFEST_VERSION || manifest?.scheme !== MANIFEST_SCHEME) { + throw new Error('Неизвестная версия манифеста файла'); + } + if (Number(manifest.chunkSize) !== DM_FILE_CHUNK_BYTES) throw new Error('Неожиданный размер chunk в манифесте'); + if (Number(manifest.fileSize) !== Number(attachment?.size || 0)) throw new Error('Размер файла не совпал с манифестом'); + if (Number(manifest.chunkCount) !== Math.ceil(Number(manifest.fileSize || 0) / DM_FILE_CHUNK_BYTES)) { + throw new Error('Некорректное число частей в манифесте'); + } + + const piecesRoot = manifest?.torrent?.piecesRoot ? base64UrlToBytes(manifest.torrent.piecesRoot) : null; + const recomputedInfo = await computeTorrentV2InfoHash({ + name: normalizeFileName(attachment?.name || 'file'), + size: Number(manifest.fileSize || 0), + piecesRoot, + }); + if (recomputedInfo.infoHashB64Url !== String(manifest?.torrent?.infoHash || '')) { + piecesRoot?.fill(0); + throw new Error('BitTorrent v2 infohash манифеста не совпал'); + } + piecesRoot?.fill(0); + return { manifest, key, ivPrefix, serverBase: objectBaseFromUrl(url) }; + } catch (error) { + key.fill(0); + ivPrefix.fill(0); + throw error; + } finally { + encrypted.fill(0); + iv.fill(0); + aad.fill(0); + plain?.fill(0); + } +} + +async function loadV2ManifestPage({ pageRef, pageIndex, key, ivPrefix, serverBase }) { + const id = String(pageRef?.id || '').trim(); + if (!id) throw new Error('В манифесте отсутствует идентификатор страницы'); + const encrypted = await fetchVerifiedObject(objectUrl(serverBase, id), id); + const iv = pageIv(ivPrefix, pageIndex); + const aad = pageAad(ivPrefix, pageIndex); + let plain; + try { + plain = await decryptBytesAesGcm(encrypted, key, iv, aad); + const page = decodeJson(plain); + if (Number(page?.v) !== MANIFEST_VERSION || Number(page?.page) !== pageIndex || !Array.isArray(page?.chunks)) { + throw new Error('Повреждена страница манифеста'); + } + if (Number(pageRef?.count || 0) !== page.chunks.length) throw new Error('Размер страницы манифеста не совпал'); + return page.chunks; + } finally { + encrypted.fill(0); + iv.fill(0); + aad.fill(0); + plain?.fill(0); + } +} + +async function *iterateV2Chunks(context) { + let expectedIndex = 0; + for (let pageIndex = 0; pageIndex < context.manifest.pages.length; pageIndex += 1) { + const descriptors = await loadV2ManifestPage({ + pageRef: context.manifest.pages[pageIndex], + pageIndex, + key: context.key, + ivPrefix: context.ivPrefix, + serverBase: context.serverBase, + }); + for (const descriptor of descriptors) { + if (assertSafeIndex(descriptor?.i, 'индекс chunk') !== expectedIndex) { + throw new Error('Нарушен порядок частей файла'); + } + expectedIndex += 1; + yield descriptor; + } + } + if (expectedIndex !== Number(context.manifest.chunkCount || 0)) { + throw new Error('Манифест содержит неполный список частей'); + } +} + +async function decryptV2Chunk(context, descriptor) { + const index = assertSafeIndex(descriptor?.i, 'индекс chunk'); + const id = String(descriptor?.id || '').trim(); + if (!id) throw new Error('У части файла отсутствует hash'); + const encrypted = await fetchVerifiedObject(objectUrl(context.serverBase, id), id); + const iv = chunkIv(context.ivPrefix, index); + const aad = chunkAad(context.ivPrefix, index); + let plain; + try { + plain = await decryptBytesAesGcm(encrypted, context.key, iv, aad); + } catch { + throw new Error(`Не удалось расшифровать часть ${index + 1}`); + } finally { + encrypted.fill(0); + iv.fill(0); + aad.fill(0); + } + if (plain.byteLength !== Number(descriptor?.ps || 0)) { + plain.fill(0); + throw new Error(`Размер расшифрованной части ${index + 1} не совпал`); + } + const expectedPieceHash = String(descriptor?.ph || '').trim(); + const pieceRoot = await computeTorrentV2PieceRoot(plain, { + padToPieceLength: Number(context.manifest.fileSize || 0) > TORRENT_V2_PIECE_BYTES, + }); + if (!pieceRoot || bytesToBase64Url(pieceRoot) !== expectedPieceHash) { + plain.fill(0); + pieceRoot?.fill(0); + throw new Error(`BitTorrent v2 SHA-256 части ${index + 1} не совпал`); + } + return { plain, pieceRoot }; +} + +async function verifyCompletedTorrentRoot(context, accumulator, singlePieceRoot) { + const expectedRoot = String(context.manifest?.torrent?.piecesRoot || ''); + if (!expectedRoot && Number(context.manifest.fileSize || 0) === 0) return; + const actualRoot = Number(context.manifest.chunkCount || 0) <= 1 + ? singlePieceRoot + : await accumulator.finalize(); + if (!actualRoot || bytesToBase64Url(actualRoot) !== expectedRoot) { + actualRoot?.fill(0); + throw new Error('Итоговый BitTorrent v2 pieces root не совпал'); + } + actualRoot.fill(0); +} + +async function decryptV2ToSink(attachment, sink, { onProgress = null } = {}) { + const context = await loadV2RootManifest(attachment); + const accumulator = new TorrentV2PieceAccumulator(); + let singlePieceRoot = null; + let processedBytes = 0; + try { + for await (const descriptor of iterateV2Chunks(context)) { + const { plain, pieceRoot } = await decryptV2Chunk(context, descriptor); + try { + if (Number(context.manifest.chunkCount || 0) <= 1) singlePieceRoot = pieceRoot.slice(); + else await accumulator.addPieceRoot(pieceRoot); + await sink(plain, descriptor); + processedBytes += plain.byteLength; + onProgress?.({ + processedBytes, + totalBytes: Number(context.manifest.fileSize || 0), + chunkIndex: Number(descriptor.i), + chunkCount: Number(context.manifest.chunkCount || 0), + }); + } finally { + plain.fill(0); + pieceRoot.fill(0); + } + } + if (processedBytes !== Number(context.manifest.fileSize || 0)) { + throw new Error('Итоговый размер расшифрованного файла не совпал'); + } + await verifyCompletedTorrentRoot(context, accumulator, singlePieceRoot); + return context.manifest; + } finally { + context.key.fill(0); + context.ivPrefix.fill(0); + singlePieceRoot?.fill(0); + } +} + +async function downloadV1(attachment = {}) { const fileId = String(attachment?.id || '').trim(); const fileUrl = String(attachment?.url || '').trim(); const keyB64Url = String(attachment?.keyB64Url || '').trim(); const ivB64Url = String(attachment?.ivB64Url || '').trim(); - if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) { - throw new Error('В сообщении не хватает данных для расшифровки файла'); - } - - let response; - try { - response = await fetch(fileUrl, { method: 'GET', cache: 'no-store' }); - } catch (error) { - throw new Error(`Не удалось скачать зашифрованный файл: ${error?.message || 'network error'}`); - } - if (!response.ok) { - throw new Error(response.status === 404 ? 'Файл больше не найден на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`); - } - - const encryptedBytes = new Uint8Array(await response.arrayBuffer()); - const actualId = bytesToBase58(await sha256Bytes(encryptedBytes)); - if (actualId !== fileId) { - encryptedBytes.fill(0); - throw new Error('SHA-256 файла не совпал: зашифрованный файл повреждён или подменён'); - } - + if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) throw new Error('В сообщении не хватает данных для расшифровки файла'); + const encryptedBytes = await fetchVerifiedObject(fileUrl, fileId); const keyBytes = base64UrlToBytes(keyB64Url); const ivBytes = base64UrlToBytes(ivB64Url); let plainBytes; @@ -179,26 +595,128 @@ export async function downloadAndDecryptDmFile(attachment = {}) { keyBytes.fill(0); ivBytes.fill(0); } - - const expectedSize = Number(attachment?.size || 0); - if (expectedSize >= 0 && plainBytes.byteLength !== expectedSize) { + if (plainBytes.byteLength !== Number(attachment?.size || 0)) { plainBytes.fill(0); throw new Error('Размер расшифрованного файла не совпал с сообщением'); } + return plainBytes; +} - const blob = new Blob([plainBytes], { - type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream', - }); - plainBytes.fill(0); +function suggestedPickerTypes(attachment) { + const mime = String(attachment?.mime || '').trim(); + if (!mime || mime === 'application/octet-stream') return undefined; + const name = normalizeFileName(attachment?.name || 'file'); + const dot = name.lastIndexOf('.'); + const extension = dot >= 0 ? name.slice(dot) : ''; + return [{ + description: 'Файл SHiNE', + accept: { [mime]: extension ? [extension] : [] }, + }]; +} - const objectUrl = URL.createObjectURL(blob); +function saveBlob(blob, name) { + const objectUrlValue = URL.createObjectURL(blob); const anchor = document.createElement('a'); - anchor.href = objectUrl; - anchor.download = normalizeFileName(attachment?.name || 'file'); + anchor.href = objectUrlValue; + anchor.download = normalizeFileName(name || 'file'); anchor.rel = 'noopener'; anchor.style.display = 'none'; document.body.append(anchor); anchor.click(); anchor.remove(); - window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); + window.setTimeout(() => URL.revokeObjectURL(objectUrlValue), 30_000); +} + +export async function decryptDmFileToBlob(attachment = {}, options = {}) { + if (Number(attachment?.version || 1) < 2) { + const plain = await downloadV1(attachment); + const blob = new Blob([plain], { type: String(attachment?.mime || 'application/octet-stream') }); + plain.fill(0); + return blob; + } + const parts = []; + await decryptV2ToSink(attachment, async (plain) => { + parts.push(plain.slice().buffer); + }, options); + return new Blob(parts, { type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream' }); +} + +export async function downloadAndDecryptDmFile(attachment = {}, { onProgress = null } = {}) { + if (Number(attachment?.version || 1) < 2) { + const plain = await downloadV1(attachment); + const blob = new Blob([plain], { type: String(attachment?.mime || 'application/octet-stream') }); + plain.fill(0); + saveBlob(blob, attachment?.name); + return { streamed: false, size: blob.size }; + } + + let writable = null; + if (typeof window.showSaveFilePicker === 'function') { + try { + const handle = await window.showSaveFilePicker({ + suggestedName: normalizeFileName(attachment?.name || 'file'), + types: suggestedPickerTypes(attachment), + }); + writable = await handle.createWritable(); + } catch (error) { + if (error?.name === 'AbortError') return { cancelled: true }; + // Some browsers expose the API but reject certain MIME/type descriptors. + try { + const handle = await window.showSaveFilePicker({ suggestedName: normalizeFileName(attachment?.name || 'file') }); + writable = await handle.createWritable(); + } catch (secondError) { + if (secondError?.name === 'AbortError') return { cancelled: true }; + } + } + } + + if (writable) { + try { + await decryptV2ToSink(attachment, async (plain) => { + await writable.write(plain); + }, { onProgress }); + await writable.close(); + writable = null; + return { streamed: true, size: Number(attachment?.size || 0) }; + } catch (error) { + try { await writable?.abort?.(); } catch { /* ignore */ } + throw error; + } + } + + // Cross-browser fallback: still chunk-download/decrypt, but the final Blob is held + // in memory because Firefox/Safari do not yet expose a writable download stream. + const blob = await decryptDmFileToBlob(attachment, { onProgress }); + saveBlob(blob, attachment?.name); + return { streamed: false, size: blob.size }; +} + +export async function buildDmFileTorrentV2(attachment = {}) { + if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов SHiNE v2'); + const context = await loadV2RootManifest(attachment); + const pieceLayer = []; + try { + for await (const descriptor of iterateV2Chunks(context)) { + if (Number(context.manifest.fileSize || 0) > TORRENT_V2_PIECE_BYTES) { + const hash = base64UrlToBytes(String(descriptor?.ph || '')); + if (hash.byteLength !== 32) throw new Error('Повреждён torrent piece layer'); + pieceLayer.push(hash); + } + } + const piecesRoot = context.manifest?.torrent?.piecesRoot + ? base64UrlToBytes(context.manifest.torrent.piecesRoot) + : null; + const bytes = buildTorrentV2MetainfoBytes({ + name: normalizeFileName(attachment?.name || 'file'), + size: Number(attachment?.size || 0), + piecesRoot, + pieceLayer, + }); + piecesRoot?.fill(0); + pieceLayer.forEach((hash) => hash.fill(0)); + return new Blob([bytes], { type: 'application/x-bittorrent' }); + } finally { + context.key.fill(0); + context.ivPrefix.fill(0); + } } diff --git a/shine-UI/js/services/dm-tech-blocks.js b/shine-UI/js/services/dm-tech-blocks.js index 9c906aef..a14c0cc9 100644 --- a/shine-UI/js/services/dm-tech-blocks.js +++ b/shine-UI/js/services/dm-tech-blocks.js @@ -9,6 +9,7 @@ function defaultParsed(rawText = '') { replyRef: null, callSummary: null, fileAttachment: null, + fileAttachments: [], }; } @@ -97,17 +98,46 @@ function decodeFileField(value = '') { } function normalizeFileAttachment(fields = {}) { + const version = Number(fields.v || 0); const id = String(fields.id || '').trim(); const url = decodeFileField(fields.url || '').trim(); const keyB64Url = String(fields.key || '').trim(); - const ivB64Url = String(fields.iv || '').trim(); const name = decodeFileField(fields.name || '').trim() || 'file'; const mime = decodeFileField(fields.mime || '').trim() || 'application/octet-stream'; const size = Number(fields.size || 0); const encryptedSize = Number(fields.encsize || 0); - if (!id || !url || !keyB64Url || !ivB64Url || !Number.isFinite(size) || size < 0) return null; + if (!id || !url || !keyB64Url || !Number.isFinite(size) || size < 0) return null; + + if (version >= 2) { + const ivPrefixB64Url = String(fields.ivp || '').trim(); + if (!ivPrefixB64Url) return null; + const chunkSize = Number(fields.chunk || 0); + const chunkCount = Number(fields.chunks || 0); + const kind = String(fields.kind || 'file').trim().toLowerCase() === 'voice' ? 'voice' : 'file'; + const durationMs = Math.max(0, Math.floor(Number(fields.dur || 0))); + return { + version, + id, + url, + keyB64Url, + ivPrefixB64Url, + name, + mime, + size, + encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0, + chunkSize: Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : 0, + chunkCount: Number.isFinite(chunkCount) && chunkCount >= 0 ? Math.floor(chunkCount) : 0, + torrentV2InfoHashB64Url: String(fields.th || '').trim(), + torrentV2PiecesRootB64Url: String(fields.pr || '').trim(), + kind, + durationMs, + }; + } + + const ivB64Url = String(fields.iv || '').trim(); + if (!ivB64Url) return null; return { - version: Number(fields.v || 0), + version: version || 1, id, url, keyB64Url, @@ -116,20 +146,37 @@ function normalizeFileAttachment(fields = {}) { mime, size, encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0, + kind: 'file', + durationMs: 0, }; } export function buildDmFileTechBlock(attachment = {}) { + const version = Math.max(1, Math.floor(Number(attachment?.version || 1))); const id = String(attachment?.id || '').trim(); const url = String(attachment?.url || '').trim(); const keyB64Url = String(attachment?.keyB64Url || '').trim(); - const ivB64Url = String(attachment?.ivB64Url || '').trim(); const size = Math.max(0, Math.floor(Number(attachment?.size || 0))); const encryptedSize = Math.max(0, Math.floor(Number(attachment?.encryptedSize || 0))); - if (!id || !url || !keyB64Url || !ivB64Url) throw new Error('Не хватает данных для технического блока файла'); + if (!id || !url || !keyB64Url) throw new Error('Не хватает данных для технического блока файла'); const name = encodeURIComponent(String(attachment?.name || 'file')); const mime = encodeURIComponent(String(attachment?.mime || 'application/octet-stream')); const encodedUrl = encodeURIComponent(url); + + if (version >= 2) { + const ivPrefix = String(attachment?.ivPrefixB64Url || '').trim(); + if (!ivPrefix) throw new Error('Не хватает IV prefix для chunked-файла'); + const chunkSize = Math.max(0, Math.floor(Number(attachment?.chunkSize || 0))); + const chunkCount = Math.max(0, Math.floor(Number(attachment?.chunkCount || 0))); + const torrentHash = String(attachment?.torrentV2InfoHashB64Url || '').trim(); + const piecesRoot = String(attachment?.torrentV2PiecesRootB64Url || '').trim(); + const kind = String(attachment?.kind || 'file') === 'voice' ? 'voice' : 'file'; + const durationMs = Math.max(0, Math.floor(Number(attachment?.durationMs || 0))); + return ``; + } + + const ivB64Url = String(attachment?.ivB64Url || '').trim(); + if (!ivB64Url) throw new Error('Не хватает IV для файла v1'); return ``; } @@ -142,6 +189,7 @@ export function parseDmTechBlocks(rawText = '') { let replyRef = null; let callSummary = null; let fileAttachment = null; + const fileAttachments = []; while (hasTechPrefix(text, cursor)) { const end = text.indexOf('>', cursor); @@ -189,8 +237,12 @@ export function parseDmTechBlocks(rawText = '') { reason: String(fields.reason || '').trim().toLowerCase(), }; } - } else if (kind === 'file' && !fileAttachment) { - fileAttachment = normalizeFileAttachment(fields); + } else if (kind === 'file') { + const attachment = normalizeFileAttachment(fields); + if (attachment) { + fileAttachments.push(attachment); + if (!fileAttachment) fileAttachment = attachment; + } } cursor = end + 1; @@ -207,5 +259,6 @@ export function parseDmTechBlocks(rawText = '') { replyRef, callSummary, fileAttachment, + fileAttachments, }; } diff --git a/shine-UI/js/services/feature-settings.js b/shine-UI/js/services/feature-settings.js index 0b2598f8..8fa93346 100644 --- a/shine-UI/js/services/feature-settings.js +++ b/shine-UI/js/services/feature-settings.js @@ -27,7 +27,7 @@ function writeBoolean(key, enabled) { } export function isDeveloperToolsEnabled() { - return readBoolean(STORAGE_KEYS.developerTools, true); + return readBoolean(STORAGE_KEYS.developerTools, false); } export function setDeveloperToolsEnabled(enabled) { diff --git a/shine-UI/js/services/torrent-v2-service.js b/shine-UI/js/services/torrent-v2-service.js new file mode 100644 index 00000000..2dc27ee7 --- /dev/null +++ b/shine-UI/js/services/torrent-v2-service.js @@ -0,0 +1,234 @@ +import { bytesToBase64Url, sha256Bytes } from './crypto-utils.js'; + +export const TORRENT_V2_BLOCK_BYTES = 16 * 1024; +export const TORRENT_V2_PIECE_BYTES = 1024 * 1024; + +const ZERO_HASH = new Uint8Array(32); +const encoder = new TextEncoder(); + +function concatBytes(parts = []) { + const arrays = parts.map((part) => part instanceof Uint8Array ? part : new Uint8Array(part || 0)); + const total = arrays.reduce((sum, part) => sum + part.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of arrays) { + out.set(part, offset); + offset += part.byteLength; + } + return out; +} + +async function hashPair(left, right) { + return sha256Bytes(concatBytes([left, right])); +} + +function nextPowerOfTwo(value) { + let n = 1; + while (n < value) n *= 2; + return n; +} + +async function reduceMerkleLevel(nodes) { + if (nodes.length <= 1) return nodes; + const next = []; + for (let i = 0; i < nodes.length; i += 2) { + next.push(hashPair(nodes[i], nodes[i + 1])); + } + return Promise.all(next); +} + +/** + * BEP 52 pieces root / piece-layer hash for one piece. + * Leaves are SHA-256 hashes of 16 KiB blocks. Padding leaves are 32 zero bytes, + * exactly as required by BitTorrent v2. + */ +export async function computeTorrentV2PieceRoot(pieceBytes, { padToPieceLength = false } = {}) { + const bytes = pieceBytes instanceof Uint8Array ? pieceBytes : new Uint8Array(pieceBytes || 0); + if (!bytes.byteLength) return null; + + const blockCount = Math.ceil(bytes.byteLength / TORRENT_V2_BLOCK_BYTES); + const leafPromises = []; + for (let offset = 0; offset < bytes.byteLength; offset += TORRENT_V2_BLOCK_BYTES) { + leafPromises.push(sha256Bytes(bytes.subarray(offset, Math.min(bytes.byteLength, offset + TORRENT_V2_BLOCK_BYTES)))); + } + let level = await Promise.all(leafPromises); + const targetLeaves = padToPieceLength + ? (TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES) + : nextPowerOfTwo(Math.max(1, blockCount)); + while (level.length < targetLeaves) level.push(ZERO_HASH.slice()); + while (level.length > 1) level = await reduceMerkleLevel(level); + return level[0]; +} + +let zeroPieceRootPromise = null; +export function getTorrentV2ZeroPieceRoot() { + if (!zeroPieceRootPromise) { + zeroPieceRootPromise = (async () => { + let level = Array.from( + { length: TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES }, + () => ZERO_HASH.slice(), + ); + while (level.length > 1) level = await reduceMerkleLevel(level); + return level[0]; + })(); + } + return zeroPieceRootPromise; +} + +/** + * Streaming Merkle accumulator for the BEP 52 piece layer. It keeps only O(log n) + * hashes in memory and pads the right side with the standard zero-piece subtree. + */ +export class TorrentV2PieceAccumulator { + constructor() { + this.stack = []; + this.count = 0; + } + + async #addSubtree(hash, level) { + let current = hash; + let currentLevel = level; + while (this.stack[currentLevel]) { + current = await hashPair(this.stack[currentLevel], current); + this.stack[currentLevel] = null; + currentLevel += 1; + } + this.stack[currentLevel] = current; + } + + async addPieceRoot(hash) { + if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) { + throw new Error('Некорректный torrent-v2 piece hash'); + } + await this.#addSubtree(hash, 0); + this.count += 1; + } + + async finalize() { + if (this.count <= 0) return null; + let target = 1; + while (target < this.count) target *= 2; + let remaining = target - this.count; + let count = this.count; + const zeroRoots = [await getTorrentV2ZeroPieceRoot()]; + const ensureZeroLevel = async (level) => { + while (zeroRoots.length <= level) { + const previous = zeroRoots[zeroRoots.length - 1]; + zeroRoots.push(await hashPair(previous, previous)); + } + return zeroRoots[level]; + }; + + while (remaining > 0) { + let maxByRemaining = Math.floor(Math.log2(remaining)); + let alignmentLevel = 0; + let aligned = count; + while (aligned > 0 && aligned % 2 === 0) { + alignmentLevel += 1; + aligned /= 2; + } + const level = Math.min(maxByRemaining, alignmentLevel); + const blockLeaves = 2 ** level; + await this.#addSubtree(await ensureZeroLevel(level), level); + count += blockLeaves; + remaining -= blockLeaves; + } + + const root = this.stack.findLast?.((value) => value) || [...this.stack].reverse().find((value) => value) || null; + return root ? root.slice() : null; + } +} + +function encodeBString(bytes) { + const data = bytes instanceof Uint8Array ? bytes : encoder.encode(String(bytes ?? '')); + return concatBytes([encoder.encode(`${data.byteLength}:`), data]); +} + +function compareByteArrays(left, right) { + const limit = Math.min(left.byteLength, right.byteLength); + for (let i = 0; i < limit; i += 1) { + if (left[i] !== right[i]) return left[i] - right[i]; + } + return left.byteLength - right.byteLength; +} + +function encodeBValue(value) { + if (value instanceof Uint8Array) return encodeBString(value); + if (typeof value === 'string') return encodeBString(encoder.encode(value)); + if (typeof value === 'number' || typeof value === 'bigint') { + const integer = typeof value === 'bigint' ? value : BigInt(Math.trunc(value)); + return encoder.encode(`i${integer.toString()}e`); + } + if (Array.isArray(value)) { + return concatBytes([encoder.encode('l'), ...value.map(encodeBValue), encoder.encode('e')]); + } + if (value && typeof value === 'object') { + const entries = Object.entries(value).map(([key, item]) => ({ + keyBytes: encoder.encode(key), + value: item, + })).sort((a, b) => compareByteArrays(a.keyBytes, b.keyBytes)); + const parts = [encoder.encode('d')]; + for (const entry of entries) { + parts.push(encodeBString(entry.keyBytes), encodeBValue(entry.value)); + } + parts.push(encoder.encode('e')); + return concatBytes(parts); + } + throw new Error('Неподдерживаемое значение bencode'); +} + +export function buildTorrentV2InfoBytes({ name, size, piecesRoot, pieceLength = TORRENT_V2_PIECE_BYTES } = {}) { + const cleanName = String(name || 'file'); + const fileData = { length: Math.max(0, Math.trunc(Number(size || 0))) }; + if (fileData.length > 0) { + if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) { + throw new Error('Для непустого файла нужен 32-байтный pieces root'); + } + fileData['pieces root'] = piecesRoot; + } + return encodeBValue({ + 'file tree': { + [cleanName]: { + '': fileData, + }, + }, + 'meta version': 2, + name: cleanName, + 'piece length': Math.trunc(pieceLength), + }); +} + +export async function computeTorrentV2InfoHash({ name, size, piecesRoot } = {}) { + const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot }); + const hash = await sha256Bytes(infoBytes); + return { + infoBytes, + infoHash: hash, + infoHashB64Url: bytesToBase64Url(hash), + }; +} + +/** Builds a tracker-less BitTorrent v2 metainfo file. */ +export function buildTorrentV2MetainfoBytes({ name, size, piecesRoot, pieceLayer = [] } = {}) { + const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot }); + const outer = [encoder.encode('d'), encodeBString(encoder.encode('info')), infoBytes]; + if (Number(size || 0) > TORRENT_V2_PIECE_BYTES) { + if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) { + throw new Error('Некорректный pieces root'); + } + const hashes = (Array.isArray(pieceLayer) ? pieceLayer : []).map((hash) => { + if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) throw new Error('Некорректный piece layer'); + return hash; + }); + const layerBytes = concatBytes(hashes); + outer.push( + encodeBString(encoder.encode('piece layers')), + encoder.encode('d'), + encodeBString(piecesRoot), + encodeBString(layerBytes), + encoder.encode('e'), + ); + } + outer.push(encoder.encode('e')); + return concatBytes(outer); +} diff --git a/shine-UI/styles/features/chat.css b/shine-UI/styles/features/chat.css index 0c9ad973..cc8b3c94 100644 --- a/shine-UI/styles/features/chat.css +++ b/shine-UI/styles/features/chat.css @@ -1373,3 +1373,303 @@ right: 0; bottom: 104px; } + +/* ===== DM file transfer v2 + voice notes ===== */ +.dm-attachment-stack { + display: grid; + gap: 8px; + min-width: 0; +} + +.dm-send-icon-btn svg { + width: 22px; + height: 22px; + display: block; + margin: auto; +} + +.dm-send-icon-btn.is-recording { + transform: scale(1.04); + box-shadow: 0 0 0 5px rgba(255, 104, 126, 0.08), 0 0 24px rgba(255, 104, 126, 0.18); +} + +.dm-send-icon-btn.is-recording:not(.is-locked) { + color: rgba(255, 190, 202, 0.98); +} + +.dm-send-icon-btn.is-locked { + color: rgba(255, 218, 226, 0.98); + background: rgba(164, 45, 70, 0.28); + border-color: rgba(255, 150, 171, 0.32); +} + +.dm-send-busy-dot { + display: inline-grid; + place-items: center; + width: 100%; + height: 100%; + font-size: 20px; + line-height: 1; +} + +.dm-voice-recording-status { + grid-column: 1; + min-height: 42px; + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 0 12px; + border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 14px; + background: rgba(255, 255, 255, 0.035); + overflow: hidden; +} + +.dm-voice-recording-status[hidden] { + display: none; +} + +.dm-voice-recording-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255, 104, 126, 0.96); + box-shadow: 0 0 12px rgba(255, 104, 126, 0.55); + animation: dmVoicePulse 1s ease-in-out infinite; +} + +@keyframes dmVoicePulse { + 0%, 100% { opacity: 0.55; transform: scale(0.82); } + 50% { opacity: 1; transform: scale(1); } +} + +.dm-voice-recording-time { + font-variant-numeric: tabular-nums; + font-size: 13px; + font-weight: 720; + color: rgba(255, 230, 235, 0.96); +} + +.dm-voice-recording-hint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + font-size: 11px; + color: rgba(210, 222, 241, 0.66); +} + +.dm-chat-input--recording .dm-actions-col { + align-self: center; +} + +.dm-voice-card { + min-width: min(280px, 72vw); + max-width: min(330px, 76vw); + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + align-items: center; + gap: 10px; +} + +.dm-voice-card__play { + width: 42px; + height: 42px; + min-width: 42px; + padding: 0; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 15px; + line-height: 1; + background: rgba(92, 190, 255, 0.12); + border: 1px solid rgba(159, 211, 255, 0.22); +} + +.dm-voice-card__play:disabled { + opacity: 0.65; + cursor: progress; +} + +.dm-voice-card__body { + min-width: 0; + display: grid; + gap: 6px; +} + +.dm-voice-card__track { + position: relative; + width: 100%; + height: 18px; + min-height: 18px; + padding: 0; + border: 0; + background: transparent; + overflow: hidden; +} + +.dm-voice-card__track::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 8px; + height: 2px; + border-radius: 999px; + background: rgba(220, 234, 255, 0.20); +} + +.dm-voice-card__fill { + position: absolute; + left: 0; + top: 7px; + width: 0; + height: 4px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 8px rgba(92, 190, 255, 0.35); + pointer-events: none; +} + +.dm-voice-card__meta { + display: flex; + justify-content: space-between; + gap: 10px; + font-size: 11px; + color: rgba(205, 219, 242, 0.68); + font-variant-numeric: tabular-nums; +} + +@media (max-width: 390px) { + .dm-voice-card { + min-width: min(252px, 76vw); + } + + .dm-voice-recording-hint { + max-width: 150px; + } +} + +/* Пересылка DM: выбор чата и подтверждение остаются внутри одного feature-owned modal. */ +.dm-forward-modal { + align-items: center; + padding: 18px; +} + +.dm-forward-card { + width: min(92vw, 430px); + max-height: min(78vh, 680px); + overflow: hidden; + gap: 12px; + border: 1px solid rgba(104, 193, 255, 0.25); + border-radius: 24px; + background: linear-gradient(155deg, rgba(20, 31, 49, 0.98), rgba(7, 13, 24, 0.98)); + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.52), 0 0 34px rgba(56, 165, 255, 0.12); + backdrop-filter: blur(24px) saturate(130%); + -webkit-backdrop-filter: blur(24px) saturate(130%); +} + +.dm-forward-head, +.dm-forward-confirm-peer { + display: flex; + align-items: center; + gap: 12px; +} + +.dm-forward-head { + justify-content: space-between; +} + +.dm-forward-close { + width: 36px; + height: 36px; + border-radius: 50%; + font-size: 25px; + line-height: 1; + color: rgba(255, 255, 255, 0.78); + background: rgba(255, 255, 255, 0.05); +} + +.dm-forward-preview { + padding: 11px 13px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + color: rgba(242, 247, 255, 0.76); + background: rgba(255, 255, 255, 0.035); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dm-forward-preview--confirm { + white-space: normal; + max-height: 128px; + overflow: auto; +} + +.dm-forward-list { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 120px; + max-height: min(54vh, 460px); + overflow-y: auto; + padding-right: 2px; +} + +.dm-forward-chat-row { + width: 100%; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 11px; + min-height: 62px; + padding: 8px 10px; + border: 0; + border-radius: 15px; + text-align: left; + color: var(--text); + background: transparent; +} + +.dm-forward-chat-row:hover, +.dm-forward-chat-row:focus-visible { + outline: none; + background: rgba(67, 166, 255, 0.11); +} + +.dm-forward-chat-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.dm-forward-chat-copy strong, +.dm-forward-chat-copy span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dm-forward-chat-arrow { + font-size: 28px; + color: rgba(130, 205, 255, 0.7); +} + +.dm-forward-loading { + padding: 20px 8px; + text-align: center; +} + +.dm-forward-confirm-peer { + padding: 12px; + border-radius: 16px; + background: rgba(67, 166, 255, 0.08); +} + +.dm-forward-confirm-actions { + margin-top: 2px; +} From c3253dceacdce739896bc45f68918b05ed4e731e964d128283dd12fd83449596 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 12 Sep 2026 10:17:23 +0300 Subject: [PATCH 17/30] =?UTF-8?q?=D0=90=D1=80=D1=85=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B2=20Arweave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shine-server-archive/build.gradle | 31 + .../java/server/archive/ArchiveFileNames.java | 56 + .../java/server/archive/ArchiveKeyLoader.java | 75 + .../archive/ArchivePublisherConfig.java | 98 + .../archive/ArchivePublisherScheduler.java | 90 + .../archive/ArchivePublisherService.java | 302 +++ .../server/archive/ArweaveArchiveService.java | 259 ++ .../java/server/archive/ArweaveMerkle.java | 157 ++ .../server/archive/ShineArchiveWriter.java | 310 +++ .../archive/SolanaArchiveHeadWriter.java | 373 +++ .../server/archive/ArchiveFileNamesTest.java | 34 + .../archive/ShineArchiveWriterTest.java | 83 + .../src/main/java/utils/config/AppConfig.java | 13 +- .../java/shine/db/DatabaseInitializer.java | 6 + .../shine/db/archive/ArchiveBigBlockRef.java | 8 + .../shine/db/archive/ArchiveChainCursor.java | 15 + .../shine/db/archive/ArchivePublishJob.java | 16 + .../db/archive/ArchivePublishJobChain.java | 17 + .../shine/db/dao/ArchivePublicationDAO.java | 472 ++++ .../main/resources/postgres/migration_v22.sql | 56 + .../src/main/resources/postgres/schema_v1.sql | 57 +- .../ws_protocol/JSON/JsonHandlerRegistry.java | 8 - .../main/java/sync/codec/ShineUsersCodec.java | 105 +- .../postgres/PostgresStorageRepository.java | 262 +- .../main/java/sync/util/SolanaPdaUtil.java | 2 +- .../java/sync/util/SolanaPdaUtilTest.java | 27 + .../src/main/java/server/ws/WsServer.java | 6 +- .../src/main/resources/application.properties | 28 + build.gradle | 1 + docs/API/09_Operations_Index.md | 2 - docs/Archive/01_PROTOCOL_v1.0.md | 1619 +++++++++++++ docs/Archive/02_IMPLEMENTATION_MAP.md | 98 + docs/Archive/03_DEPLOY_TEST_SERVER.md | 415 ++++ docs/Archive/04_TEST_AND_OPERATIONS.md | 86 + .../Archive/05_PATCH_CONTENTS_AND_REMOVALS.md | 60 + docs/Archive/06_FILE_MANIFEST.md | 73 + docs/Archive/README.md | 28 + .../archive-publisher.example.properties | 23 + docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md | 2147 +++++++++-------- docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md | 174 +- docs/Solana/user_pda/README.md | 29 +- .../details/shine_users.md | 20 + settings.gradle | 2 + shine-UI/js/services/auth-service.js | 16 - .../js/services/shine-user-pda-service.js | 35 +- .../formats/shine-user-pda-format-v.1.0.md | 29 +- .../shine/doc/programs/shine_users.md | 20 + .../shine/programs/shine_users/src/lib.rs | 65 +- 48 files changed, 6681 insertions(+), 1227 deletions(-) create mode 100644 SHiNE-server/shine-server-archive/build.gradle create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java create mode 100644 SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java create mode 100644 SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql create mode 100644 SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/util/SolanaPdaUtilTest.java create mode 100644 docs/Archive/01_PROTOCOL_v1.0.md create mode 100644 docs/Archive/02_IMPLEMENTATION_MAP.md create mode 100644 docs/Archive/03_DEPLOY_TEST_SERVER.md create mode 100644 docs/Archive/04_TEST_AND_OPERATIONS.md create mode 100644 docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md create mode 100644 docs/Archive/06_FILE_MANIFEST.md create mode 100644 docs/Archive/README.md create mode 100644 docs/Archive/archive-publisher.example.properties diff --git a/SHiNE-server/shine-server-archive/build.gradle b/SHiNE-server/shine-server-archive/build.gradle new file mode 100644 index 00000000..dca78bdc --- /dev/null +++ b/SHiNE-server/shine-server-archive/build.gradle @@ -0,0 +1,31 @@ +plugins { + id 'java' +} + +group = 'shine' +version = '1.0.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { mavenCentral() } + +dependencies { + implementation project(':shine-server-config') + implementation project(':shine-server-db') + implementation project(':shine-server-crypto') + implementation project(':shine-server-blockchain') + implementation project(':shine-server-net-protocol') + implementation project(':shine-server-solana-users-sync') + + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' + implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' + implementation 'org.slf4j:slf4j-api:2.0.16' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' +} + +test { useJUnitPlatform() } diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java new file mode 100644 index 00000000..55eff901 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java @@ -0,0 +1,56 @@ +package server.archive; + +import java.nio.file.Path; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Base64; + +/** Единые локальные имена больших архивных файлов. */ +public final class ArchiveFileNames { + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yy"); + + private ArchiveFileNames() {} + + /** + * Временное имя полностью собранного файла до появления Arweave TX ID. + * Пример: archive01.00001.11.09.26.tmp.SHiNE-archive + */ + public static String pendingFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) { + return baseName(login, bigBlockNumber, createdAtMs, zone) + ".tmp.SHiNE-archive"; + } + + /** + * Постоянное имя после успешной загрузки в Arweave. + * Пример: archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive + */ + public static String uploadedFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) { + if (txId == null || txId.length != 32) throw new IllegalArgumentException("Arweave TX ID должен быть 32 bytes"); + String tx = Base64.getUrlEncoder().withoutPadding().encodeToString(txId); + return baseName(login, bigBlockNumber, createdAtMs, zone) + "." + tx + ".SHiNE-archive"; + } + + public static Path pendingFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone) { + return archiveDir.resolve(pendingFileName(login, bigBlockNumber, createdAtMs, zone)); + } + + public static Path uploadedFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) { + return archiveDir.resolve(uploadedFileName(login, bigBlockNumber, createdAtMs, zone, txId)); + } + + private static String baseName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) { + if (bigBlockNumber < 0 || bigBlockNumber > 0xffff_ffffL) { + throw new IllegalArgumentException("Номер большого блока вне u32: " + bigBlockNumber); + } + String safeLogin = sanitize(login); + String number = String.format("%05d", bigBlockNumber); + String date = DATE.format(Instant.ofEpochMilli(createdAtMs).atZone(zone == null ? ZoneId.systemDefault() : zone)); + return safeLogin + "." + number + "." + date; + } + + static String sanitize(String login) { + String s = login == null ? "server" : login.trim(); + if (s.isEmpty()) s = "server"; + return s.replaceAll("[^A-Za-z0-9._-]", "_"); + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java new file mode 100644 index 00000000..0e6d4b37 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java @@ -0,0 +1,75 @@ +package server.archive; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import sync.util.Base58Util; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; + +/** Читает Ed25519 seed32 из распространённых SHiNE/Solana форматов ключей. */ +final class ArchiveKeyLoader { + private static final ObjectMapper JSON = new ObjectMapper(); + + private ArchiveKeyLoader() {} + + static byte[] loadSeed32(Path path) throws IOException { + if (path == null) throw new IllegalArgumentException("Путь ключа не задан"); + byte[] raw = Files.readAllBytes(path); + if (raw.length == 32) return raw.clone(); + if (raw.length == 64) return first32(raw); + + String text = new String(raw, StandardCharsets.UTF_8).trim(); + if (text.isEmpty()) throw new IOException("Пустой key file: " + path); + + // Solana keypair JSON: [64 чисел]. + if (text.startsWith("[")) { + JsonNode arr = JSON.readTree(text); + if (!arr.isArray() || (arr.size() != 32 && arr.size() != 64)) { + throw new IOException("Ожидался JSON keypair на 32/64 байта: " + path); + } + byte[] bytes = new byte[arr.size()]; + for (int i = 0; i < arr.size(); i++) { + int v = arr.get(i).asInt(-1); + if (v < 0 || v > 255) throw new IOException("Некорректный байт keypair: " + path); + bytes[i] = (byte) v; + } + return bytes.length == 32 ? bytes : first32(bytes); + } + + // Base58 seed32 или Solana secret key 64 bytes. + try { + byte[] decoded = Base58Util.decode(text); + if (decoded.length == 32) return decoded; + if (decoded.length == 64) return first32(decoded); + } catch (IllegalArgumentException ignored) { + // Пробуем остальные текстовые форматы ниже. + } + + // Base64 raw seed / keypair / Ed25519 PKCS8. В SHiNE PKCS8 seed находится в последних 32 байтах. + try { + byte[] decoded = Base64.getDecoder().decode(text.replaceAll("\\s+", "")); + if (decoded.length == 32) return decoded; + if (decoded.length == 64) return first32(decoded); + if (decoded.length > 32) { + byte[] seed = new byte[32]; + System.arraycopy(decoded, decoded.length - 32, seed, 0, 32); + return seed; + } + } catch (IllegalArgumentException ignored) { + // Ниже выдадим понятную общую ошибку. + } + + throw new IOException("Не удалось распознать Ed25519 key file: " + path + + ". Поддерживаются raw seed32, raw/keypair64, Solana JSON, Base58 seed/keypair и Base64 PKCS8."); + } + + private static byte[] first32(byte[] bytes) { + byte[] out = new byte[32]; + System.arraycopy(bytes, 0, out, 0, 32); + return out; + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java new file mode 100644 index 00000000..d4ed7e00 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java @@ -0,0 +1,98 @@ +package server.archive; + +import utils.config.AppConfig; +import utils.config.SolanaProgramsConfig; + +import java.nio.file.Path; +import java.time.LocalTime; +import java.time.ZoneId; + +/** Настройки опционального архивного publisher-а. По умолчанию функция выключена. */ +public record ArchivePublisherConfig( + boolean enabled, + LocalTime publishTime, + ZoneId publishZone, + long maxFileBytes, + Path workDir, + String serverLogin, + String arweaveGateway, + Path arweaveWalletJwkPath, + int arweaveMinConfirmations, + int arweaveConfirmPollSeconds, + int arweaveConfirmTimeoutMinutes, + Path solanaRootKeyPath, + Path solanaClientKeyPath, + String solanaRpcUrl, + String solanaUsersProgramId, + String solanaPaymentsProgramId, + int solanaConfirmPollSeconds, + int solanaConfirmTimeoutMinutes, + String solanaCommitment +) { + public static ArchivePublisherConfig load() { + AppConfig c = AppConfig.getInstance(); + return new ArchivePublisherConfig( + c.getBoolean("archive.publish.enabled", false), + parseTime(orDefault(c.getParam("archive.publish.time"), "00:00")), + parseZone(c.getParam("archive.publish.zoneId")), + positiveLong(parseLong(c.getParam("archive.maxFileBytes"), 4_000_000_000L), "archive.maxFileBytes"), + Path.of(orDefault(c.getParam("archive.workDir"), "data/archive")), + requiredIfEnabled(c, "server.SHiNE.login"), + orDefault(c.getParam("archive.arweave.gateway"), "https://arweave.net"), + optionalPath(c.getParam("archive.arweave.walletJwkPath")), + nonNegative(c.getInt("archive.arweave.minConfirmations", 1), "archive.arweave.minConfirmations"), + positive(c.getInt("archive.arweave.confirmPollSeconds", 30), "archive.arweave.confirmPollSeconds"), + positive(c.getInt("archive.arweave.confirmTimeoutMinutes", 180), "archive.arweave.confirmTimeoutMinutes"), + optionalPath(c.getParam("archive.solana.rootKeyPath")), + optionalPath(c.getParam("archive.solana.clientKeyPath")), + firstNonBlank(c.getParam("solana.users.sync.rpcUrl"), c.getParam("solana.rpcUrl"), SolanaProgramsConfig.SOLANA_RPC_URL), + firstNonBlank(c.getParam("solana.users.sync.programId"), SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID), + SolanaProgramsConfig.SHINE_PAYMENTS_PROGRAM_ID, + positive(c.getInt("archive.solana.confirmPollSeconds", 5), "archive.solana.confirmPollSeconds"), + positive(c.getInt("archive.solana.confirmTimeoutMinutes", 30), "archive.solana.confirmTimeoutMinutes"), + orDefault(c.getParam("archive.solana.commitment"), "finalized") + ); + } + + public void validateForStart() { + if (!enabled) return; + if (serverLogin == null || serverLogin.isBlank()) throw new IllegalArgumentException("server.SHiNE.login обязателен для archive publisher"); + if (arweaveWalletJwkPath == null) throw new IllegalArgumentException("archive.arweave.walletJwkPath обязателен"); + if (solanaRootKeyPath == null) throw new IllegalArgumentException("archive.solana.rootKeyPath обязателен"); + if (solanaClientKeyPath == null) throw new IllegalArgumentException("archive.solana.clientKeyPath обязателен"); + if (solanaRpcUrl == null || solanaRpcUrl.isBlank()) throw new IllegalArgumentException("solana.users.sync.rpcUrl или solana.rpcUrl обязателен"); + if (maxFileBytes >= 0x1_0000_0000L) { + throw new IllegalArgumentException("SHINE-ARCHIVE v1 требует archive.maxFileBytes < 4294967296"); + } + } + + private static String requiredIfEnabled(AppConfig c, String name) { + String v = c.getParam(name); + return v == null ? "" : v.trim(); + } + private static Path optionalPath(String s) { return s == null || s.isBlank() ? null : Path.of(s.trim()); } + private static String firstNonBlank(String... values) { + if (values != null) for (String v : values) if (v != null && !v.isBlank()) return v.trim(); + return ""; + } + private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); } + private static long parseLong(String s, long d) { return s == null || s.isBlank() ? d : Long.parseLong(s.trim()); } + private static LocalTime parseTime(String value) { + try { + return LocalTime.parse(value.trim()); + } catch (Exception e) { + throw new IllegalArgumentException("archive.publish.time должен быть в формате HH:mm, например 00:00", e); + } + } + private static ZoneId parseZone(String value) { + if (value == null || value.isBlank()) return ZoneId.systemDefault(); + try { + return ZoneId.of(value.trim()); + } catch (Exception e) { + throw new IllegalArgumentException("Некорректный archive.publish.zoneId: " + value, e); + } + } + private static int positive(int v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; } + private static int nonNegative(int v, String n) { if (v < 0) throw new IllegalArgumentException(n + " должен быть >= 0"); return v; } + private static long positiveLong(long v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java new file mode 100644 index 00000000..10afac77 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java @@ -0,0 +1,90 @@ +package server.archive; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Опциональный scheduler: один новый archive job в заданное локальное время каждый день. */ +public final class ArchivePublisherScheduler { + private static final Logger log = LoggerFactory.getLogger(ArchivePublisherScheduler.class); + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static ScheduledExecutorService executor; + + private ArchivePublisherScheduler() {} + + public static void startOrLog() { + ArchivePublisherConfig cfg; + try { + cfg = ArchivePublisherConfig.load(); + } catch (Exception e) { + log.error("Не удалось прочитать archive publisher config", e); + return; + } + if (!cfg.enabled()) { + log.info("Archive publisher выключен (archive.publish.enabled=false)"); + return; + } + if (!STARTED.compareAndSet(false, true)) return; + + try { + ArchivePublisherService service = new ArchivePublisherService(cfg); + executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + @Override public Thread newThread(Runnable r) { + Thread t = new Thread(r, "shine-archive-publisher"); + t.setDaemon(true); + return t; + } + }); + + // Незавершённую публикацию после рестарта продолжаем сразу, а новый snapshot + // создаём только в очередное назначенное суточное время. + executor.execute(() -> { + try { + service.resumeUnfinishedJob(); + } catch (Exception e) { + log.error("Не удалось продолжить незавершённый archive job", e); + } + }); + scheduleNextDaily(service, cfg); + log.info("Archive publisher включён: login={} dir={} dailyAt={} zone={}", + cfg.serverLogin(), cfg.workDir(), cfg.publishTime(), cfg.publishZone()); + } catch (Exception e) { + STARTED.set(false); + log.error("Archive publisher НЕ запущен из-за ошибки preflight", e); + } + } + + private static void scheduleNextDaily(ArchivePublisherService service, ArchivePublisherConfig cfg) { + ScheduledExecutorService e = executor; + if (e == null || e.isShutdown()) return; + + ZonedDateTime now = ZonedDateTime.now(cfg.publishZone()); + ZonedDateTime next = now.toLocalDate().atTime(cfg.publishTime()).atZone(cfg.publishZone()); + if (!next.isAfter(now)) next = next.plusDays(1); + long delayMs = Math.max(1L, Duration.between(now, next).toMillis()); + + e.schedule(() -> { + try { + service.runCycle(); + } catch (Exception ex) { + log.error("Archive publisher daily cycle failed; retry нового snapshot будет в следующее назначенное время", ex); + } finally { + scheduleNextDaily(service, cfg); + } + }, delayMs, TimeUnit.MILLISECONDS); + + log.info("Следующая архивная публикация запланирована на {}", next); + } + + public static void close() { + ScheduledExecutorService e = executor; + if (e != null) e.shutdownNow(); + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java new file mode 100644 index 00000000..a523f849 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java @@ -0,0 +1,302 @@ +package server.archive; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; +import shine.db.archive.ArchiveBigBlockRef; +import shine.db.archive.ArchiveChainCursor; +import shine.db.archive.ArchivePublishJob; +import shine.db.archive.ArchivePublishJobChain; +import shine.db.dao.ArchivePublicationDAO; +import shine.db.dao.BlockchainStateDAO; +import shine.db.dao.BlocksDAO; +import shine.db.entities.BlockEntry; +import shine.db.entities.BlockchainStateEntry; + +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; + +/** Один crash-safe цикл публикации большого SHINE-ARCHIVE блока. */ +public final class ArchivePublisherService { + private static final Logger log = LoggerFactory.getLogger(ArchivePublisherService.class); + + private final ArchivePublisherConfig cfg; + private final ArchivePublicationDAO archiveDao = ArchivePublicationDAO.getInstance(); + private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance(); + private final BlocksDAO blocksDao = BlocksDAO.getInstance(); + private final ShineArchiveWriter writer = new ShineArchiveWriter(); + private final ArweaveArchiveService arweave; + private final SolanaArchiveHeadWriter solana; + private final byte[] archiveSigningSeed32; + private final AtomicBoolean running = new AtomicBoolean(false); + + public ArchivePublisherService(ArchivePublisherConfig cfg) throws Exception { + this.cfg = Objects.requireNonNull(cfg); + cfg.validateForStart(); + Files.createDirectories(cfg.workDir()); + this.archiveSigningSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath()); + this.arweave = new ArweaveArchiveService(cfg); + this.solana = new SolanaArchiveHeadWriter(cfg); + this.solana.validateKeysAgainstPda(); + } + + /** После рестарта продолжает только уже существующий незавершённый job; новый snapshot не создаёт. */ + public boolean resumeUnfinishedJob() throws Exception { + if (!running.compareAndSet(false, true)) return false; + try { + ArchivePublishJob job = archiveDao.findUnfinishedJob(); + if (job == null) return false; + log.info("Archive publisher: после рестарта продолжаем job id={} block={} status={}", + job.id(), job.bigBlockNumber(), job.status()); + process(job.id()); + return true; + } finally { + running.set(false); + } + } + + /** Запускает суточный цикл: продолжает незавершённый job либо создаёт новый frozen snapshot. */ + public boolean runCycle() throws Exception { + if (!running.compareAndSet(false, true)) { + log.info("Archive publisher: предыдущий цикл ещё работает, новый пропущен"); + return false; + } + try { + ArchivePublishJob job = archiveDao.findUnfinishedJob(); + if (job == null) { + job = createFrozenJob(); + if (job == null) { + log.info("Archive publisher: новых SHiNE-блоков нет"); + return false; + } + } else { + log.info("Archive publisher: продолжаем незавершённый job id={} block={} status={}", + job.id(), job.bigBlockNumber(), job.status()); + } + process(job.id()); + return true; + } finally { + running.set(false); + } + } + + private ArchivePublishJob createFrozenJob() throws Exception { + List chains = new ArrayList<>(); + for (BlockchainStateEntry listed : stateDao.listAll()) { + if (listed == null || listed.getBlockchainName() == null || listed.getBlockchainName().isBlank()) continue; + String name = listed.getBlockchainName(); + ReentrantLock lock = BlockchainLocks.lockFor(name); + lock.lock(); + try { + BlockchainStateEntry state = stateDao.getByBlockchainName(name); + if (state == null || state.getLastBlockNumber() < 0) continue; + ArchiveChainCursor cursor = archiveDao.getCursor(name); + long from; + byte[] previousSourceHash; + Long prevBig = null; + byte[] prevBigHash = null; + Long prevChunkOffset = null; + Long prevChunkSize = null; + + if (cursor == null) { + from = 0L; + previousSourceHash = new byte[32]; + } else { + long cursorBlock = cursor.lastArchivedSourceBlockNumber(); + if (cursorBlock < 0 || cursorBlock > 0xffff_ffffL) { + throw new IllegalStateException("Некорректный archive cursor для " + name + ": " + cursorBlock); + } + byte[] actualCursorHash = blocksDao.getHashByNumber(name, (int) cursorBlock); + if (!Arrays.equals(actualCursorHash, cursor.lastArchivedSourceBlockHash())) { + throw new IllegalStateException("Archive cursor hash не совпадает с локальной blockchain: " + name + "#" + cursorBlock); + } + from = cursorBlock + 1L; + previousSourceHash = cursor.lastArchivedSourceBlockHash(); + prevBig = cursor.lastArchiveBigBlockNumber(); + prevBigHash = cursor.lastArchiveBigBlockHash(); + prevChunkOffset = cursor.lastChunkOffset(); + prevChunkSize = cursor.lastChunkSize(); + } + + long to = Integer.toUnsignedLong(state.getLastBlockNumber()); + if (from > to) continue; + if (from > 0xffff_ffffL || to > 0xffff_ffffL) throw new IllegalStateException("Source block number вне u32: " + name); + byte[] lastHash = state.getLastBlockHash(); + if (lastHash == null || lastHash.length != 32) { + lastHash = blocksDao.getHashByNumber(name, (int) to); + } + require32(lastHash, "last source block hash " + name); + chains.add(new ArchivePublishJobChain( + 0L, name, from, to, previousSourceHash, lastHash, + prevBig, prevBigHash, prevChunkOffset, prevChunkSize, null, null)); + } finally { + lock.unlock(); + } + } + + if (chains.isEmpty()) return null; + chains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName)); + long bigBlock = archiveDao.nextBigBlockNumber(); + if (bigBlock > 0xffff_ffffL) throw new IllegalStateException("Закончился диапазон u32 big_block_number"); + long createdAt = System.currentTimeMillis(); + long id = archiveDao.createJobWithChains(bigBlock, createdAt, chains); + log.info("Archive snapshot создан: job={} bigBlock={} chains={}", id, bigBlock, chains.size()); + return archiveDao.getJob(id); + } + + private void process(long jobId) throws Exception { + while (true) { + ArchivePublishJob job = archiveDao.getJob(jobId); + if (job == null) throw new IllegalStateException("Archive job исчез: " + jobId); + switch (job.status()) { + case "SNAPSHOT_CREATED" -> buildFile(job); + case "FILE_BUILT" -> uploadArweave(job); + case "ARWEAVE_UPLOADED" -> waitArweave(job); + case "ARWEAVE_CONFIRMED" -> submitSolana(job); + case "SOLANA_SUBMITTED" -> waitSolana(job); + case "SOLANA_FINALIZED" -> commitCursors(job); + case "CURSORS_COMMITTED" -> { + log.info("Archive job завершён: id={} bigBlock={} file={} tx={}", job.id(), job.bigBlockNumber(), job.localArchivePath(), b64url(job.arweaveTxId())); + return; + } + case "FAILED" -> throw new IllegalStateException("Archive job помечен FAILED: " + job.errorText()); + default -> throw new IllegalStateException("Неизвестный archive job status: " + job.status()); + } + } + } + + private void buildFile(ArchivePublishJob job) throws Exception { + List chains = archiveDao.listJobChains(job.id()); + if (chains.isEmpty()) throw new IllegalStateException("SNAPSHOT_CREATED job не содержит chains: " + job.id()); + Map> records = new LinkedHashMap<>(); + + for (ArchivePublishJobChain ch : chains) { + ReentrantLock lock = BlockchainLocks.lockFor(ch.blockchainName()); + lock.lock(); + try { + if (ch.fromSourceBlockNumber() > 0) { + byte[] prev = blocksDao.getHashByNumber(ch.blockchainName(), (int)(ch.fromSourceBlockNumber() - 1)); + if (!Arrays.equals(prev, ch.previousSourceBlockHash())) { + throw new IllegalStateException("Frozen previous source hash изменился: " + ch.blockchainName()); + } + } + List range = blocksDao.listRangeByNumber( + ch.blockchainName(), (int)ch.fromSourceBlockNumber(), (int)ch.toSourceBlockNumber()); + if (range.isEmpty()) throw new IllegalStateException("Frozen range пуст: " + ch.blockchainName()); + byte[] last = range.get(range.size()-1).getBlockHash(); + if (!Arrays.equals(last, ch.lastSourceBlockHash())) { + throw new IllegalStateException("Frozen last source hash изменился: " + ch.blockchainName()); + } + records.put(ch.blockchainName(), range); + } finally { + lock.unlock(); + } + } + + List previous = archiveDao.listFinalizedBigBlocks(); + Path file = ArchiveFileNames.pendingFilePath( + cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone()); + ShineArchiveWriter.BuildResult result = writer.build( + file, cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), previous, + chains, records, archiveSigningSeed32, cfg.maxFileBytes()); + for (Map.Entry e : result.chunks().entrySet()) { + archiveDao.updateNewChunkPosition(job.id(), e.getKey(), e.getValue().offset(), e.getValue().size()); + } + archiveDao.markFileBuilt(job.id(), result.file().toString(), result.archiveHash()); + log.info("SHINE-ARCHIVE локально сохранён: block={} file={} bytes={} hash={}", + job.bigBlockNumber(), result.file(), result.fileSize(), hex(result.archiveHash())); + } + + private void uploadArweave(ArchivePublishJob job) throws Exception { + Path file = requireLocalFile(job); + byte[] verified = ShineArchiveWriter.verifyLocalFile(file); + if (!Arrays.equals(verified, job.archiveHash())) throw new IllegalStateException("Локальный archive hash отличается от job DB"); + ArweaveArchiveService.UploadResult upload = arweave.upload(file, cfg.serverLogin(), job.bigBlockNumber(), job.archiveHash()); + // Сначала надёжно фиксируем полученный TX ID в БД. Если процесс упадёт до rename, + // recovery на следующем шаге переименует тот же временный файл без повторной загрузки. + archiveDao.markArweaveUploaded(job.id(), upload.txIdBytes()); + Path uploadedFile = markLocalFileUploaded(job, file, upload.txIdBytes()); + log.info("SHINE-ARCHIVE загружен в Arweave: block={} tx={} file={}", + job.bigBlockNumber(), upload.txId(), uploadedFile); + } + + private void waitArweave(ArchivePublishJob job) throws Exception { + ensureUploadedLocalName(job); + String tx = b64url(job.arweaveTxId()); + int confirmations = arweave.waitForConfirmations(tx); + archiveDao.markArweaveConfirmed(job.id(), confirmations); + log.info("Arweave archive закреплён: block={} tx={} confirmations={}", job.bigBlockNumber(), tx, confirmations); + } + + + /** После успешного Arweave upload временный файл получает реальный TX ID в имени и остаётся локально. */ + private Path markLocalFileUploaded(ArchivePublishJob job, Path currentFile, byte[] txId) throws Exception { + require32(txId, "arweave tx id"); + Path target = ArchiveFileNames.uploadedFilePath( + cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), txId); + Files.createDirectories(target.toAbsolutePath().getParent()); + if (!currentFile.equals(target)) { + try { + Files.move(currentFile, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(currentFile, target, StandardCopyOption.REPLACE_EXISTING); + } + } + archiveDao.updateLocalArchivePath(job.id(), target.toString()); + return target; + } + + /** Crash recovery: TX уже сохранён, а временный файл ещё не успели переименовать. */ + private void ensureUploadedLocalName(ArchivePublishJob job) throws Exception { + require32(job.arweaveTxId(), "job.arweave_tx_id"); + Path target = ArchiveFileNames.uploadedFilePath( + cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), job.arweaveTxId()); + if (Files.isRegularFile(target)) { + if (!target.toString().equals(job.localArchivePath())) { + archiveDao.updateLocalArchivePath(job.id(), target.toString()); + } + return; + } + if (job.localArchivePath() == null || job.localArchivePath().isBlank()) return; + Path current = Path.of(job.localArchivePath()); + if (Files.isRegularFile(current)) { + markLocalFileUploaded(job, current, job.arweaveTxId()); + } + } + + private void submitSolana(ArchivePublishJob job) throws Exception { + require32(job.arweaveTxId(), "job.arweave_tx_id"); + require32(job.archiveHash(), "job.archive_hash"); + SolanaArchiveHeadWriter.SubmitResult submitted = solana.submitArchiveHead(job.arweaveTxId(), job.archiveHash()); + archiveDao.markSolanaSubmitted(job.id(), submitted.signature()); + } + + private void waitSolana(ArchivePublishJob job) throws Exception { + if (job.solanaSignature() == null || job.solanaSignature().isBlank()) throw new IllegalStateException("SOLANA_SUBMITTED без signature"); + solana.waitFinalized(job.solanaSignature()); + if (!solana.currentHeadMatches(job.arweaveTxId(), job.archiveHash())) { + throw new IllegalStateException("После Solana finalized User PDA archive head не совпадает с job"); + } + archiveDao.markSolanaFinalized(job.id()); + log.info("Solana archive head finalized: block={} signature={}", job.bigBlockNumber(), job.solanaSignature()); + } + + private void commitCursors(ArchivePublishJob job) throws Exception { + archiveDao.commitCursors(job.id(), job.archiveHash()); + } + + private static Path requireLocalFile(ArchivePublishJob job) { + if (job.localArchivePath() == null || job.localArchivePath().isBlank()) throw new IllegalStateException("FILE_BUILT без local_archive_path"); + Path p = Path.of(job.localArchivePath()); + if (!Files.isRegularFile(p)) throw new IllegalStateException("Локальный archive-файл отсутствует: " + p); + return p; + } + + private static void require32(byte[] v, String name) { + if (v == null || v.length != 32) throw new IllegalStateException(name + " должен быть 32 bytes"); + } + private static String b64url(byte[] v) { return v == null ? "" : Base64.getUrlEncoder().withoutPadding().encodeToString(v); } + private static String hex(byte[] b){StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java new file mode 100644 index 00000000..2711d4c7 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java @@ -0,0 +1,259 @@ +package server.archive; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.*; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; +import java.security.spec.RSAPrivateCrtKeySpec; +import java.time.Duration; +import java.util.*; + +/** + * Публикация готовых локальных SHINE-ARCHIVE файлов в Arweave v2. + * Поддерживает стандартный data Merkle tree и загрузку через /chunk. + */ +public final class ArweaveArchiveService { + private static final Logger log = LoggerFactory.getLogger(ArweaveArchiveService.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder B64D = Base64.getUrlDecoder(); + + private final ArchivePublisherConfig cfg; + private final HttpClient http; + private volatile Wallet wallet; + + public ArweaveArchiveService(ArchivePublisherConfig cfg) { + this.cfg = Objects.requireNonNull(cfg); + this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); + } + + public record UploadResult(String txId, byte[] txIdBytes, String gateway) {} + + public UploadResult upload(Path archiveFile, String publisherLogin, long bigBlockNumber, byte[] archiveHash) + throws Exception { + long dataSize = Files.size(archiveFile); + if (dataSize <= 0) throw new IllegalArgumentException("Пустой SHINE-ARCHIVE"); + Wallet w = wallet(); + String gateway = trimGateway(cfg.arweaveGateway()); + + ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(archiveFile); + String anchor = getRequiredText(gateway, "/tx_anchor"); + String reward = getRequiredText(gateway, "/price/" + dataSize); + if (!reward.matches("^\\d+$")) throw new IOException("Arweave вернул некорректную цену: " + reward); + verifyBalance(gateway, w.address(), reward); + + List> tagList = new ArrayList<>(); + List> jsonTags = new ArrayList<>(); + addTag(jsonTags, tagList, "Content-Type", "application/octet-stream"); + addTag(jsonTags, tagList, "App-Name", "SHiNE"); + addTag(jsonTags, tagList, "SHiNE-Type", "archive"); + addTag(jsonTags, tagList, "SHiNE-Archive-Version", "1.0"); + addTag(jsonTags, tagList, "SHiNE-Publisher", publisherLogin); + addTag(jsonTags, tagList, "SHiNE-Big-Block", Long.toUnsignedString(bigBlockNumber)); + addTag(jsonTags, tagList, "SHiNE-Archive-SHA256", hex(archiveHash)); + + byte[] signaturePayload = deepHash(List.of( + utf8("2"), + b64Decode(w.owner()), + new byte[0], + utf8("0"), + utf8(reward), + b64Decode(anchor), + tagList, + utf8(Long.toString(dataSize)), + merkle.dataRoot() + )); + byte[] rawSignature = signPayload(w.privateKey(), signaturePayload); + byte[] txIdBytes = sha256(rawSignature); + String txId = b64(txIdBytes); + + Map tx = new LinkedHashMap<>(); + tx.put("format", 2); + tx.put("id", txId); + tx.put("last_tx", anchor); + tx.put("owner", w.owner()); + tx.put("tags", jsonTags); + tx.put("target", ""); + tx.put("quantity", "0"); + tx.put("data_root", b64(merkle.dataRoot())); + tx.put("data_size", Long.toString(dataSize)); + tx.put("data", ""); + tx.put("reward", reward); + tx.put("signature", b64(rawSignature)); + + postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60)); + log.info("Arweave tx header принят: tx={}, chunks={}, bytes={}", txId, merkle.chunks().size(), dataSize); + + for (int i=0; i body = new LinkedHashMap<>(); + body.put("data_root", b64(merkle.dataRoot())); + body.put("data_size", Long.toString(dataSize)); + body.put("data_path", b64(proof.proof())); + body.put("offset", Long.toString(proof.offset())); + body.put("chunk", b64(chunk)); + postChunkWithRetry(gateway, body, i, merkle.chunks().size()); + } + return new UploadResult(txId, txIdBytes, gateway); + } + + /** Ждёт включения tx в блок и заданного количества подтверждений. */ + public int waitForConfirmations(String txId) throws Exception { + if (cfg.arweaveMinConfirmations() <= 0) return 0; + String gateway = trimGateway(cfg.arweaveGateway()); + long deadline = System.currentTimeMillis() + cfg.arweaveConfirmTimeoutMinutes() * 60_000L; + int last = 0; + while (System.currentTimeMillis() < deadline) { + HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx/" + txId + "/status")) + .timeout(Duration.ofSeconds(20)).GET().build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (resp.statusCode() == 200) { + JsonNode json = MAPPER.readTree(resp.body()); + last = json.path("number_of_confirmations").asInt(0); + if (last >= cfg.arweaveMinConfirmations()) return last; + } else if (resp.statusCode() != 202 && resp.statusCode() != 404) { + log.warn("Arweave status tx={} HTTP {}: {}", txId, resp.statusCode(), safe(resp.body())); + } + Thread.sleep(cfg.arweaveConfirmPollSeconds() * 1000L); + } + throw new IOException("Истёк timeout ожидания Arweave confirmations для " + txId + ", last=" + last); + } + + private void postChunkWithRetry(String gateway, Map body, int index, int total) throws Exception { + Exception last = null; + for (int attempt=1; attempt<=12; attempt++) { + try { + postJsonExpect2xx(gateway + "/chunk", body, Duration.ofSeconds(60)); + if ((index + 1) % 25 == 0 || index + 1 == total) { + log.info("Arweave chunks: {}/{}", index + 1, total); + } + return; + } catch (Exception e) { + last = e; + if (attempt == 12) break; + log.warn("Arweave chunk {}/{} attempt {}/12: {}", index + 1, total, attempt, e.getMessage()); + Thread.sleep(5_000L); + } + } + throw new IOException("Не удалось загрузить Arweave chunk " + (index + 1) + "/" + total, last); + } + + private void postJsonExpect2xx(String url, Object body, Duration timeout) throws Exception { + String json = MAPPER.writeValueAsString(body); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .timeout(timeout) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw new IOException("HTTP " + resp.statusCode() + " " + safe(resp.body())); + } + } + + private void verifyBalance(String gateway, String address, String reward) throws Exception { + if (address == null || address.isBlank()) return; + String balance = getRequiredText(gateway, "/wallet/" + address + "/balance"); + if (balance.matches("^\\d+$") && new BigInteger(balance).compareTo(new BigInteger(reward)) < 0) { + throw new IllegalStateException("Недостаточно AR: balance=" + balance + ", reward=" + reward); + } + } + + private String getRequiredText(String gateway, String path) throws Exception { + HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path)).timeout(Duration.ofSeconds(20)).GET().build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw new IOException("Arweave HTTP " + resp.statusCode() + " для " + path + ": " + safe(resp.body())); + } + String s = resp.body() == null ? "" : resp.body().trim(); + if (s.isBlank()) throw new IOException("Пустой Arweave ответ для " + path); + return s; + } + + private Wallet wallet() throws Exception { + Wallet local = wallet; + if (local != null) return local; + synchronized (this) { + if (wallet != null) return wallet; + JsonNode jwk = MAPPER.readTree(Files.readString(cfg.arweaveWalletJwkPath(), StandardCharsets.UTF_8)); + String owner = required(jwk, "n"); + PrivateKey pk = buildPrivateKey(jwk); + String address = b64(sha256(b64Decode(owner))); + wallet = new Wallet(owner, address, pk); + return wallet; + } + } + + private static PrivateKey buildPrivateKey(JsonNode jwk) throws GeneralSecurityException { + RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec( + big(required(jwk,"n")), big(required(jwk,"e")), big(required(jwk,"d")), + big(required(jwk,"p")), big(required(jwk,"q")), big(required(jwk,"dp")), + big(required(jwk,"dq")), big(required(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[] 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))); + return sha384(concat(sha384(tag), sha384(bytes))); + } + + private static byte[] deepHashChunks(List list, byte[] acc) throws GeneralSecurityException { + byte[] cur = acc; + for (Object item : list) cur = sha384(concat(cur, deepHash(item))); + return cur; + } + + private static void addTag(List> json, List> raw, String name, String value) { + byte[] n=utf8(name), v=utf8(value); + Map item = new LinkedHashMap<>(); + item.put("name", b64(n)); item.put("value", b64(v)); + json.add(item); raw.add(List.of(n,v)); + } + + private static String required(JsonNode node, String field) { + String s = node == null ? "" : node.path(field).asText("").trim(); + if (s.isBlank()) throw new IllegalStateException("В Arweave JWK отсутствует " + field); + return s; + } + private static BigInteger big(String s) { return new BigInteger(1, b64Decode(s)); } + private static String trimGateway(String s) { return (s == null ? "https://arweave.net" : s.trim()).replaceAll("/+$", ""); } + private static byte[] utf8(String s) { return String.valueOf(s == null ? "" : s).getBytes(StandardCharsets.UTF_8); } + private static byte[] b64Decode(String s) { return B64D.decode(s); } + private static String b64(byte[] b) { return B64.encodeToString(b); } + private static byte[] sha256(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-256").digest(b); } + private static byte[] sha384(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-384").digest(b); } + private static String hex(byte[] b) { StringBuilder s=new StringBuilder(); for(byte x:b)s.append(String.format("%02x",x)); return s.toString(); } + private static byte[] concat(byte[]... arrays) { int n=0; for(byte[] a:arrays)n=Math.addExact(n,a.length); byte[] o=new byte[n]; int p=0; for(byte[]a:arrays){System.arraycopy(a,0,o,p,a.length);p+=a.length;} return o; } + private static String safe(String s) { s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim(); return s.length()<=250?s:s.substring(0,250)+"..."; } + + private record Wallet(String owner, String address, PrivateKey privateKey) {} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java new file mode 100644 index 00000000..a33c81ef --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java @@ -0,0 +1,157 @@ +package server.archive; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; + +/** + * Arweave v2 data Merkle tree/proofs. Алгоритм повторяет arweave-js merkle.ts. + */ +final class ArweaveMerkle { + static final int MAX_CHUNK_SIZE = 256 * 1024; + static final int MIN_CHUNK_SIZE = 32 * 1024; + + record Chunk(byte[] dataHash, long minByteRange, long maxByteRange) { + long size() { return maxByteRange - minByteRange; } + } + record Proof(long offset, byte[] proof) {} + record Prepared(byte[] dataRoot, List chunks, List proofs) {} + + private sealed interface Node permits Leaf, Branch { + byte[] id(); + long maxByteRange(); + } + private record Leaf(byte[] id, byte[] dataHash, long minByteRange, long maxByteRange) implements Node {} + private record Branch(byte[] id, long byteRange, long maxByteRange, Node left, Node right) implements Node {} + + private ArweaveMerkle() {} + + static Prepared prepare(Path file) throws IOException { + long fileSize = Files.size(file); + List chunks = chunkFile(file, fileSize); + List leaves = new ArrayList<>(chunks.size()); + for (Chunk c : chunks) { + byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange())))); + leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange())); + } + if (leaves.isEmpty()) throw new IOException("Arweave Merkle: нет leaves"); + Node root = buildLayers(leaves); + List proofs = new ArrayList<>(); + resolveProofs(root, new byte[0], proofs); + + // arweave-js строит root вместе с нулевым последним chunk при точном кратном 256KiB, + // но сам нулевой chunk/proof не загружает. + if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) { + chunks = new ArrayList<>(chunks); + chunks.remove(chunks.size() - 1); + proofs.remove(proofs.size() - 1); + } + if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch"); + return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs)); + } + + private static List chunkFile(Path file, long fileSize) throws IOException { + List out = new ArrayList<>(); + long remaining = fileSize; + long cursor = 0; + try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) { + while (remaining >= MAX_CHUNK_SIZE) { + long chunkSize = MAX_CHUNK_SIZE; + long next = remaining - MAX_CHUNK_SIZE; + if (next > 0 && next < MIN_CHUNK_SIZE) { + chunkSize = (remaining + 1) / 2; + } + byte[] data = readExact(in, (int)chunkSize); + out.add(new Chunk(sha256(data), cursor, cursor + chunkSize)); + cursor += chunkSize; + remaining -= chunkSize; + } + byte[] rest = readExact(in, (int)remaining); + out.add(new Chunk(sha256(rest), cursor, cursor + remaining)); + } + return out; + } + + private static Node buildLayers(List nodes) { + if (nodes.size() < 2) return nodes.get(0); + List next = new ArrayList<>((nodes.size() + 1) / 2); + for (int i = 0; i < nodes.size(); i += 2) { + Node left = nodes.get(i); + Node right = i + 1 < nodes.size() ? nodes.get(i + 1) : null; + if (right == null) { + next.add(left); + continue; + } + long byteRange = left.maxByteRange(); + byte[] id = sha256(concat( + sha256(left.id()), + sha256(right.id()), + sha256(note(byteRange)) + )); + next.add(new Branch(id, byteRange, right.maxByteRange(), left, right)); + } + return buildLayers(next); + } + + private static void resolveProofs(Node node, byte[] prefix, List out) { + if (node instanceof Leaf leaf) { + out.add(new Proof(leaf.maxByteRange() - 1, + concat(prefix, leaf.dataHash(), note(leaf.maxByteRange())))); + return; + } + Branch b = (Branch) node; + byte[] partial = concat(prefix, b.left().id(), b.right().id(), note(b.byteRange())); + resolveProofs(b.left(), partial, out); + resolveProofs(b.right(), partial, out); + } + + static byte[] readChunk(Path file, Chunk c) throws IOException { + long len = c.size(); + if (len < 0 || len > Integer.MAX_VALUE) throw new IOException("Некорректный Arweave chunk size: " + len); + try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) { + raf.seek(c.minByteRange()); + byte[] data = new byte[(int)len]; + raf.readFully(data); + return data; + } + } + + private static byte[] readExact(InputStream in, int len) throws IOException { + byte[] out = new byte[len]; + int pos = 0; + while (pos < len) { + int n = in.read(out, pos, len - pos); + if (n < 0) throw new EOFException("Неожиданный EOF"); + pos += n; + } + return out; + } + + private static byte[] note(long value) { + if (value < 0) throw new IllegalArgumentException("negative note"); + byte[] out = new byte[32]; + long v = value; + for (int i = 31; i >= 0 && v != 0; i--) { + out[i] = (byte)(v & 0xff); + v >>>= 8; + } + return out; + } + + private static byte[] sha256(byte[] data) { + try { return MessageDigest.getInstance("SHA-256").digest(data); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + + private static byte[] concat(byte[]... arrays) { + int len = 0; + for (byte[] a : arrays) len = Math.addExact(len, a.length); + byte[] out = new byte[len]; + int p=0; + for (byte[] a : arrays) { System.arraycopy(a,0,out,p,a.length); p+=a.length; } + return out; + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java new file mode 100644 index 00000000..e1e99d4e --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java @@ -0,0 +1,310 @@ +package server.archive; + +import shine.db.archive.ArchiveBigBlockRef; +import shine.db.archive.ArchivePublishJobChain; +import shine.db.entities.BlockEntry; +import utils.crypto.Ed25519Util; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; + +/** + * Writer бинарного SHINE-ARCHIVE v1.0. + * + * Один blockchain_name встречается максимум один раз и содержит все новые raw records этой цепочки. + */ +public final class ShineArchiveWriter { + public static final byte VERSION_MAJOR = 1; + public static final byte VERSION_MINOR = 0; + public static final byte REFERENCES_FULL = 0; + public static final long NO_REFERENCE_U32 = 0xffff_ffffL; + public static final int REFERENCE_ENTRY_SIZE = 68; + public static final byte[] MAGIC = "SHINE-ARCHIVE".getBytes(StandardCharsets.US_ASCII); + public static final byte[] SIGNATURE_DOMAIN = "SHINE-ARCHIVE-V1".getBytes(StandardCharsets.US_ASCII); + + public record ChunkPosition(long offset, long size) {} + public record BuildResult(Path file, byte[] archiveHash, Map chunks, long fileSize) {} + + public BuildResult build( + Path finalPath, + String creatorLogin, + long bigBlockNumber, + long createdAtMs, + List previousBigBlocks, + List chains, + Map> recordsByBlockchain, + byte[] signingPrivateSeed32, + long maxFileBytes + ) throws IOException { + Objects.requireNonNull(finalPath, "finalPath"); + Objects.requireNonNull(previousBigBlocks, "previousBigBlocks"); + Objects.requireNonNull(chains, "chains"); + Objects.requireNonNull(recordsByBlockchain, "recordsByBlockchain"); + requireU32(bigBlockNumber, "big_block_number"); + if (signingPrivateSeed32 == null || signingPrivateSeed32.length != 32) { + throw new IllegalArgumentException("Archive signing key должен быть 32-byte Ed25519 seed"); + } + + byte[] loginBytes = utf8U8(creatorLogin, "creator_login"); + if (previousBigBlocks.size() > 0xffff_ffffL || chains.size() > 0xffff_ffffL) { + throw new IllegalArgumentException("Слишком много references/chunks для v1"); + } + + List sortedChains = new ArrayList<>(chains); + sortedChains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName)); + + long totalRecords = 0; + for (ArchivePublishJobChain ch : sortedChains) { + List rs = recordsByBlockchain.get(ch.blockchainName()); + if (rs == null || rs.isEmpty()) { + throw new IllegalArgumentException("Нет frozen records для " + ch.blockchainName()); + } + totalRecords += rs.size(); + requireU32(totalRecords, "total_user_records_count"); + } + + Map referenceIndexByBlock = new HashMap<>(); + for (int i = 0; i < previousBigBlocks.size(); i++) { + ArchiveBigBlockRef ref = previousBigBlocks.get(i); + requireU32(ref.bigBlockNumber(), "PreviousBigBlockReference.big_block_number"); + require32(ref.archiveHash(), "PreviousBigBlockReference.big_block_hash"); + require32(ref.arweaveTxId(), "PreviousBigBlockReference.arweave_tx_id"); + referenceIndexByBlock.put(ref.bigBlockNumber(), i); + } + + Files.createDirectories(finalPath.toAbsolutePath().getParent()); + Path tmp = finalPath.resolveSibling(finalPath.getFileName() + ".tmp"); + Files.deleteIfExists(tmp); + + Map positions = new LinkedHashMap<>(); + MessageDigest digest = sha256(); + + try (OutputStream fileOut = new BufferedOutputStream(Files.newOutputStream(tmp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)); + CountingOutputStream counting = new CountingOutputStream(fileOut); + DigestOutputStream digestOut = new DigestOutputStream(counting, digest); + DataOutputStream out = new DataOutputStream(digestOut)) { + + // ---------- header ---------- + out.write(MAGIC); + out.writeByte(VERSION_MAJOR); + out.writeByte(VERSION_MINOR); + + long headerSize = fixedHeaderSize() + loginBytes.length; + writeU32(out, headerSize); + writeU32(out, bigBlockNumber); + out.writeLong(createdAtMs); + out.writeByte(loginBytes.length); + out.writeByte(REFERENCES_FULL); + writeU32(out, previousBigBlocks.size()); + out.writeShort(REFERENCE_ENTRY_SIZE); + long parentIndex = previousBigBlocks.isEmpty() ? NO_REFERENCE_U32 : previousBigBlocks.size() - 1L; + writeU32(out, parentIndex); + writeU32(out, sortedChains.size()); + writeU32(out, totalRecords); + out.write(loginBytes); + + // ---------- полный список предыдущих больших блоков ---------- + for (ArchiveBigBlockRef ref : previousBigBlocks) { + writeU32(out, ref.bigBlockNumber()); + out.write(ref.archiveHash()); + out.write(ref.arweaveTxId()); + } + + // ---------- по одному chunk на blockchain_name ---------- + for (ArchivePublishJobChain ch : sortedChains) { + List records = new ArrayList<>(recordsByBlockchain.get(ch.blockchainName())); + records.sort(Comparator.comparingInt(BlockEntry::getBlockNumber)); + validateFrozenRange(ch, records); + + byte[] name = utf8U8(ch.blockchainName(), "blockchain_name"); + long chunkSize = calculateChunkSize(name.length, records); + requireU32(chunkSize, "chunk_size"); + long chunkOffset = counting.getCount(); + requireU32(chunkOffset, "chunk_offset"); + if (chunkOffset + chunkSize > maxFileBytes) { + throw new ArchiveTooLargeException("Большой архив превысит archive.maxFileBytes: " + (chunkOffset + chunkSize)); + } + + writeU32(out, chunkSize); + out.writeByte(name.length); + out.write(name); + writeU32(out, records.size()); + for (BlockEntry record : records) { + byte[] raw = record.getBlockBytes(); + if (raw == null) throw new IllegalArgumentException("block_bytes=NULL: " + ch.blockchainName() + "#" + record.getBlockNumber()); + writeU32(out, raw.length); + out.write(raw); + } + + if (ch.previousArchiveBigBlockNumber() == null) { + writeU32(out, NO_REFERENCE_U32); + writeU32(out, 0); + writeU32(out, 0); + } else { + Integer refIndex = referenceIndexByBlock.get(ch.previousArchiveBigBlockNumber()); + if (refIndex == null) { + throw new IllegalStateException("Предыдущий big block blockchain отсутствует в FULL reference table: " + + ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber()); + } + ArchiveBigBlockRef previousRef = previousBigBlocks.get(refIndex); + require32(ch.previousArchiveBigBlockHash(), "previous_archive_big_block_hash " + ch.blockchainName()); + if (!Arrays.equals(previousRef.archiveHash(), ch.previousArchiveBigBlockHash())) { + throw new IllegalStateException("Hash предыдущего big block не совпадает с FULL reference table: " + + ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber()); + } + if (ch.previousChunkOffset() == null || ch.previousChunkSize() == null) { + throw new IllegalStateException("Нет предыдущих chunk offset/size: " + ch.blockchainName()); + } + writeU32(out, refIndex); + writeU32(out, ch.previousChunkOffset()); + writeU32(out, ch.previousChunkSize()); + } + positions.put(ch.blockchainName(), new ChunkPosition(chunkOffset, chunkSize)); + } + + // ---------- footer, login входит в hash ---------- + out.writeByte(loginBytes.length); + out.write(loginBytes); + out.flush(); + + byte[] archiveHash = digest.digest(); + byte[] signedPayload = new byte[SIGNATURE_DOMAIN.length + archiveHash.length]; + System.arraycopy(SIGNATURE_DOMAIN, 0, signedPayload, 0, SIGNATURE_DOMAIN.length); + System.arraycopy(archiveHash, 0, signedPayload, SIGNATURE_DOMAIN.length, archiveHash.length); + byte[] signature = Ed25519Util.sign(signedPayload, signingPrivateSeed32); + + // hash/signature не должны попасть в hash самого блока. + digestOut.on(false); + out.write(archiveHash); + out.write(signature); + out.flush(); + + long finalSize = counting.getCount(); + if (finalSize >= 0x1_0000_0000L || finalSize > maxFileBytes) { + throw new ArchiveTooLargeException("Размер SHINE-ARCHIVE v1 превышает лимит: " + finalSize); + } + } catch (Exception e) { + Files.deleteIfExists(tmp); + if (e instanceof IOException ioe) throw ioe; + throw new IOException("Не удалось собрать SHINE-ARCHIVE", e); + } + + // До Arweave upload существует уже готовый локальный файл с окончательным именем. + try { + Files.move(tmp, finalPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, finalPath, StandardCopyOption.REPLACE_EXISTING); + } + + byte[] hash = readStoredArchiveHash(finalPath); + long size = Files.size(finalPath); + return new BuildResult(finalPath, hash, Collections.unmodifiableMap(positions), size); + } + + /** Проверяет локальный файл перед оплатой Arweave. */ + public static byte[] verifyLocalFile(Path file) throws IOException { + long size = Files.size(file); + if (size < MAGIC.length + 2 + 96) throw new IOException("Слишком короткий SHINE-ARCHIVE: " + file); + byte[] stored = readStoredArchiveHash(file); + MessageDigest md = sha256(); + long hashInputSize = size - 96; // [archive_hash 32][signature 64] + try (InputStream in = Files.newInputStream(file)) { + byte[] buf = new byte[1024 * 1024]; + long left = hashInputSize; + while (left > 0) { + int n = in.read(buf, 0, (int)Math.min(buf.length, left)); + if (n < 0) throw new EOFException("Неожиданный EOF при проверке архива"); + md.update(buf, 0, n); + left -= n; + } + } + byte[] actual = md.digest(); + if (!Arrays.equals(stored, actual)) throw new IOException("SHA-256 локального SHINE-ARCHIVE не совпадает с footer"); + return actual; + } + + public static byte[] readStoredArchiveHash(Path file) throws IOException { + long size = Files.size(file); + if (size < 96) throw new EOFException("Файл слишком короткий"); + try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) { + raf.seek(size - 96); + byte[] hash = new byte[32]; + raf.readFully(hash); + return hash; + } + } + + private static long fixedHeaderSize() { + // magic + major/minor + headerSize + blockNo + time + creatorLen + mode + refCount + refSize + parentIndex + chunkCount + recordCount + return 13L + 1 + 1 + 4 + 4 + 8 + 1 + 1 + 4 + 2 + 4 + 4 + 4; + } + + private static long calculateChunkSize(int nameLen, List records) { + long size = 4L + 1L + nameLen + 4L + 12L; + for (BlockEntry e : records) { + byte[] raw = e.getBlockBytes(); + if (raw == null) throw new IllegalArgumentException("raw record is null"); + size += 4L + raw.length; + } + return size; + } + + private static void validateFrozenRange(ArchivePublishJobChain ch, List records) { + long expected = ch.toSourceBlockNumber() - ch.fromSourceBlockNumber() + 1; + if (expected <= 0 || expected != records.size()) { + throw new IllegalArgumentException("Frozen range неполон для " + ch.blockchainName() + + ": ожидалось=" + expected + ", получено=" + records.size()); + } + long n = ch.fromSourceBlockNumber(); + for (BlockEntry e : records) { + if (Integer.toUnsignedLong(e.getBlockNumber()) != n) { + throw new IllegalArgumentException("Нарушена непрерывность " + ch.blockchainName() + + ": ожидался block " + n + ", получен " + Integer.toUnsignedLong(e.getBlockNumber())); + } + n++; + } + } + + private static byte[] utf8U8(String value, String name) { + if (value == null) throw new IllegalArgumentException(name + " == null"); + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException(name + " должен занимать 1..255 UTF-8 байт"); + return bytes; + } + + private static void require32(byte[] v, String name) { + if (v == null || v.length != 32) throw new IllegalArgumentException(name + " должен быть 32 bytes"); + } + + private static void requireU32(long v, String name) { + if (v < 0 || v > 0xffff_ffffL) throw new IllegalArgumentException(name + " вне u32: " + v); + } + + private static void writeU32(DataOutputStream out, long v) throws IOException { + requireU32(v, "u32"); + out.writeInt((int)v); // DataOutputStream = big-endian; битовый шаблон корректен и для unsigned > 2^31. + } + + private static MessageDigest sha256() { + try { return MessageDigest.getInstance("SHA-256"); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + + /** OutputStream со счётчиком без ограничения int. */ + private static final class CountingOutputStream extends FilterOutputStream { + private long count; + CountingOutputStream(OutputStream out) { super(out); } + long getCount() { return count; } + @Override public void write(int b) throws IOException { out.write(b); count++; } + @Override public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); count += len; } + } + + public static final class ArchiveTooLargeException extends IOException { + public ArchiveTooLargeException(String message) { super(message); } + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java new file mode 100644 index 00000000..33ea9c65 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java @@ -0,0 +1,373 @@ +package server.archive; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sync.codec.ShineUsersCodec; +import sync.codec.ShineUsersCodec.UserPdaSnapshot; +import sync.util.Base58Util; +import sync.util.SolanaPdaUtil; +import utils.crypto.Ed25519Util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +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.security.MessageDigest; +import java.time.Duration; +import java.util.*; + +/** + * Обновляет ArchiveHeadBlock (type=100) обычной инструкцией update_user_pda. + * Отдельной Solana-инструкции для архива нет. + */ +public final class SolanaArchiveHeadWriter { + private static final Logger log = LoggerFactory.getLogger(SolanaArchiveHeadWriter.class); + private static final ObjectMapper JSON = new ObjectMapper(); + + private static final String SYSTEM_PROGRAM = "11111111111111111111111111111111"; + private static final String ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111"; + private static final String COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111"; + private static final String SYSVAR_INSTRUCTIONS = "Sysvar1nstructions1111111111111111111111111"; + private static final String USER_SEED_PREFIX = "user_login="; + private static final String ECONOMY_SEED = "shine_users_economy_config"; + private static final String INFLOW_SEED = "shine_payments_inflow_vault"; + private static final byte[] LAST_BLOCK_STATE_PREFIX = "SHiNE_LAST_BLOCK".getBytes(StandardCharsets.UTF_8); + + private final ArchivePublisherConfig cfg; + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); + private final byte[] rootSeed32; + private final byte[] clientSeed32; + + public SolanaArchiveHeadWriter(ArchivePublisherConfig cfg) throws IOException { + this.cfg = Objects.requireNonNull(cfg); + this.rootSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath()); + this.clientSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaClientKeyPath()); + } + + public record SubmitResult(String signature, String userPda) {} + + /** Preflight: проверяет, что локальные root/client private keys соответствуют текущему User PDA. */ + public void validateKeysAgainstPda() throws Exception { + Current current = loadCurrent(); + byte[] expectedRoot = Base58Util.decode(current.snapshot.rootKey()); + byte[] expectedClient = Base58Util.decode(current.snapshot.clientKey()); + if (!Arrays.equals(expectedRoot, Ed25519Util.derivePublicKey(rootSeed32))) { + throw new IllegalStateException("archive.solana.rootKeyPath не соответствует root_key User PDA " + cfg.serverLogin()); + } + if (!Arrays.equals(expectedClient, Ed25519Util.derivePublicKey(clientSeed32))) { + throw new IllegalStateException("archive.solana.clientKeyPath не соответствует client_key User PDA " + cfg.serverLogin()); + } + } + + public SubmitResult submitArchiveHead(byte[] arweaveTxId32, byte[] archiveHash32) throws Exception { + require32(arweaveTxId32, "Arweave TX ID"); + require32(archiveHash32, "archive hash"); + + Current current = loadCurrent(); + UserPdaSnapshot old = current.snapshot; + byte[] rootPub = Ed25519Util.derivePublicKey(rootSeed32); + byte[] clientPub = Ed25519Util.derivePublicKey(clientSeed32); + if (!Arrays.equals(rootPub, Base58Util.decode(old.rootKey()))) { + throw new IllegalStateException("Root key archive publisher-а не совпадает с User PDA"); + } + if (!Arrays.equals(clientPub, Base58Util.decode(old.clientKey()))) { + throw new IllegalStateException("Client key archive publisher-а не совпадает с User PDA"); + } + + byte[] currentRaw = Base64.getDecoder().decode(old.rawDataBase64()); + int currentRecordLen = u16le(currentRaw, 7); + byte[] currentUnsigned = Arrays.copyOf(currentRaw, currentRecordLen - 64); + byte[] prevHash = sha256(currentUnsigned); + + long updatedAt = System.currentTimeMillis(); + String txB64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(arweaveTxId32); + String archiveHashHex = hex(archiveHash32); + String zeroSig = Base58Util.encode(new byte[64]); + + UserPdaSnapshot nextUnsignedSnapshot = new UserPdaSnapshot( + old.pdaAddress(), old.login(), old.recordNumber() + 1, old.slot(), "", + old.recoveryKey(), old.rootKey(), old.clientKey(), old.blockchainName(), old.blockchainKey(), + old.paidLimitBytes(), old.usedBytes(), old.lastBlockNumber(), old.lastBlockHash(), + old.lastBlockSignature(), old.arweaveTxId(), txB64Url, archiveHashHex, + old.isServer(), old.addressFormatType(), old.addressFormatVersion(), old.serverAddress(), + old.syncServers(), old.accessServers(), old.sessionsMode(), old.sessions(), old.trustedCount(), + old.createdAtMs(), updatedAt, hex(prevHash), zeroSig, "" + ); + byte[] nextFull = Base64.getDecoder().decode(ShineUsersCodec.serializeSnapshotToBase64(nextUnsignedSnapshot)); + int nextRecordLen = u16le(nextFull, 7); + byte[] nextUnsigned = Arrays.copyOf(nextFull, nextRecordLen - 64); + byte[] nextUnsignedHash = sha256(nextUnsigned); + byte[] rootSignature = Ed25519Util.sign(nextUnsignedHash, rootSeed32); + + byte[] updateData = buildUpdateInstruction(old, updatedAt, prevHash, arweaveTxId32, archiveHash32, rootSignature); + byte[] lastStateHash = sha256(buildLastBlockState(old)); + byte[] lastBlockSignature = Base58Util.decode(old.lastBlockSignature()); + byte[] blockchainPub = Base58Util.decode(old.blockchainKey()); + + byte[] rootEdData = buildEd25519Instruction(rootSignature, rootPub, nextUnsignedHash); + byte[] bchEdData = buildEd25519Instruction(lastBlockSignature, blockchainPub, lastStateHash); + + String blockhash = getLatestBlockhash(); + byte[] message = buildLegacyMessage(current.userPda, updateData, rootEdData, bchEdData, blockhash, clientPub); + byte[] clientSignature = Ed25519Util.sign(message, clientSeed32); + byte[] transaction = serializeTransaction(clientSignature, message); + String signature = sendTransaction(transaction); + log.info("Archive head отправлен в Solana: login={} pda={} signature={}", cfg.serverLogin(), current.userPda, signature); + return new SubmitResult(signature, current.userPda); + } + + public void waitFinalized(String signature) throws Exception { + long deadline = System.currentTimeMillis() + cfg.solanaConfirmTimeoutMinutes() * 60_000L; + while (System.currentTimeMillis() < deadline) { + JsonNode result = rpc("getSignatureStatuses", List.of(List.of(signature), Map.of("searchTransactionHistory", true))).path("result"); + JsonNode values = result.path("value"); + JsonNode status = values.isArray() && values.size() > 0 ? values.get(0) : null; + if (status != null && !status.isNull()) { + if (status.hasNonNull("err")) { + throw new IOException("Solana archive-head tx завершилась ошибкой: " + status.path("err")); + } + String confirmation = status.path("confirmationStatus").asText(""); + if ("finalized".equals(confirmation)) return; + } + Thread.sleep(cfg.solanaConfirmPollSeconds() * 1000L); + } + throw new IOException("Timeout ожидания Solana finalized: " + signature); + } + + public boolean currentHeadMatches(byte[] txId32, byte[] hash32) throws Exception { + Current current = loadCurrent(); + if (current.snapshot.archiveHeadTxId() == null || current.snapshot.archiveHeadTxId().isBlank()) return false; + byte[] currentTx = Base64.getUrlDecoder().decode(current.snapshot.archiveHeadTxId()); + byte[] currentHash = unhex(current.snapshot.archiveHeadHash()); + return Arrays.equals(currentTx, txId32) && Arrays.equals(currentHash, hash32); + } + + private Current loadCurrent() throws Exception { + String normalized = cfg.serverLogin().trim().toLowerCase(Locale.ROOT); + String userPda = SolanaPdaUtil.findProgramAddress( + List.of(USER_SEED_PREFIX.getBytes(StandardCharsets.UTF_8), normalized.getBytes(StandardCharsets.UTF_8)), + cfg.solanaUsersProgramId()); + JsonNode accountResult = rpc("getAccountInfo", List.of(userPda, Map.of("encoding", "base64", "commitment", cfg.solanaCommitment()))).path("result"); + JsonNode value = accountResult.path("value"); + if (value.isMissingNode() || value.isNull()) throw new IOException("User PDA не найден для " + cfg.serverLogin()); + String owner = value.path("owner").asText(""); + if (!cfg.solanaUsersProgramId().equals(owner)) throw new IOException("User PDA принадлежит другой программе: " + owner); + JsonNode data = value.path("data"); + if (!data.isArray() || data.size() < 1) throw new IOException("Некорректный getAccountInfo.data"); + String rawB64 = data.get(0).asText(); + UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(userPda, 0L, rawB64, ""); + return new Current(userPda, snapshot); + } + + private byte[] buildUpdateInstruction(UserPdaSnapshot s, long updatedAt, byte[] prevHash, + byte[] archiveTx, byte[] archiveHash, byte[] rootSig) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(4); // IX_UPDATE_USER_PDA + strU8(out, s.login()); + out.write(Base58Util.decode(s.recoveryKey())); + out.write(Base58Util.decode(s.rootKey())); + u64le(out, s.createdAtMs()); + u64le(out, updatedAt); + u32le(out, Integer.toUnsignedLong(s.recordNumber() + 1)); + out.write(prevHash); + u64le(out, 0L); // additional_limit + out.write(Base58Util.decode(s.clientKey())); + out.write(Base58Util.decode(s.blockchainKey())); + strU8(out, s.blockchainName()); + u64le(out, s.usedBytes()); + u32le(out, Integer.toUnsignedLong(s.lastBlockNumber())); + out.write(unhex(s.lastBlockHash())); + out.write(Base58Util.decode(s.lastBlockSignature())); + strU8(out, s.arweaveTxId()); + out.write(s.isServer() ? 1 : 0); + if (s.isServer()) { + out.write(s.addressFormatType() & 0xff); + out.write(s.addressFormatVersion() & 0xff); + strU8(out, s.serverAddress()); + if (s.syncServers().size() > 255) throw new IOException("Слишком много sync_servers"); + out.write(s.syncServers().size()); + for (String v : s.syncServers()) strU8(out, v); + } + if (s.accessServers().size() > 255) throw new IOException("Слишком много access_servers"); + out.write(s.accessServers().size()); + for (String v : s.accessServers()) strU8(out, v); + out.write(s.sessionsMode() & 0xff); + if (s.sessions().size() > 255) throw new IOException("Слишком много sessions"); + out.write(s.sessions().size()); + for (ShineUsersCodec.UserSessionSnapshot session : s.sessions()) { + out.write(session.sessionType() & 0xff); + out.write(session.sessionVersion() & 0xff); + strU8(out, session.sessionName()); + out.write(Base58Util.decode(session.sessionPubKey())); + } + out.write(s.trustedCount() & 0xff); + // Archive extension к обычному update_user_pda: marker=1 + tx32 + hash32. + out.write(1); + out.write(archiveTx); + out.write(archiveHash); + out.write(rootSig); + return out.toByteArray(); + } + + private static byte[] buildLastBlockState(UserPdaSnapshot s) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(LAST_BLOCK_STATE_PREFIX); + strU8(out, s.login()); + strU8(out, s.blockchainName()); + u32le(out, Integer.toUnsignedLong(s.lastBlockNumber())); + out.write(unhex(s.lastBlockHash())); + u64le(out, s.usedBytes()); + return out.toByteArray(); + } + + private byte[] buildLegacyMessage(String userPda, byte[] updateData, byte[] rootEd, byte[] bchEd, + String recentBlockhash, byte[] clientPub) throws IOException { + String economy = SolanaPdaUtil.findProgramAddress(List.of(ECONOMY_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaUsersProgramId()); + String inflow = SolanaPdaUtil.findProgramAddress(List.of(INFLOW_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaPaymentsProgramId()); + + List accounts = List.of( + new Account(Base58Util.encode(clientPub), true, true), + new Account(userPda, false, true), + new Account(inflow, false, true), + new Account(SYSTEM_PROGRAM, false, false), + new Account(SYSVAR_INSTRUCTIONS, false, false), + new Account(economy, false, false), + new Account(COMPUTE_BUDGET_PROGRAM, false, false), + new Account(ED25519_PROGRAM, false, false), + new Account(cfg.solanaUsersProgramId(), false, false) + ); + Map ix = new HashMap<>(); + for (int i=0;i instructions = new ArrayList<>(); + instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeHeapData(262_144))); + instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeLimitData(800_000))); + instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], rootEd)); + instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], bchEd)); + instructions.add(new CompiledInstruction(ix.get(cfg.solanaUsersProgramId()), new int[]{ + ix.get(Base58Util.encode(clientPub)), ix.get(userPda), ix.get(SYSTEM_PROGRAM), ix.get(inflow), + ix.get(SYSVAR_INSTRUCTIONS), ix.get(economy) + }, updateData)); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(1); // numRequiredSignatures + out.write(0); // numReadonlySignedAccounts + out.write(6); // readonly unsigned: system, sysvar, economy, compute, ed25519, users + shortVec(out, accounts.size()); + for (Account a : accounts) { + byte[] pk = Base58Util.decode(a.pubkey); + if (pk.length != 32) throw new IOException("Solana pubkey !=32: " + a.pubkey); + out.write(pk); + } + byte[] bh = Base58Util.decode(recentBlockhash); + if (bh.length != 32) throw new IOException("recentBlockhash !=32"); + out.write(bh); + shortVec(out, instructions.size()); + for (CompiledInstruction ci : instructions) { + out.write(ci.programIdIndex); + shortVec(out, ci.accounts.length); + for (int a : ci.accounts) out.write(a); + shortVec(out, ci.data.length); + out.write(ci.data); + } + return out.toByteArray(); + } + + private String getLatestBlockhash() throws Exception { + JsonNode result = rpc("getLatestBlockhash", List.of(Map.of("commitment", cfg.solanaCommitment()))).path("result"); + String blockhash = result.path("value").path("blockhash").asText(""); + if (blockhash.isBlank()) throw new IOException("Solana getLatestBlockhash не вернул blockhash"); + return blockhash; + } + + private String sendTransaction(byte[] tx) throws Exception { + String txB64 = Base64.getEncoder().encodeToString(tx); + JsonNode result = rpc("sendTransaction", List.of(txB64, Map.of( + "encoding", "base64", + "skipPreflight", false, + "preflightCommitment", "confirmed", + "maxRetries", 5 + ))).path("result"); + String sig = result.asText(""); + if (sig.isBlank()) throw new IOException("Solana sendTransaction не вернул signature"); + return sig; + } + + private JsonNode rpc(String method, List params) throws Exception { + Map body = new LinkedHashMap<>(); + body.put("jsonrpc", "2.0"); body.put("id", 1); body.put("method", method); body.put("params", params); + HttpRequest req = HttpRequest.newBuilder(URI.create(cfg.solanaRpcUrl())) + .timeout(Duration.ofSeconds(60)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body), StandardCharsets.UTF_8)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Solana RPC HTTP " + resp.statusCode() + ": " + safe(resp.body())); + JsonNode root = JSON.readTree(resp.body()); + if (root.hasNonNull("error")) throw new IOException("Solana RPC " + method + " error: " + root.path("error")); + return root; + } + + private static byte[] serializeTransaction(byte[] signature64, byte[] message) throws IOException { + if (signature64.length != 64) throw new IOException("Solana signature !=64"); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + shortVec(out, 1); + out.write(signature64); + out.write(message); + return out.toByteArray(); + } + + private static byte[] buildEd25519Instruction(byte[] signature64, byte[] publicKey32, byte[] message) { + if (signature64.length != 64 || publicKey32.length != 32) throw new IllegalArgumentException("Некорректный Ed25519 ix key/signature"); + int sigOff=16, pkOff=sigOff+64, msgOff=pkOff+32; + if (message.length > 0xffff) throw new IllegalArgumentException("Ed25519 message слишком длинный"); + byte[] data = new byte[msgOff + message.length]; + data[0]=1; data[1]=0; + putU16le(data,2,sigOff); putU16le(data,4,0xffff); + putU16le(data,6,pkOff); putU16le(data,8,0xffff); + putU16le(data,10,msgOff); putU16le(data,12,message.length); putU16le(data,14,0xffff); + System.arraycopy(signature64,0,data,sigOff,64); + System.arraycopy(publicKey32,0,data,pkOff,32); + System.arraycopy(message,0,data,msgOff,message.length); + return data; + } + + private static byte[] computeHeapData(int bytes) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(1); u32le(out, Integer.toUnsignedLong(bytes)); return out.toByteArray(); + } + private static byte[] computeLimitData(int units) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(2); u32le(out, Integer.toUnsignedLong(units)); return out.toByteArray(); + } + + private static void shortVec(ByteArrayOutputStream out, int value) { + int rem=value; + while (true) { + int elem=rem & 0x7f; rem >>>=7; + if (rem==0) { out.write(elem); return; } + out.write(elem | 0x80); + } + } + private static void strU8(ByteArrayOutputStream out, String value) throws IOException { + byte[] b=String.valueOf(value==null?"":value).getBytes(StandardCharsets.UTF_8); + if (b.length>255) throw new IOException("Строка >255 bytes"); out.write(b.length); out.write(b); + } + private static void u32le(ByteArrayOutputStream out, long v) { for(int i=0;i<4;i++) out.write((int)(v >>> (8*i)) & 0xff); } + private static void u64le(ByteArrayOutputStream out, long v) { for(int i=0;i<8;i++) out.write((int)(v >>> (8*i)) & 0xff); } + private static void putU16le(byte[] d,int o,int v){d[o]=(byte)v;d[o+1]=(byte)(v>>>8);} + private static int u16le(byte[] b,int o){return (b[o]&255)|((b[o+1]&255)<<8);} + private static byte[] sha256(byte[] bytes) throws Exception { return MessageDigest.getInstance("SHA-256").digest(bytes); } + private static String hex(byte[] b){StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();} + private static byte[] unhex(String s){String x=String.valueOf(s==null?"":s).trim(); if(x.length()!=64) throw new IllegalArgumentException("hex32 expected");byte[]o=new byte[32];for(int i=0;i<32;i++)o[i]=(byte)Integer.parseInt(x.substring(i*2,i*2+2),16);return o;} + private static void require32(byte[] b,String n){if(b==null||b.length!=32)throw new IllegalArgumentException(n+" должен быть 32 bytes");} + private static String safe(String s){String x=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return x.length()<=500?x:x.substring(0,500)+"...";} + + private record Current(String userPda, UserPdaSnapshot snapshot) {} + private record Account(String pubkey, boolean signer, boolean writable) {} + private record CompiledInstruction(int programIdIndex, int[] accounts, byte[] data) {} +} diff --git a/SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java b/SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java new file mode 100644 index 00000000..5f394f39 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java @@ -0,0 +1,34 @@ +package server.archive; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ArchiveFileNamesTest { + private static final long CREATED_AT = Instant.parse("2026-09-11T00:00:00Z").toEpochMilli(); + private static final ZoneId UTC = ZoneId.of("UTC"); + + @Test + void usesTemporaryAndFinalArweaveNames() { + byte[] tx = new byte[32]; + for (int i = 0; i < tx.length; i++) tx[i] = (byte) i; + String txText = Base64.getUrlEncoder().withoutPadding().encodeToString(tx); + + assertEquals("archive01.00001.11.09.26.tmp.SHiNE-archive", + ArchiveFileNames.pendingFileName("archive01", 1, CREATED_AT, UTC)); + assertEquals("archive01.00001.11.09.26." + txText + ".SHiNE-archive", + ArchiveFileNames.uploadedFileName("archive01", 1, CREATED_AT, UTC, tx)); + assertEquals("archive01.100000.11.09.26.tmp.SHiNE-archive", + ArchiveFileNames.pendingFileName("archive01", 100000, CREATED_AT, UTC)); + } + + @Test + void sanitizesLoginForFileSystemOnly() { + assertEquals("archive_user.00002.11.09.26.tmp.SHiNE-archive", + ArchiveFileNames.pendingFileName("archive/user", 2, CREATED_AT, UTC)); + } +} diff --git a/SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java b/SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java new file mode 100644 index 00000000..7fdcd2de --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java @@ -0,0 +1,83 @@ +package server.archive; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import shine.db.archive.ArchiveBigBlockRef; +import shine.db.archive.ArchivePublishJobChain; +import shine.db.entities.BlockEntry; + +import java.io.DataInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ShineArchiveWriterTest { + @TempDir Path dir; + + @Test + void writesOneChunkWithAllRecordsAndOneBacklink() throws Exception { + Path file = ArchiveFileNames.pendingFilePath(dir, "serverA", 2, 123456789L, ZoneId.of("UTC")); + ArchiveBigBlockRef previous = new ArchiveBigBlockRef(1, new byte[32], new byte[32]); + ArchivePublishJobChain chain = new ArchivePublishJobChain( + 1, "alice-001", 10, 11, new byte[32], new byte[32], + 1L, new byte[32], 100L, 55L, null, null); + + ShineArchiveWriter.BuildResult result = new ShineArchiveWriter().build( + file, "serverA", 2, 123456789L, + List.of(previous), List.of(chain), + Map.of("alice-001", List.of(block(10, new byte[]{1,2,3}), block(11, new byte[]{4,5}))), + new byte[32], 4_000_000_000L); + + assertArrayEquals(result.archiveHash(), ShineArchiveWriter.verifyLocalFile(file)); + assertEquals("serverA.00002.02.01.70.tmp.SHiNE-archive", file.getFileName().toString()); + + try (InputStream raw = java.nio.file.Files.newInputStream(file); DataInputStream in = new DataInputStream(raw)) { + assertEquals("SHINE-ARCHIVE", new String(in.readNBytes(13), StandardCharsets.US_ASCII)); + assertEquals(1, in.readUnsignedByte()); + assertEquals(0, in.readUnsignedByte()); + long headerSize = Integer.toUnsignedLong(in.readInt()); + assertEquals(2, Integer.toUnsignedLong(in.readInt())); + assertEquals(123456789L, in.readLong()); + int creatorLen = in.readUnsignedByte(); + assertEquals(ShineArchiveWriter.REFERENCES_FULL, in.readUnsignedByte()); + assertEquals(1, Integer.toUnsignedLong(in.readInt())); + assertEquals(68, in.readUnsignedShort()); + assertEquals(0, Integer.toUnsignedLong(in.readInt())); + assertEquals(1, Integer.toUnsignedLong(in.readInt())); + assertEquals(2, Integer.toUnsignedLong(in.readInt())); + assertEquals("serverA", new String(in.readNBytes(creatorLen), StandardCharsets.UTF_8)); + assertEquals(headerSize, 51L + creatorLen); + + assertEquals(1, Integer.toUnsignedLong(in.readInt())); + in.skipNBytes(64); // previous hash + txid + + long chunkSize = Integer.toUnsignedLong(in.readInt()); + int nameLen = in.readUnsignedByte(); + assertEquals("alice-001", new String(in.readNBytes(nameLen), StandardCharsets.UTF_8)); + assertEquals(2, Integer.toUnsignedLong(in.readInt())); + assertEquals(3, Integer.toUnsignedLong(in.readInt())); + assertArrayEquals(new byte[]{1,2,3}, in.readNBytes(3)); + assertEquals(2, Integer.toUnsignedLong(in.readInt())); + assertArrayEquals(new byte[]{4,5}, in.readNBytes(2)); + assertEquals(0, Integer.toUnsignedLong(in.readInt())); // previous ref index + assertEquals(100, Integer.toUnsignedLong(in.readInt())); + assertEquals(55, Integer.toUnsignedLong(in.readInt())); + assertEquals(4L + 1 + nameLen + 4 + (4+3) + (4+2) + 12, chunkSize); + } + } + + private static BlockEntry block(int number, byte[] bytes) { + BlockEntry e = new BlockEntry(); + e.setBlockNumber(number); + e.setBlockBytes(bytes); + e.setBlockHash(new byte[32]); + e.setBlockSignature(new byte[64]); + return e; + } +} diff --git a/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java index 027779b0..14a71683 100644 --- a/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java +++ b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java @@ -66,19 +66,20 @@ public final class AppConfig { /** Вернёт строку или пустую строку, если параметр не найден. */ public String getStringOrEmpty(String name) { - String value = properties.getProperty(name); + String value = getParam(name); return value == null ? "" : value.trim(); } - /** Можно добавить методы для удобства */ + /** Целочисленный параметр с тем же приоритетом: system property -> env -> properties. */ public int getInt(String name, int defaultValue) { - String v = properties.getProperty(name); - return v == null ? defaultValue : Integer.parseInt(v); + String v = getParam(name); + return v == null || v.isBlank() ? defaultValue : Integer.parseInt(v.trim()); } + /** Boolean-параметр с тем же приоритетом: system property -> env -> properties. */ public boolean getBoolean(String name, boolean defaultValue) { - String v = properties.getProperty(name); - return v == null ? defaultValue : Boolean.parseBoolean(v); + String v = getParam(name); + return v == null || v.isBlank() ? defaultValue : Boolean.parseBoolean(v.trim()); } private static String toEnvName(String name) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 021e9c8b..52a19ca8 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -38,6 +38,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_19 = 19; public static final int SCHEMA_VERSION_20 = 20; public static final int SCHEMA_VERSION_21 = 21; + public static final int SCHEMA_VERSION_22 = 22; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -59,6 +60,7 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql"; public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql"; public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql"; + public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql"; private DatabaseInitializer() {} @@ -212,6 +214,10 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE); currentVersion = SCHEMA_VERSION_21; } + if (currentVersion < SCHEMA_VERSION_22) { + runSqlScript(conn, POSTGRES_MIGRATION_V22_RESOURCE); + currentVersion = SCHEMA_VERSION_22; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java new file mode 100644 index 00000000..027d6b03 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java @@ -0,0 +1,8 @@ +package shine.db.archive; + +/** Ссылка на уже успешно опубликованный большой архивный блок. */ +public record ArchiveBigBlockRef( + long bigBlockNumber, + byte[] archiveHash, + byte[] arweaveTxId +) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java new file mode 100644 index 00000000..db9cd0ea --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java @@ -0,0 +1,15 @@ +package shine.db.archive; + +/** + * Последнее окончательно опубликованное архивное положение одной SHiNE blockchain. + */ +public record ArchiveChainCursor( + String blockchainName, + long lastArchivedSourceBlockNumber, + byte[] lastArchivedSourceBlockHash, + long lastArchiveBigBlockNumber, + byte[] lastArchiveBigBlockHash, + long lastChunkOffset, + long lastChunkSize, + long updatedAtMs +) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java new file mode 100644 index 00000000..9fff0a20 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java @@ -0,0 +1,16 @@ +package shine.db.archive; + +/** Crash-safe состояние публикации одного большого SHINE-ARCHIVE блока. */ +public record ArchivePublishJob( + long id, + long bigBlockNumber, + String status, + long createdAtMs, + String localArchivePath, + byte[] archiveHash, + byte[] arweaveTxId, + Integer arweaveConfirmations, + String solanaSignature, + String errorText, + long updatedAtMs +) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java new file mode 100644 index 00000000..36a665a8 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java @@ -0,0 +1,17 @@ +package shine.db.archive; + +/** Замороженный диапазон одной blockchain внутри конкретной архивной публикации. */ +public record ArchivePublishJobChain( + long jobId, + String blockchainName, + long fromSourceBlockNumber, + long toSourceBlockNumber, + byte[] previousSourceBlockHash, + byte[] lastSourceBlockHash, + Long previousArchiveBigBlockNumber, + byte[] previousArchiveBigBlockHash, + Long previousChunkOffset, + Long previousChunkSize, + Long newChunkOffset, + Long newChunkSize +) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java new file mode 100644 index 00000000..0d241896 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java @@ -0,0 +1,472 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.archive.ArchiveBigBlockRef; +import shine.db.archive.ArchiveChainCursor; +import shine.db.archive.ArchivePublishJob; +import shine.db.archive.ArchivePublishJobChain; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +/** + * DAO архивного publisher-а. Все курсоры считаются подтверждёнными только после Solana finalized. + */ +public final class ArchivePublicationDAO { + private static volatile ArchivePublicationDAO instance; + private final DbController db = DbController.getInstance(); + + private ArchivePublicationDAO() {} + + public static ArchivePublicationDAO getInstance() { + if (instance == null) { + synchronized (ArchivePublicationDAO.class) { + if (instance == null) instance = new ArchivePublicationDAO(); + } + } + return instance; + } + + public ArchiveChainCursor getCursor(String blockchainName) throws SQLException { + try (Connection c = db.getConnection()) { + return getCursor(c, blockchainName); + } + } + + public ArchiveChainCursor getCursor(Connection c, String blockchainName) throws SQLException { + String sql = """ + SELECT blockchain_name, last_archived_source_block_number, last_archived_source_block_hash, + last_archive_big_block_number, last_archive_big_block_hash, + last_chunk_offset, last_chunk_size, updated_at_ms + FROM archive_chain_cursor + WHERE blockchain_name = ? + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, blockchainName); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapCursor(rs); + } + } + } + + public long nextBigBlockNumber() throws SQLException { + String sql = """ + SELECT COALESCE(MAX(big_block_number), 0) + 1 + FROM archive_publish_job + WHERE status = 'CURSORS_COMMITTED' + """; + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getLong(1); + } + } + + public List listFinalizedBigBlocks() throws SQLException { + String sql = """ + SELECT big_block_number, archive_hash, arweave_tx_id + FROM archive_publish_job + WHERE status = 'CURSORS_COMMITTED' + AND archive_hash IS NOT NULL + AND arweave_tx_id IS NOT NULL + ORDER BY big_block_number ASC + """; + List out = new ArrayList<>(); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + out.add(new ArchiveBigBlockRef( + rs.getLong("big_block_number"), + rs.getBytes("archive_hash"), + rs.getBytes("arweave_tx_id") + )); + } + } + return out; + } + + public ArchivePublishJob findUnfinishedJob() throws SQLException { + String sql = """ + SELECT id, big_block_number, status, created_at_ms, local_archive_path, + archive_hash, arweave_tx_id, arweave_confirmations, solana_signature, + error_text, updated_at_ms + FROM archive_publish_job + WHERE status NOT IN ('CURSORS_COMMITTED', 'FAILED') + ORDER BY id ASC + LIMIT 1 + """; + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + return rs.next() ? mapJob(rs) : null; + } + } + + public ArchivePublishJob getJob(long id) throws SQLException { + String sql = """ + SELECT id, big_block_number, status, created_at_ms, local_archive_path, + archive_hash, arweave_tx_id, arweave_confirmations, solana_signature, + error_text, updated_at_ms + FROM archive_publish_job + WHERE id = ? + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, id); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? mapJob(rs) : null; + } + } + } + + + /** Атомарно создаёт frozen archive job и все его blockchain ranges. */ + public long createJobWithChains(long bigBlockNumber, long createdAtMs, List chains) throws SQLException { + if (chains == null || chains.isEmpty()) throw new IllegalArgumentException("Archive job без chains не создаётся"); + try (Connection c = db.getConnection()) { + boolean oldAuto = c.getAutoCommit(); + c.setAutoCommit(false); + try { + long jobId; + try (PreparedStatement ps = c.prepareStatement(""" + INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms) + VALUES(?, 'SNAPSHOT_CREATED', ?, ?) + RETURNING id + """)) { + ps.setLong(1, bigBlockNumber); + ps.setLong(2, createdAtMs); + ps.setLong(3, createdAtMs); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job"); + jobId = rs.getLong(1); + } + } + String sql = """ + INSERT INTO archive_publish_job_chain( + job_id, blockchain_name, from_source_block_number, to_source_block_number, + previous_source_block_hash, last_source_block_hash, + previous_archive_big_block_number, previous_archive_big_block_hash, + previous_chunk_offset, previous_chunk_size, new_chunk_offset, new_chunk_size + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + for (ArchivePublishJobChain ch : chains) { + bindJobChain(ps, new ArchivePublishJobChain( + jobId, ch.blockchainName(), ch.fromSourceBlockNumber(), ch.toSourceBlockNumber(), + ch.previousSourceBlockHash(), ch.lastSourceBlockHash(), + ch.previousArchiveBigBlockNumber(), ch.previousArchiveBigBlockHash(), + ch.previousChunkOffset(), ch.previousChunkSize(), null, null)); + ps.addBatch(); + } + ps.executeBatch(); + } + c.commit(); + return jobId; + } catch (Exception e) { + c.rollback(); + if (e instanceof SQLException se) throw se; + throw new SQLException("Не удалось создать frozen archive job", e); + } finally { + c.setAutoCommit(oldAuto); + } + } + } + + public long createJob(long bigBlockNumber, long createdAtMs) throws SQLException { + String sql = """ + INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms) + VALUES(?, 'SNAPSHOT_CREATED', ?, ?) + RETURNING id + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, bigBlockNumber); + ps.setLong(2, createdAtMs); + ps.setLong(3, createdAtMs); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job"); + return rs.getLong(1); + } + } + } + + public void insertJobChain(ArchivePublishJobChain e) throws SQLException { + String sql = """ + INSERT INTO archive_publish_job_chain( + job_id, blockchain_name, from_source_block_number, to_source_block_number, + previous_source_block_hash, last_source_block_hash, + previous_archive_big_block_number, previous_archive_big_block_hash, + previous_chunk_offset, previous_chunk_size, + new_chunk_offset, new_chunk_size + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + bindJobChain(ps, e); + ps.executeUpdate(); + } + } + + public List listJobChains(long jobId) throws SQLException { + String sql = """ + SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number, + previous_source_block_hash, last_source_block_hash, + previous_archive_big_block_number, previous_archive_big_block_hash, + previous_chunk_offset, previous_chunk_size, + new_chunk_offset, new_chunk_size + FROM archive_publish_job_chain + WHERE job_id = ? + ORDER BY blockchain_name ASC + """; + List out = new ArrayList<>(); + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, jobId); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(mapJobChain(rs)); + } + } + return out; + } + + public void updateNewChunkPosition(long jobId, String blockchainName, long offset, long size) throws SQLException { + String sql = """ + UPDATE archive_publish_job_chain + SET new_chunk_offset=?, new_chunk_size=? + WHERE job_id=? AND blockchain_name=? + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, offset); + ps.setLong(2, size); + ps.setLong(3, jobId); + ps.setString(4, blockchainName); + if (ps.executeUpdate() != 1) throw new SQLException("Не найдена archive_publish_job_chain для " + blockchainName); + } + } + + public void markFileBuilt(long jobId, String localPath, byte[] hash) throws SQLException { + updateJob(jobId, "FILE_BUILT", "local_archive_path=? , archive_hash=?", ps -> { + ps.setString(1, localPath); + ps.setBytes(2, hash); + }); + } + + public void markArweaveUploaded(long jobId, byte[] txId) throws SQLException { + updateJob(jobId, "ARWEAVE_UPLOADED", "arweave_tx_id=?", ps -> ps.setBytes(1, txId)); + } + + /** Обновляет только локальное имя/путь уже собранного archive-файла. */ + public void updateLocalArchivePath(long jobId, String localPath) throws SQLException { + updateJob(jobId, null, "local_archive_path=?", ps -> ps.setString(1, localPath)); + } + + public void markArweaveConfirmed(long jobId, int confirmations) throws SQLException { + updateJob(jobId, "ARWEAVE_CONFIRMED", "arweave_confirmations=?", ps -> ps.setInt(1, confirmations)); + } + + public void markSolanaSubmitted(long jobId, String signature) throws SQLException { + updateJob(jobId, "SOLANA_SUBMITTED", "solana_signature=?", ps -> ps.setString(1, signature)); + } + + public void markSolanaFinalized(long jobId) throws SQLException { + updateJob(jobId, "SOLANA_FINALIZED", null, null); + } + + public void markFailed(long jobId, String error) throws SQLException { + updateJob(jobId, "FAILED", "error_text=?", ps -> ps.setString(1, error == null ? "" : error)); + } + + /** + * После Solana finalized атомарно переводит курсоры всех blockchain на новый chunk и закрывает job. + */ + public void commitCursors(long jobId, byte[] bigBlockHash) throws SQLException { + try (Connection c = db.getConnection()) { + boolean oldAuto = c.getAutoCommit(); + c.setAutoCommit(false); + try { + ArchivePublishJob job = getJob(c, jobId); + if (job == null) throw new SQLException("Archive job не найден: " + jobId); + List chains = listJobChains(c, jobId); + long now = System.currentTimeMillis(); + String upsert = """ + INSERT INTO archive_chain_cursor( + blockchain_name, last_archived_source_block_number, last_archived_source_block_hash, + last_archive_big_block_number, last_archive_big_block_hash, + last_chunk_offset, last_chunk_size, updated_at_ms + ) VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(blockchain_name) DO UPDATE SET + last_archived_source_block_number=EXCLUDED.last_archived_source_block_number, + last_archived_source_block_hash=EXCLUDED.last_archived_source_block_hash, + last_archive_big_block_number=EXCLUDED.last_archive_big_block_number, + last_archive_big_block_hash=EXCLUDED.last_archive_big_block_hash, + last_chunk_offset=EXCLUDED.last_chunk_offset, + last_chunk_size=EXCLUDED.last_chunk_size, + updated_at_ms=EXCLUDED.updated_at_ms + """; + try (PreparedStatement ps = c.prepareStatement(upsert)) { + for (ArchivePublishJobChain ch : chains) { + if (ch.newChunkOffset() == null || ch.newChunkSize() == null) { + throw new SQLException("Не сохранены координаты нового chunk: " + ch.blockchainName()); + } + ps.setString(1, ch.blockchainName()); + ps.setLong(2, ch.toSourceBlockNumber()); + ps.setBytes(3, ch.lastSourceBlockHash()); + ps.setLong(4, job.bigBlockNumber()); + ps.setBytes(5, bigBlockHash); + ps.setLong(6, ch.newChunkOffset()); + ps.setLong(7, ch.newChunkSize()); + ps.setLong(8, now); + ps.addBatch(); + } + ps.executeBatch(); + } + try (PreparedStatement ps = c.prepareStatement( + "UPDATE archive_publish_job SET status='CURSORS_COMMITTED', updated_at_ms=? WHERE id=?")) { + ps.setLong(1, now); + ps.setLong(2, jobId); + ps.executeUpdate(); + } + c.commit(); + } catch (Exception e) { + c.rollback(); + if (e instanceof SQLException se) throw se; + throw new SQLException("Не удалось commit archive cursors", e); + } finally { + c.setAutoCommit(oldAuto); + } + } + } + + private ArchivePublishJob getJob(Connection c, long id) throws SQLException { + String sql = """ + SELECT id, big_block_number, status, created_at_ms, local_archive_path, + archive_hash, arweave_tx_id, arweave_confirmations, solana_signature, + error_text, updated_at_ms + FROM archive_publish_job WHERE id=? + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, id); + try (ResultSet rs = ps.executeQuery()) { return rs.next() ? mapJob(rs) : null; } + } + } + + private List listJobChains(Connection c, long jobId) throws SQLException { + String sql = """ + SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number, + previous_source_block_hash, last_source_block_hash, + previous_archive_big_block_number, previous_archive_big_block_hash, + previous_chunk_offset, previous_chunk_size, + new_chunk_offset, new_chunk_size + FROM archive_publish_job_chain + WHERE job_id=? ORDER BY blockchain_name ASC + """; + List out = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, jobId); + try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapJobChain(rs)); } + } + return out; + } + + @FunctionalInterface + private interface Binder { void bind(PreparedStatement ps) throws SQLException; } + + private void updateJob(long jobId, String status, String setFragment, Binder binder) throws SQLException { + if (setFragment != null && !setFragment.isBlank()) { + try (Connection c = db.getConnection()) { + updateJobWithExtra(c, jobId, status, setFragment, binder); + } + return; + } + String sql = status == null + ? "UPDATE archive_publish_job SET updated_at_ms=? WHERE id=?" + : "UPDATE archive_publish_job SET status=?, updated_at_ms=? WHERE id=?"; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + if (status != null) ps.setString(i++, status); + ps.setLong(i++, System.currentTimeMillis()); + ps.setLong(i, jobId); + ps.executeUpdate(); + } + } + + private void updateJobWithExtra(Connection c, long jobId, String status, String setFragment, Binder binder) throws SQLException { + String statusSql = status == null ? "" : ", status=?"; + String sql = "UPDATE archive_publish_job SET " + setFragment + statusSql + ", updated_at_ms=? WHERE id=?"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + binder.bind(ps); + int count = countParameters(setFragment); + int i = count + 1; + if (status != null) ps.setString(i++, status); + ps.setLong(i++, System.currentTimeMillis()); + ps.setLong(i, jobId); + ps.executeUpdate(); + } + } + + private static int countParameters(String sqlPart) { + int n = 0; + for (int i = 0; i < sqlPart.length(); i++) if (sqlPart.charAt(i) == '?') n++; + return n; + } + + private static void bindJobChain(PreparedStatement ps, ArchivePublishJobChain e) throws SQLException { + int i=1; + ps.setLong(i++, e.jobId()); + ps.setString(i++, e.blockchainName()); + ps.setLong(i++, e.fromSourceBlockNumber()); + ps.setLong(i++, e.toSourceBlockNumber()); + ps.setBytes(i++, e.previousSourceBlockHash()); + ps.setBytes(i++, e.lastSourceBlockHash()); + setLongNullable(ps, i++, e.previousArchiveBigBlockNumber()); + ps.setBytes(i++, e.previousArchiveBigBlockHash()); + setLongNullable(ps, i++, e.previousChunkOffset()); + setLongNullable(ps, i++, e.previousChunkSize()); + setLongNullable(ps, i++, e.newChunkOffset()); + setLongNullable(ps, i, e.newChunkSize()); + } + + private static ArchiveChainCursor mapCursor(ResultSet rs) throws SQLException { + return new ArchiveChainCursor( + rs.getString("blockchain_name"), + rs.getLong("last_archived_source_block_number"), + rs.getBytes("last_archived_source_block_hash"), + rs.getLong("last_archive_big_block_number"), + rs.getBytes("last_archive_big_block_hash"), + rs.getLong("last_chunk_offset"), + rs.getLong("last_chunk_size"), + rs.getLong("updated_at_ms") + ); + } + + private static ArchivePublishJob mapJob(ResultSet rs) throws SQLException { + int conf = rs.getInt("arweave_confirmations"); + Integer confObj = rs.wasNull() ? null : conf; + return new ArchivePublishJob( + rs.getLong("id"), rs.getLong("big_block_number"), rs.getString("status"), + rs.getLong("created_at_ms"), rs.getString("local_archive_path"), + rs.getBytes("archive_hash"), rs.getBytes("arweave_tx_id"), confObj, + rs.getString("solana_signature"), rs.getString("error_text"), rs.getLong("updated_at_ms") + ); + } + + private static ArchivePublishJobChain mapJobChain(ResultSet rs) throws SQLException { + return new ArchivePublishJobChain( + rs.getLong("job_id"), rs.getString("blockchain_name"), + rs.getLong("from_source_block_number"), rs.getLong("to_source_block_number"), + rs.getBytes("previous_source_block_hash"), rs.getBytes("last_source_block_hash"), + getLongNullable(rs, "previous_archive_big_block_number"), rs.getBytes("previous_archive_big_block_hash"), + getLongNullable(rs, "previous_chunk_offset"), getLongNullable(rs, "previous_chunk_size"), + getLongNullable(rs, "new_chunk_offset"), getLongNullable(rs, "new_chunk_size") + ); + } + + private static Long getLongNullable(ResultSet rs, String name) throws SQLException { + long v = rs.getLong(name); + return rs.wasNull() ? null : v; + } + + private static void setLongNullable(PreparedStatement ps, int idx, Long v) throws SQLException { + if (v == null) ps.setNull(idx, Types.BIGINT); else ps.setLong(idx, v); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql new file mode 100644 index 00000000..ae030254 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS archive_chain_cursor ( + blockchain_name TEXT PRIMARY KEY, + last_archived_source_block_number BIGINT NOT NULL, + last_archived_source_block_hash BYTEA NOT NULL, + last_archive_big_block_number BIGINT NOT NULL, + last_archive_big_block_hash BYTEA NOT NULL, + last_chunk_offset BIGINT NOT NULL, + last_chunk_size BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS archive_publish_job ( + id BIGSERIAL PRIMARY KEY, + big_block_number BIGINT NOT NULL, + status TEXT NOT NULL, + created_at_ms BIGINT NOT NULL, + local_archive_path TEXT, + archive_hash BYTEA, + arweave_tx_id BYTEA, + arweave_confirmations INTEGER, + solana_signature TEXT, + error_text TEXT, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status + ON archive_publish_job(status); +CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number + ON archive_publish_job(big_block_number); + +CREATE TABLE IF NOT EXISTS archive_publish_job_chain ( + job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE, + blockchain_name TEXT NOT NULL, + from_source_block_number BIGINT NOT NULL, + to_source_block_number BIGINT NOT NULL, + previous_source_block_hash BYTEA NOT NULL, + last_source_block_hash BYTEA NOT NULL, + previous_archive_big_block_number BIGINT, + previous_archive_big_block_hash BYTEA, + previous_chunk_offset BIGINT, + previous_chunk_size BIGINT, + new_chunk_offset BIGINT, + new_chunk_size BIGINT, + PRIMARY KEY (job_id, blockchain_name) +); + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; + +UPDATE db_schema_version SET schema_version = 22, updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id = 1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 52081438..337a4d71 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -2028,8 +2028,63 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state ( PRIMARY KEY (owner_login, category) ); +CREATE TABLE IF NOT EXISTS archive_chain_cursor ( + blockchain_name TEXT PRIMARY KEY, + last_archived_source_block_number BIGINT NOT NULL, + last_archived_source_block_hash BYTEA NOT NULL, + last_archive_big_block_number BIGINT NOT NULL, + last_archive_big_block_hash BYTEA NOT NULL, + last_chunk_offset BIGINT NOT NULL, + last_chunk_size BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS archive_publish_job ( + id BIGSERIAL PRIMARY KEY, + big_block_number BIGINT NOT NULL, + status TEXT NOT NULL, + created_at_ms BIGINT NOT NULL, + local_archive_path TEXT, + archive_hash BYTEA, + arweave_tx_id BYTEA, + arweave_confirmations INTEGER, + solana_signature TEXT, + error_text TEXT, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status + ON archive_publish_job(status); +CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number + ON archive_publish_job(big_block_number); + +CREATE TABLE IF NOT EXISTS archive_publish_job_chain ( + job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE, + blockchain_name TEXT NOT NULL, + from_source_block_number BIGINT NOT NULL, + to_source_block_number BIGINT NOT NULL, + previous_source_block_hash BYTEA NOT NULL, + last_source_block_hash BYTEA NOT NULL, + previous_archive_big_block_number BIGINT, + previous_archive_big_block_hash BYTEA, + previous_chunk_offset BIGINT, + previous_chunk_size BIGINT, + new_chunk_offset BIGINT, + new_chunk_size BIGINT, + PRIMARY KEY (job_id, blockchain_name) +); + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; + INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,21,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,22,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 5dbf6e86..10406a25 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -46,11 +46,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_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; @@ -165,8 +161,6 @@ public final class JsonHandlerRegistry { private static final Map HANDLERS = Map.ofEntries( 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("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()), @@ -259,8 +253,6 @@ public final class JsonHandlerRegistry { private static final Map> REQUEST_TYPES = Map.ofEntries( 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("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class), diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java index 38597f90..0b17c1e6 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java @@ -59,6 +59,9 @@ public final class ShineUsersCodec { private static final int BLOCK_TYPE_TRUSTED_STATE = 70; + private static final int BLOCK_TYPE_ARCHIVE_HEAD = + 100; + private static final int BLOCK_VERSION_0 = 0; @@ -306,6 +309,12 @@ public final class ShineUsersCodec { int trustedCount = 0; + String archiveHeadTxId = + ""; + + String archiveHeadHash = + ""; + for (int i = 0; i < blocksCount; i++) { int blockType = @@ -455,6 +464,13 @@ public final class ShineUsersCodec { trustedCount = reader.readU8(); + case BLOCK_TYPE_ARCHIVE_HEAD -> { + archiveHeadTxId = + Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32)); + archiveHeadHash = + toHex(reader.readFixed(32)); + } + default -> throw new IllegalArgumentException( "Unsupported block type: " + blockType @@ -484,6 +500,8 @@ public final class ShineUsersCodec { lastBlockHash, lastBlockSignature, arweaveTxId, + archiveHeadTxId, + archiveHeadHash, isServer, addressFormatType, addressFormatVersion, @@ -520,7 +538,9 @@ public final class ShineUsersCodec { mutation.createdAtMs(), mutation.createdAtMs(), toHex(ZERO_HASH), - paidLimitBytes + paidLimitBytes, + mutation.fields().archiveHeadSupplied() ? mutation.fields().archiveHeadTxId() : "", + mutation.fields().archiveHeadSupplied() ? mutation.fields().archiveHeadHash() : "" ); } @@ -535,6 +555,13 @@ public final class ShineUsersCodec { previous.paidLimitBytes() + mutation.additionalLimit(); + String archiveTx = mutation.fields().archiveHeadSupplied() + ? mutation.fields().archiveHeadTxId() + : previous.archiveHeadTxId(); + String archiveHash = mutation.fields().archiveHeadSupplied() + ? mutation.fields().archiveHeadHash() + : previous.archiveHeadHash(); + return buildSnapshot( mutation, mutation.version(), @@ -543,7 +570,9 @@ public final class ShineUsersCodec { mutation.createdAtMs(), mutation.updatedAtMs(), toHex(mutation.prevHash()), - paidLimitBytes + paidLimitBytes, + archiveTx, + archiveHash ); } @@ -617,10 +646,13 @@ public final class ShineUsersCodec { pushFixed(out, prevHash); pushStringU8(out, loginBytes); + boolean hasArchiveHead = + snapshot.archiveHeadTxId() != null + && !snapshot.archiveHeadTxId().isBlank(); + int blocksCount = - snapshot.isServer() - ? 8 - : 7; + (snapshot.isServer() ? 8 : 7) + + (hasArchiveHead ? 1 : 0); out.add((byte) blocksCount); @@ -695,6 +727,18 @@ public final class ShineUsersCodec { out.add((byte) BLOCK_VERSION_0); out.add((byte) snapshot.trustedCount()); + if (hasArchiveHead) { + byte[] txId = Base64.getUrlDecoder().decode(snapshot.archiveHeadTxId()); + byte[] archiveHash = fromHex(snapshot.archiveHeadHash()); + if (txId.length != 32 || archiveHash.length != 32) { + throw new IllegalArgumentException("Archive head должен содержать txId/hash по 32 байта"); + } + out.add((byte) BLOCK_TYPE_ARCHIVE_HEAD); + out.add((byte) BLOCK_VERSION_0); + pushFixed(out, txId); + pushFixed(out, archiveHash); + } + int recordLength = out.size() + 64; @@ -871,6 +915,8 @@ public final class ShineUsersCodec { UserFields fields = parseFields(reader); + fields = parseOptionalArchiveExtension(reader, fields); + String recordSignature = Base58Util.encode( reader.readFixed(64) @@ -1008,7 +1054,35 @@ public final class ShineUsersCodec { List.copyOf(accessServers), sessionsMode, List.copyOf(sessions), - trustedCount + trustedCount, + false, + "", + "" + ); + } + + private static UserFields parseOptionalArchiveExtension(Reader reader, UserFields fields) { + if (reader.remaining() == 64) { + return fields; + } + if (reader.remaining() < 65) { + throw new IllegalArgumentException("Некорректная archive extension в update_user_pda"); + } + int present = reader.readU8(); + String tx = ""; + String hash = ""; + if (present == 1) { + tx = Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32)); + hash = toHex(reader.readFixed(32)); + } else if (present != 0) { + throw new IllegalArgumentException("Некорректный archive head marker: " + present); + } + return new UserFields( + fields.clientKey(), fields.blockchainKey(), fields.blockchainName(), fields.usedBytes(), + fields.lastBlockNumber(), fields.lastBlockHash(), fields.lastBlockSignature(), fields.arweaveTxId(), + fields.isServer(), fields.addressFormatType(), fields.addressFormatVersion(), fields.serverAddress(), + fields.syncServers(), fields.accessServers(), fields.sessionsMode(), fields.sessions(), fields.trustedCount(), + true, tx, hash ); } @@ -1020,7 +1094,9 @@ public final class ShineUsersCodec { long createdAtMs, long updatedAtMs, String prevRecordHash, - long paidLimitBytes + long paidLimitBytes, + String archiveHeadTxId, + String archiveHeadHash ) { UserFields fields = @@ -1044,6 +1120,8 @@ public final class ShineUsersCodec { fields.lastBlockHash(), fields.lastBlockSignature(), fields.arweaveTxId(), + archiveHeadTxId == null ? "" : archiveHeadTxId, + archiveHeadHash == null ? "" : archiveHeadHash, fields.isServer(), fields.addressFormatType(), fields.addressFormatVersion(), @@ -1266,6 +1344,10 @@ public final class ShineUsersCodec { ) { cursor += bytes; } + + private int remaining() { + return data.length - cursor; + } } public enum TxKind { @@ -1327,7 +1409,10 @@ public final class ShineUsersCodec { List accessServers, int sessionsMode, List sessions, - int trustedCount + int trustedCount, + boolean archiveHeadSupplied, + String archiveHeadTxId, + String archiveHeadHash ) { } @@ -1389,6 +1474,8 @@ public final class ShineUsersCodec { String lastBlockHash, String lastBlockSignature, String arweaveTxId, + String archiveHeadTxId, + String archiveHeadHash, boolean isServer, int addressFormatType, int addressFormatVersion, @@ -1424,6 +1511,8 @@ public final class ShineUsersCodec { lastBlockHash, lastBlockSignature, arweaveTxId, + archiveHeadTxId, + archiveHeadHash, isServer, addressFormatType, addressFormatVersion, diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java index f279f65d..9f56f004 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java @@ -425,13 +425,13 @@ public final class PostgresStorageRepository "record_number, recovery_key, root_key, client_key, " + "blockchain_name, blockchain_key, paid_limit_bytes, " + "used_bytes, last_block_number, last_block_hash, " + - "last_block_signature, arweave_tx_id, is_server, " + + "last_block_signature, arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, " + "address_format_type, address_format_version, " + "server_address, sync_servers_json, access_servers_json, " + "sessions_mode, sessions_json, trusted_count, " + "created_at_ms, updated_at_ms, prev_record_hash, " + "record_signature, raw_data_base64, saved_at_ms" + - ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT (pda_address, record_number) DO NOTHING"; try (PreparedStatement statement = @@ -470,13 +470,13 @@ public final class PostgresStorageRepository "recovery_key, root_key, client_key, blockchain_name, " + "blockchain_key, paid_limit_bytes, used_bytes, " + "last_block_number, last_block_hash, last_block_signature, " + - "arweave_tx_id, is_server, address_format_type, " + + "arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, address_format_type, " + "address_format_version, server_address, sync_servers_json, " + "access_servers_json, sessions_mode, sessions_json, " + "trusted_count, created_at_ms, updated_at_ms, " + "prev_record_hash, record_signature, raw_data_base64, " + "first_seen_at_ms, last_synced_at_ms" + - ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT (pda_address) DO UPDATE SET " + "login = EXCLUDED.login, " + "normalized_login = EXCLUDED.normalized_login, " + @@ -494,6 +494,8 @@ public final class PostgresStorageRepository "last_block_hash = EXCLUDED.last_block_hash, " + "last_block_signature = EXCLUDED.last_block_signature, " + "arweave_tx_id = EXCLUDED.arweave_tx_id, " + + "archive_head_tx_id = EXCLUDED.archive_head_tx_id, " + + "archive_head_hash = EXCLUDED.archive_head_hash, " + "is_server = EXCLUDED.is_server, " + "address_format_type = EXCLUDED.address_format_type, " + "address_format_version = EXCLUDED.address_format_version, " + @@ -536,141 +538,41 @@ public final class PostgresStorageRepository ShineUsersCodec.UserPdaSnapshot snapshot, long nowMs ) throws Exception { - - statement.setString( - 1, - snapshot.lastTxSignature() - ); - statement.setLong( - 2, - snapshot.slot() - ); - statement.setNull( - 3, - Types.BIGINT - ); - statement.setString( - 4, - snapshot.pdaAddress() - ); - statement.setString( - 5, - snapshot.login() - ); - statement.setInt( - 6, - snapshot.recordNumber() - ); - statement.setString( - 7, - snapshot.recoveryKey() - ); - statement.setString( - 8, - snapshot.rootKey() - ); - statement.setString( - 9, - snapshot.clientKey() - ); - statement.setString( - 10, - snapshot.blockchainName() - ); - statement.setString( - 11, - snapshot.blockchainKey() - ); - statement.setLong( - 12, - snapshot.paidLimitBytes() - ); - statement.setLong( - 13, - snapshot.usedBytes() - ); - statement.setInt( - 14, - snapshot.lastBlockNumber() - ); - statement.setString( - 15, - snapshot.lastBlockHash() - ); - statement.setString( - 16, - snapshot.lastBlockSignature() - ); - statement.setString( - 17, - snapshot.arweaveTxId() - ); - statement.setBoolean( - 18, - snapshot.isServer() - ); - statement.setInt( - 19, - snapshot.addressFormatType() - ); - statement.setInt( - 20, - snapshot.addressFormatVersion() - ); - statement.setString( - 21, - snapshot.serverAddress() - ); - statement.setString( - 22, - writeJson( - snapshot.syncServers() - ) - ); - statement.setString( - 23, - writeJson( - snapshot.accessServers() - ) - ); - statement.setInt( - 24, - snapshot.sessionsMode() - ); - statement.setString( - 25, - writeJson( - snapshot.sessions() - ) - ); - statement.setInt( - 26, - snapshot.trustedCount() - ); - statement.setLong( - 27, - snapshot.createdAtMs() - ); - statement.setLong( - 28, - snapshot.updatedAtMs() - ); - statement.setString( - 29, - snapshot.prevRecordHash() - ); - statement.setString( - 30, - snapshot.recordSignature() - ); - statement.setString( - 31, - snapshot.rawDataBase64() - ); - statement.setLong( - 32, - nowMs - ); + int i = 1; + statement.setString(i++, snapshot.lastTxSignature()); + statement.setLong(i++, snapshot.slot()); + statement.setNull(i++, Types.BIGINT); + statement.setString(i++, snapshot.pdaAddress()); + statement.setString(i++, snapshot.login()); + statement.setInt(i++, snapshot.recordNumber()); + statement.setString(i++, snapshot.recoveryKey()); + statement.setString(i++, snapshot.rootKey()); + statement.setString(i++, snapshot.clientKey()); + statement.setString(i++, snapshot.blockchainName()); + statement.setString(i++, snapshot.blockchainKey()); + statement.setLong(i++, snapshot.paidLimitBytes()); + statement.setLong(i++, snapshot.usedBytes()); + statement.setInt(i++, snapshot.lastBlockNumber()); + statement.setString(i++, snapshot.lastBlockHash()); + statement.setString(i++, snapshot.lastBlockSignature()); + statement.setString(i++, snapshot.arweaveTxId()); + statement.setString(i++, snapshot.archiveHeadTxId()); + statement.setString(i++, snapshot.archiveHeadHash()); + statement.setBoolean(i++, snapshot.isServer()); + statement.setInt(i++, snapshot.addressFormatType()); + statement.setInt(i++, snapshot.addressFormatVersion()); + statement.setString(i++, snapshot.serverAddress()); + statement.setString(i++, writeJson(snapshot.syncServers())); + statement.setString(i++, writeJson(snapshot.accessServers())); + statement.setInt(i++, snapshot.sessionsMode()); + statement.setString(i++, writeJson(snapshot.sessions())); + statement.setInt(i++, snapshot.trustedCount()); + statement.setLong(i++, snapshot.createdAtMs()); + statement.setLong(i++, snapshot.updatedAtMs()); + statement.setString(i++, snapshot.prevRecordHash()); + statement.setString(i++, snapshot.recordSignature()); + statement.setString(i++, snapshot.rawDataBase64()); + statement.setLong(i, nowMs); } private void bindCurrentSnapshot( @@ -678,40 +580,42 @@ public final class PostgresStorageRepository ShineUsersCodec.UserPdaSnapshot snapshot, long nowMs ) throws Exception { - - statement.setString(1, snapshot.pdaAddress()); - statement.setString(2, snapshot.login()); - statement.setString(3, normalizeLogin(snapshot.login())); - statement.setInt(4, snapshot.recordNumber()); - statement.setLong(5, snapshot.slot()); - statement.setString(6, snapshot.lastTxSignature()); - statement.setString(7, snapshot.recoveryKey()); - statement.setString(8, snapshot.rootKey()); - statement.setString(9, snapshot.clientKey()); - statement.setString(10, snapshot.blockchainName()); - statement.setString(11, snapshot.blockchainKey()); - statement.setLong(12, snapshot.paidLimitBytes()); - statement.setLong(13, snapshot.usedBytes()); - statement.setInt(14, snapshot.lastBlockNumber()); - statement.setString(15, snapshot.lastBlockHash()); - statement.setString(16, snapshot.lastBlockSignature()); - statement.setString(17, snapshot.arweaveTxId()); - statement.setBoolean(18, snapshot.isServer()); - statement.setInt(19, snapshot.addressFormatType()); - statement.setInt(20, snapshot.addressFormatVersion()); - statement.setString(21, snapshot.serverAddress()); - statement.setString(22, writeJson(snapshot.syncServers())); - statement.setString(23, writeJson(snapshot.accessServers())); - statement.setInt(24, snapshot.sessionsMode()); - statement.setString(25, writeJson(snapshot.sessions())); - statement.setInt(26, snapshot.trustedCount()); - statement.setLong(27, snapshot.createdAtMs()); - statement.setLong(28, snapshot.updatedAtMs()); - statement.setString(29, snapshot.prevRecordHash()); - statement.setString(30, snapshot.recordSignature()); - statement.setString(31, snapshot.rawDataBase64()); - statement.setLong(32, nowMs); - statement.setLong(33, nowMs); + int i=1; + statement.setString(i++, snapshot.pdaAddress()); + statement.setString(i++, snapshot.login()); + statement.setString(i++, normalizeLogin(snapshot.login())); + statement.setInt(i++, snapshot.recordNumber()); + statement.setLong(i++, snapshot.slot()); + statement.setString(i++, snapshot.lastTxSignature()); + statement.setString(i++, snapshot.recoveryKey()); + statement.setString(i++, snapshot.rootKey()); + statement.setString(i++, snapshot.clientKey()); + statement.setString(i++, snapshot.blockchainName()); + statement.setString(i++, snapshot.blockchainKey()); + statement.setLong(i++, snapshot.paidLimitBytes()); + statement.setLong(i++, snapshot.usedBytes()); + statement.setInt(i++, snapshot.lastBlockNumber()); + statement.setString(i++, snapshot.lastBlockHash()); + statement.setString(i++, snapshot.lastBlockSignature()); + statement.setString(i++, snapshot.arweaveTxId()); + statement.setString(i++, snapshot.archiveHeadTxId()); + statement.setString(i++, snapshot.archiveHeadHash()); + statement.setBoolean(i++, snapshot.isServer()); + statement.setInt(i++, snapshot.addressFormatType()); + statement.setInt(i++, snapshot.addressFormatVersion()); + statement.setString(i++, snapshot.serverAddress()); + statement.setString(i++, writeJson(snapshot.syncServers())); + statement.setString(i++, writeJson(snapshot.accessServers())); + statement.setInt(i++, snapshot.sessionsMode()); + statement.setString(i++, writeJson(snapshot.sessions())); + statement.setInt(i++, snapshot.trustedCount()); + statement.setLong(i++, snapshot.createdAtMs()); + statement.setLong(i++, snapshot.updatedAtMs()); + statement.setString(i++, snapshot.prevRecordHash()); + statement.setString(i++, snapshot.recordSignature()); + statement.setString(i++, snapshot.rawDataBase64()); + statement.setLong(i++, nowMs); + statement.setLong(i, nowMs); } private String normalizeLogin(String login) { @@ -763,6 +667,8 @@ public final class PostgresStorageRepository resultSet.getString("last_block_hash"), resultSet.getString("last_block_signature"), resultSet.getString("arweave_tx_id"), + resultSet.getString("archive_head_tx_id"), + resultSet.getString("archive_head_hash"), resultSet.getBoolean("is_server"), resultSet.getInt("address_format_type"), resultSet.getInt("address_format_version"), @@ -933,6 +839,8 @@ public final class PostgresStorageRepository "last_block_hash TEXT NOT NULL, " + "last_block_signature TEXT NOT NULL, " + "arweave_tx_id TEXT NOT NULL, " + + "archive_head_tx_id TEXT NOT NULL DEFAULT '', " + + "archive_head_hash TEXT NOT NULL DEFAULT '', " + "is_server BOOLEAN NOT NULL, " + "address_format_type INTEGER NOT NULL, " + "address_format_version INTEGER NOT NULL, " + @@ -962,6 +870,9 @@ public final class PostgresStorageRepository " OR normalized_login <> LOWER(BTRIM(login))" ); + statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate( "CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " + "ON solana_user_pda_current(slot)" @@ -995,6 +906,8 @@ public final class PostgresStorageRepository "last_block_hash TEXT NOT NULL, " + "last_block_signature TEXT NOT NULL, " + "arweave_tx_id TEXT NOT NULL, " + + "archive_head_tx_id TEXT NOT NULL DEFAULT '', " + + "archive_head_hash TEXT NOT NULL DEFAULT '', " + "is_server BOOLEAN NOT NULL, " + "address_format_type INTEGER NOT NULL, " + "address_format_version INTEGER NOT NULL, " + @@ -1014,6 +927,9 @@ public final class PostgresStorageRepository ")" ); + statement.executeUpdate("ALTER TABLE solana_user_pda_history ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate("ALTER TABLE solana_user_pda_history ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate( "CREATE INDEX IF NOT EXISTS idx_user_pda_history_login " + "ON solana_user_pda_history(login)" diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java index af065063..5e5fd518 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java @@ -87,7 +87,7 @@ public final class SolanaPdaUtil { return false; } - return Ed25519.validatePublicKeyFull( + return Ed25519.validatePublicKeyPartial( publicKey, 0 ); diff --git a/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/util/SolanaPdaUtilTest.java b/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/util/SolanaPdaUtilTest.java new file mode 100644 index 00000000..55fe85a9 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/util/SolanaPdaUtilTest.java @@ -0,0 +1,27 @@ +package sync.util; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SolanaPdaUtilTest { + + @Test + void findProgramAddressMatchesSolanaRuntimeForUserLoginSeed() { + String pda = SolanaPdaUtil.findProgramAddress( + List.of( + "user_login=".getBytes(StandardCharsets.UTF_8), + "server_t2".getBytes(StandardCharsets.UTF_8) + ), + "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6" + ); + + assertEquals( + "EJCW7cPXgEMvQAzmhJMZ7pxQxxxFbQYZoJNhcfJpWnz8", + pda + ); + } +} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 87f30e7d..7e76d99d 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -5,6 +5,7 @@ import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import server.archive.ArchivePublisherScheduler; import server.debug.DebugApiConfigurator; import server.files.DmFileApiConfigurator; import server.sync.BlockchainResyncRecoveryOnStartup; @@ -82,6 +83,9 @@ public final class WsServer { // ============================================================ PeriodicBlockchainSyncService.startOrLog(); + // Опциональная серверная публикация больших SHINE-ARCHIVE блоков. + ArchivePublisherScheduler.startOrLog(); + // ============================================================ // 2) Запуск Jetty WS // ============================================================ @@ -110,7 +114,7 @@ public final class WsServer { log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); ServerConnectionPool.getInstance().startOrLog(); Runtime.getRuntime().addShutdownHook(new Thread( - () -> ServerConnectionPool.getInstance().close(), + () -> { ServerConnectionPool.getInstance().close(); ArchivePublisherScheduler.close(); }, "server-connection-pool-shutdown")); PeriodicDmDeliveryService.startOrLog(); server.join(); diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 8c79392e..6ce8cf97 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -14,6 +14,34 @@ solana.users.sync.dbUser= solana.users.sync.dbPassword= solana.users.sync.pollIntervalSeconds=300 +# ------------------------------------------------------------ +# SHiNE archive publisher (Arweave + User PDA archive head) +# По умолчанию выключен: большинство серверов только читают/синхронизируют блоки. +# При включении готовые большие файлы сначала сохраняются локально в data/archive, +# затем загружаются в Arweave и только после подтверждения фиксируются в Solana. +# ------------------------------------------------------------ +archive.publish.enabled=false +archive.publish.time=00:00 +# Пусто = системная timezone сервера. Можно задать, например Europe/Warsaw. +archive.publish.zoneId= +archive.maxFileBytes=4000000000 +archive.workDir=data/archive + +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath= +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 + +# Для записи archive head используется уже существующий Solana RPC: +# solana.users.sync.rpcUrl (если задан), иначе solana.rpcUrl. +# Отдельного archive.solana.rpcUrl нет. +archive.solana.rootKeyPath= +archive.solana.clientKeyPath= +archive.solana.confirmPollSeconds=5 +archive.solana.confirmTimeoutMinutes=30 +archive.solana.commitment=finalized + # ------------------------------------------------------------ # Межсерверная синхронизация: как создавать локальную запись пользователя, # если во время sync пришла чужая цепочка, а у нас такого login ещё нет. diff --git a/build.gradle b/build.gradle index 28a0756f..278c8fd6 100644 --- a/build.gradle +++ b/build.gradle @@ -84,6 +84,7 @@ dependencies { implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов implementation project(':shine-server-solana-users-sync') + implementation project(':shine-server-archive') diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 124eb013..07553d0b 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -14,8 +14,6 @@ | --- | --- | --- | | `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 | | `ResolveLoginForAuth` | `02_Authentication_API.md` | проверка login перед входом: LOCAL / REMOTE / NOT_FOUND / NO_ACCESS_SERVER + URL правильного access server | | `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии | | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | diff --git a/docs/Archive/01_PROTOCOL_v1.0.md b/docs/Archive/01_PROTOCOL_v1.0.md new file mode 100644 index 00000000..ce7950e2 --- /dev/null +++ b/docs/Archive/01_PROTOCOL_v1.0.md @@ -0,0 +1,1619 @@ +# SHiNE Archive Protocol v1.0 + +## Полная спецификация серверной архивации SHiNE-блоков в Arweave и фиксации головы архива в Solana + +**Статус:** согласованная спецификация v1.0 +**Язык:** русский +**Magic:** `SHINE-ARCHIVE` +**Версия протокола:** `1.0` +**Endian:** big-endian для всех фиксированных целых чисел + +--- + +# 1. Назначение + +Этот документ описывает механизм, по которому SHiNE-сервер периодически: + +1. синхронизирует обычные SHiNE-блоки существующим механизмом; +2. определяет новые локальные блоки, ещё не попавшие в архив; +3. фиксирует неизменяемую дельту; +4. группирует все новые записи одной `blockchain_name` в один блок пользователя внутри большого архивного блока; +5. добавляет к каждому такому пользовательскому блоку одну ссылку на предыдущий пользовательский блок этой же `blockchain_name`; +6. формирует один большой бинарный блок `SHINE-ARCHIVE`; +7. загружает его в Arweave; +8. ждёт закрепления/подтверждения Arweave-транзакции; +9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; +10. только после Solana `finalized` переводит локальные курсоры на новую точку. + +На первом этапе предполагается один архивный Raspberry-сервер, но формат изначально не должен запрещать появление других архиваторов. + +--- + +# 2. Основные понятия + +## 2.1. Исходный SHiNE-блок / запись + +Это существующие в серверной БД оригинальные `block_bytes`, относящиеся к конкретной `blockchain_name`. + +Они уже содержат пользовательские данные и существующую криптографию SHiNE. + +Архиватор: + +- не изменяет их; +- не переподписывает их; +- не переводит их в Base64; +- не пересобирает их содержимое. + +## 2.2. `blockchain_name` + +В архивном формате основной идентификатор пользовательской цепочки — именно `blockchain_name`. + +Отдельно хранить `login` для пользовательского блока не требуется. + +`blockchain_name` уже однозначно идентифицирует конкретную пользовательскую blockchain и логически включает пользовательскую идентичность + номер/вариант цепочки. + +## 2.3. Большой архивный блок + +Один файл/объект `SHINE-ARCHIVE`, который сервер формирует за один цикл архивной публикации. + +Он содержит: + +- header; +- список предыдущих больших архивных блоков; +- по одному `UserBlockchainChunk` для каждой `blockchain_name`, у которой есть новые записи; +- footer; +- SHA-256 большого блока; +- подпись сервера, который этот большой блок сформировал и закрыл. + +Один большой архивный блок после публикации соответствует одной Arweave-транзакции. + +## 2.4. `UserBlockchainChunk` + +В одном большом архивном блоке для одной `blockchain_name` существует максимум один такой chunk. + +Если за текущую дельту у пользователя/цепочки накопилось 1, 3, 5 или 20 записей, все они помещаются внутрь одного `UserBlockchainChunk`. + +Навигационная ссылка одна на весь chunk. + +--- + +# 3. Главная модель пользовательской истории + +Допустим `alice-001` имела записи: + +```text +BigBlock 70: + AliceChunk = 2 записи + +BigBlock 81: + AliceChunk = 5 записей + +BigBlock 95: + AliceChunk = 3 записи + +BigBlock 100: + AliceChunk = 4 записи +``` + +Тогда история `alice-001` выглядит: + +```text +BigBlock 100 + AliceChunk + 4 записи + | + +--> PreviousBlockchainChunkRef + | + v +BigBlock 95 + AliceChunk + 3 записи + | + +--> PreviousBlockchainChunkRef + | + v +BigBlock 81 + AliceChunk + 5 записей + | + v +BigBlock 70 +``` + +Навигация идёт пачками записей одной blockchain, а не по одной записи за раз. + +--- + +# 4. Сервер сам добавляет навигацию + +Пользователь не обязан знать: + +- в какой большой архивный блок попадут его записи; +- какой будет Arweave TX ID; +- какие будут offsets; +- где находится предыдущий chunk. + +Все эти данные сервер добавляет при формировании большого блока. + +Исходные подписанные пользовательские `block_bytes` остаются неизменными. + +`PreviousBlockchainChunkRef` является внешней серверной архивной метаинформацией и защищается hash + подписью всего большого архивного блока. + +--- + +# 5. Необходимые ключи архивного сервера + +Архивный Raspberry/server хранит: + +## 5.1. SHiNE root private key + +Используется: + +- для подписания новой версии User PDA при обычном `update_user_pda`; +- в v1.0 — для подписи закрытого большого архивного блока. + +## 5.2. SHiNE client private key + +Используется как Solana transaction signer / fee payer в соответствии с текущей логикой `shine_users`. + +## 5.3. Arweave JWK / private wallet key + +Используется для загрузки и оплаты Arweave-транзакции. + +## 5.4. Blockchain private key + +Для архивной публикации не требуется. + +Архиватор не создаёт новые пользовательские blockchain-блоки. + +--- + +# 6. Расписание публикации + +Архивная публикация запускается один раз в сутки в заданное локальное время. Она НЕ использует интервал «каждые N минут». + +Пример: + +```properties +archive.publish.enabled=true +archive.publish.time=00:00 +archive.publish.zoneId= +``` + +Для v1.0 значение по умолчанию: + +```text +00:00 +``` + +То есть новый snapshot и новый большой архивный блок создаются один раз в сутки в полночь. + +Если `archive.publish.zoneId` пуст, используется системная timezone сервера. При необходимости её можно задать явно, например `Europe/Warsaw`. Это сохраняет публикацию ровно в указанное локальное время даже при переходах летнего/зимнего времени. + +Незавершённый archive job после рестарта не ждёт следующей полуночи: сервер продолжает именно его сразу. Новый snapshot при старте вне назначенного времени не создаётся. + +## 6.1. Первая архивная публикация + +Если у данного archive publisher ещё нет подтверждённых архивных курсоров, первая публикация берёт ВСЁ локально известное состояние: + +```text +для каждой blockchain_name: +source block 0 .. current local head +``` + +То есть первый большой архивный блок содержит все SHiNE-блоки, которые сервер успел узнать к моменту первого суточного snapshot. После успешной публикации следующие большие блоки содержат только дельту относительно подтверждённых курсоров. + +## 6.2. Локальная папка и имена файлов + +Перед любой сетевой загрузкой большой блок сначала полностью создаётся на локальном диске. По умолчанию каталог: + +```text +data/archive/ +``` + +До получения Arweave TX ID файл имеет временное имя: + +```text +.<00001>.<дд.мм.гг>.tmp.SHiNE-archive +``` + +Например: + +```text +archive01.00001.11.09.26.tmp.SHiNE-archive +``` + +Дата — реальная дата создания/freeze snapshot большого блока в timezone archive publisher-а. Номер имеет минимальную ширину 5 цифр. Пять цифр — форматирование, а не лимит: блок `100000` получает шестизначный номер. + +После успешной загрузки Arweave возвращает реальный TX ID. Сервер сначала надёжно сохраняет TX ID в БД, затем атомарно переименовывает тот же локальный файл в: + +```text +.<00001>.<дд.мм.гг>..SHiNE-archive +``` + +Например: + +```text +archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive +``` + +Поле `` — не слово `trx`, а настоящий Base64URL TX ID загруженного объекта в Arweave. Финальный файл остаётся локально как постоянная копия. + +Crash recovery обязан продолжать работу с этим же файлом. Если TX ID уже сохранён, но процесс упал до rename, при следующем запуске сервер вычисляет финальное имя из сохранённого TX ID и завершает переименование без повторной сборки дельты. + +--- + +# 7. Состояние, относительно которого считается дельта + +Нужно различать: + +1. уже окончательно опубликованное состояние; +2. текущий замороженный job, который ещё проходит Arweave/Solana. + +Нельзя считать дельту от живой головы во время уже запущенной публикации. + +--- + +# 8. Таблица `archive_chain_cursor` + +Хранит окончательно подтверждённое архивное состояние для каждой `blockchain_name`. + +```sql +CREATE TABLE archive_chain_cursor ( + blockchain_name TEXT PRIMARY KEY, + + last_archived_source_block_number BIGINT NOT NULL, + last_archived_source_block_hash BYTEA NOT NULL, + + last_archive_big_block_number BIGINT, + last_archive_big_block_hash BYTEA, + + last_chunk_offset BIGINT, + last_chunk_size BIGINT, + + updated_at_ms BIGINT NOT NULL +); +``` + +Назначение: + +- `last_archived_source_block_number` — до какого исходного SHiNE-блока этой blockchain всё успешно опубликовано; +- `last_archived_source_block_hash` — hash этого исходного блока; +- `last_archive_big_block_number` и `last_archive_big_block_hash` — в каком большом `SHINE-ARCHIVE` находится последний chunk этой blockchain; +- `last_chunk_offset` и `last_chunk_size` — точное положение последнего chunk внутри большого блока. + +Этого достаточно, чтобы следующая публикация создала один backlink на предыдущий chunk. + +--- + +# 9. Отдельный индекс по каждой записи не нужен + +Поскольку все новые записи одной `blockchain_name` агрегируются в один `UserBlockchainChunk`, не нужно хранить offsets каждой отдельной записи в истории. + +Для следующей публикации достаточно помнить: + +```text +предыдущий big block +предыдущий big block hash +chunk offset +chunk size +``` + +--- + +# 10. Таблица `archive_publish_job` + +Хранит crash-safe состояние одной архивной публикации. + +```sql +CREATE TABLE archive_publish_job ( + id BIGSERIAL PRIMARY KEY, + + big_block_number BIGINT NOT NULL, + status TEXT NOT NULL, + + created_at_ms BIGINT NOT NULL, + + local_archive_path TEXT, + + archive_hash BYTEA, + arweave_tx_id BYTEA, + + arweave_confirmations INTEGER, + solana_signature TEXT, + + error_text TEXT, + updated_at_ms BIGINT NOT NULL +); +``` + +Рекомендуемые статусы: + +```text +SNAPSHOT_CREATED +FILE_BUILT +ARWEAVE_UPLOADED +ARWEAVE_CONFIRMED +SOLANA_SUBMITTED +SOLANA_FINALIZED +CURSORS_COMMITTED +FAILED +``` + +Завершённые rows этой таблицы одновременно дают локальную историю больших блоков: + +```text +big_block_number +archive_hash +arweave_tx_id +``` + +По ним строится FULL-список предыдущих больших блоков следующей публикации. + +--- + +# 11. Таблица `archive_publish_job_chain` + +Хранит frozen range каждой blockchain в текущем job и координаты создаваемого chunk. + +```sql +CREATE TABLE archive_publish_job_chain ( + job_id BIGINT NOT NULL, + blockchain_name TEXT NOT NULL, + + from_source_block_number BIGINT NOT NULL, + to_source_block_number BIGINT NOT NULL, + + previous_source_block_hash BYTEA NOT NULL, + last_source_block_hash BYTEA NOT NULL, + + previous_archive_big_block_number BIGINT, + previous_archive_big_block_hash BYTEA, + previous_chunk_offset BIGINT, + previous_chunk_size BIGINT, + + new_chunk_offset BIGINT, + new_chunk_size BIGINT, + + PRIMARY KEY (job_id, blockchain_name) +); +``` + +--- + +# 12. Заморозка дельты + +Пример: + +```text +локально: +alice-001 = source block 180 +bob-001 = source block 94 + +опубликовано: +alice-001 = 160 +bob-001 = 90 +``` + +Новый job фиксирует: + +```text +alice-001: 161..180 +bob-001: 91..94 +``` + +Если во время загрузки появятся: + +```text +alice-001 181..185 +bob-001 95..97 +``` + +они попадут только в следующий job. + +--- + +# 13. Проверка курсора перед публикацией + +Перед созданием frozen range сервер проверяет: + +```text +local hash(last_archived_source_block_number) +== +archive_chain_cursor.last_archived_source_block_hash +``` + +При несовпадении публикация этой chain останавливается до resync/fork handling. + +--- + +# 14. Ограничение размера большого блока + +Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. + +Поэтому один большой archive-файл MUST быть меньше `4 GiB`. + +Рекомендуемый запас: + +```properties +archive.maxFileBytes=4000000000 +``` + +Если собранный frozen job превышает этот лимит, v1.0 останавливает публикацию с явной ошибкой `ArchiveTooLargeException` и не двигает курсоры. Практически лимит очень велик; для такого сервера следует уменьшить объём данных между суточными закрытиями или реализовать деление snapshot на несколько big blocks. Автоматическое деление одного snapshot на несколько big blocks оставлено как совместимое будущее расширение. + +--- + +# 15. Magic и версия + +Файл начинается ASCII: + +```text +SHINE-ARCHIVE +``` + +Размер: 13 байт. + +Далее: + +```text +u8 version_major +u8 version_minor +``` + +Для v1.0: + +```text +1 +0 +``` + +--- + +# 16. Общий layout большого блока + +```text ++------------------------------------------+ +| FIXED HEADER | ++------------------------------------------+ +| creator_login | ++------------------------------------------+ +| PREVIOUS BIG BLOCK REFERENCES | +| ref[0] | +| ref[1] | +| ... | ++------------------------------------------+ +| UserBlockchainChunk #1 | +| UserBlockchainChunk #2 | +| ... | ++------------------------------------------+ +| FOOTER: closer_login | +| block_hash | +| signature | ++------------------------------------------+ +``` + +--- + +# 17. Fixed Header v1.0 + +Все integers — big-endian. + +| Поле | Тип | Размер | +|---|---:|---:| +| magic | bytes[13] | 13 | +| version_major | u8 | 1 | +| version_minor | u8 | 1 | +| header_size | u32 | 4 | +| big_block_number | u32 | 4 | +| created_at_ms | u64 | 8 | +| creator_login_length | u8 | 1 | +| references_mode | u8 | 1 | +| references_count | u32 | 4 | +| reference_entry_size | u16 | 2 | +| parent_reference_index | u32 | 4 | +| blockchain_chunks_count | u32 | 4 | +| total_user_records_count | u32 | 4 | + +После fixed header немедленно идут `creator_login` bytes. + +`header_size` — byte offset первого `PreviousBigBlockReference`. + +--- + +# 18. `creator_login` + +SHiNE login пользователя/сервера, который сформировал большой блок. + +```text +u8 creator_login_length +bytes[N] creator_login UTF-8 +``` + +Этот login находится в начале файла, поэтому его можно узнать без скачивания payload. + +--- + +# 19. `big_block_number` + +```text +u32 +``` + +Рекомендуемая нумерация опубликованных больших блоков: + +```text +first = 1 +next = 2 +next = 3 +... +``` + +Первый реально публикуемый большой блок имеет номер `1`, поэтому его локальное имя содержит `00001`. Неудачная незавершённая попытка не становится частью опубликованной archive-цепочки. + +--- + +# 20. `created_at_ms` + +```text +u64 +``` + +Unix Epoch UTC milliseconds. + +Это время заморозки содержимого big block. + +--- + +# 21. Режим ссылок на предыдущие большие блоки + +```text +references_mode u8 +``` + +Значения: + +```text +0 = FULL +1 = PARTIAL, зарезервировано на будущее +``` + +Writer v1.0 MUST использовать `FULL`. + +--- + +# 22. FULL previous-block table + +Новый big block содержит ссылки на ВСЕ предыдущие большие блоки своей ветки. + +Пример: + +```text +BigBlock #365 + +References: +#1 +#2 +#3 +... +#364 +``` + +При одном big block в день через год таблица занимает примерно 24.8 KiB. + +--- + +# 23. `PreviousBigBlockReference` + +Фиксированный размер: 68 байт. + +| Поле | Тип | Размер | +|---|---:|---:| +| big_block_number | u32 | 4 | +| big_block_hash | bytes[32] | 32 | +| arweave_tx_id | bytes[32] | 32 | + +Семантика: + +```text +big_block_number = логический номер +big_block_hash = криптографическая идентичность +arweave_tx_id = где скачать блок +``` + +--- + +# 24. `references_count` + +Количество entries в таблице ссылок. + +```text +u32 +``` + +--- + +# 25. `reference_entry_size` + +```text +u16 +``` + +Для v1.0: + +```text +68 +``` + +Поле оставлено для будущего расширения entry. + +--- + +# 26. `parent_reference_index` + +```text +u32 +``` + +Индекс непосредственного родителя внутри reference table. + +Для первого большого блока (`#1`), у которого нет родителя: + +```text +0xFFFFFFFF +``` + +В простой FULL-линейной ветке обычно это последний entry. + +--- + +# 27. Быстрое скачивание начала блока + +Клиент сначала скачивает fixed header + creator login. + +Из него узнаёт: + +```text +header_size +references_count +reference_entry_size +``` + +Затем скачивает только reference table и уже имеет адреса/hash всех предыдущих больших блоков. + +Payload пользователей можно пока не скачивать. + +--- + +# 28. `blockchain_chunks_count` + +```text +u32 +``` + +Количество `UserBlockchainChunk` в текущем big block. + +Одна `blockchain_name` встречается максимум один раз. + +--- + +# 29. `total_user_records_count` + +```text +u32 +``` + +Общее количество исходных SHiNE-записей во всех chunks. + +--- + +# 30. Порядок chunks и records + +Для детерминированной сериализации chunks SHOULD сортироваться по `blockchain_name ASC`. + +Внутри chunk исходные записи MUST идти по возрастанию номера исходного SHiNE-блока. + +--- + +# 31. Формат `UserBlockchainChunk` + +```text +u32 chunk_size + +u8 blockchain_name_length +bytes[N] blockchain_name UTF-8 + +u32 records_count + +repeat records_count: + u32 record_size + bytes[M] raw_record_bytes + +u32 previous_big_block_ref +u32 previous_chunk_offset +u32 previous_chunk_size +``` + +--- + +# 32. `chunk_size` + +```text +u32 +``` + +Полный размер chunk от первого байта `chunk_size` до последнего байта `previous_chunk_size` включительно. + +Это позволяет: + +- быстро пропустить chunk; +- скачать его Range-запросом; +- сохранить его точные координаты для следующего backlink. + +--- + +# 33. `blockchain_name` внутри chunk + +Хранится только `blockchain_name`. + +Отдельный `login` не нужен. + +--- + +# 34. `records_count` + +```text +u32 +``` + +Если за период у `alice-001` появилось 7 записей: + +```text +records_count = 7 +``` + +Все 7 находятся в одном chunk. + +--- + +# 35. Raw records + +Каждая исходная запись сериализуется: + +```text +u32 record_size +bytes[record_size] raw_record_bytes +``` + +`raw_record_bytes` — оригинальные данные SHiNE из БД. + +Сервер не меняет их содержимое. + +--- + +# 36. Один backlink на весь chunk + +После всех raw records находится ровно один `PreviousBlockchainChunkRef`. + +Он относится ко всей пачке записей blockchain. + +Он не повторяется у отдельных записей. + +--- + +# 37. `PreviousBlockchainChunkRef` + +| Поле | Тип | Размер | +|---|---:|---:| +| previous_big_block_ref | u32 | 4 | +| previous_chunk_offset | u32 | 4 | +| previous_chunk_size | u32 | 4 | + +Итого 12 байт на весь chunk. + +--- + +# 38. `previous_big_block_ref` + +Индекс в `PreviousBigBlockReferences[]` текущего big block. + +Он указывает на тот предыдущий big block, где находится предыдущий chunk этой же `blockchain_name`. + +Это именно reference index, а не номер блока. + +--- + +# 39. `previous_chunk_offset` + +```text +u32 +``` + +Byte offset от начала предыдущего `SHINE-ARCHIVE` до первого байта предыдущего chunk этой blockchain. + +Первый байт chunk — поле `chunk_size`. + +--- + +# 40. `previous_chunk_size` + +```text +u32 +``` + +Полный размер предыдущего chunk. + +Поэтому клиент может Range-запросом забрать сразу всю предыдущую пачку записей пользователя. + +--- + +# 41. Первая публикация blockchain + +Если у данной `blockchain_name` нет предыдущего архивного chunk: + +```text +previous_big_block_ref = 0xFFFFFFFF +previous_chunk_offset = 0 +previous_chunk_size = 0 +``` + +--- + +# 42. Пример backlink-цепочки + +В `BigBlock #100` есть `alice-001` chunk из 4 записей. + +Его backlink: + +```text +previous_big_block_ref = 94 +previous_chunk_offset = 8123 +previous_chunk_size = 1770 +``` + +`ref[94]` содержит: + +```text +big_block_number = 95 +big_block_hash = ... +arweave_tx_id = ... +``` + +Клиент: + +1. получает TX big block #95; +2. проверяет hash; +3. делает Range на `8123 .. 8123+1770`; +4. получает весь предыдущий `alice-001` chunk; +5. читает его записи; +6. берёт следующий backlink. + +--- + +# 43. Почему TX ID не хранится в каждом chunk + +TX ID и hash предыдущих больших блоков уже один раз записаны в общей reference table. + +Поэтому в chunk достаточно компактного `u32 previous_big_block_ref`. + +--- + +# 44. Footer большого блока + +После последнего chunk: + +```text +u8 closer_login_length +bytes[N] closer_login UTF-8 + +bytes[32] block_hash +bytes[64] closer_signature +``` + +--- + +# 45. `closer_login` + +Login пользователя/сервера, который сформировал и закрыл big block. + +В v1.0: + +```text +closer_login MUST == creator_login +``` + +Login повторяется намеренно: + +- header даёт автора сразу; +- footer явно фиксирует того, кто закрыл блок; +- hash покрывает footer login; +- verifier проверяет совпадение. + +--- + +# 46. `block_hash` + +```text +bytes[32] +SHA-256 +``` + +Хэшируется всё от первого байта magic до последнего байта `closer_login` включительно. + +Не входят: + +```text +block_hash +closer_signature +``` + +Псевдокод: + +```text +hash_input = + fixed_header + + creator_login + + references + + all UserBlockchainChunks + + closer_login_length + + closer_login + +block_hash = SHA256(hash_input) +``` + +--- + +# 47. `closer_signature` + +```text +bytes[64] +Ed25519 +``` + +Подписывается: + +```text +ASCII("SHINE-ARCHIVE-V1") || block_hash +``` + +Для v1.0 используется root private key аккаунта `creator_login`. + +--- + +# 48. Проверка подписи + +Verifier: + +1. читает `creator_login`; +2. проверяет `creator_login == closer_login`; +3. получает root public key аккаунта из User PDA / исторического состояния Solana; +4. пересчитывает `block_hash`; +5. проверяет Ed25519 signature. + +Старые архивы должны оставаться проверяемыми после ротации root key, поэтому исторические PDA-состояния нужно сохранять/уметь получать. + +--- + +# 49. Собственный Arweave TX ID текущего блока + +Текущий файл не может заранее знать свой будущий TX ID. + +Поэтому собственный TX ID: + +- не входит в сам текущий файл; +- сохраняется после upload; +- записывается в Solana PDA; +- появляется как previous-block reference уже в следующем big block. + +--- + +# 50. User PDA block type `100` + +Archive head хранится прямо в существующем User PDA publisher-а. + +Формат: + +```text +u8 block_type = 100 +u8 block_version = 0 +bytes[32] archive_tx_id +bytes[32] archive_hash +``` + +Итого 66 байт. + +`archive_hash` = `block_hash` последнего успешно опубликованного big block. + +--- + +# 51. Обновление Solana + +Отдельная новая Solana instruction не требуется. + +Используется существующий `update_user_pda`. + +Нужно расширить существующие serializer/parser/codec так, чтобы block type 100: + +- читался; +- записывался; +- сохранялся обычными обновлениями; +- мог быть изменён архивным сервером обычным update User PDA. + +--- + +# 52. Полный порядок публикации + +```text +1. Существующая server-to-server sync независимо обновляет локальные blockchains. + +2. Scheduler запускает archive job. + +3. Проверяется отсутствие другого активного archive job. + +4. Читаются archive_chain_cursor. + +5. Читаются текущие локальные blockchain heads. + +6. Для каждой blockchain вычисляется frozen delta. + +7. Создаётся archive_publish_job. + +8. Создаются archive_publish_job_chain rows. + +9. Загружается история всех успешно finalized previous big blocks. + +10. Формируется fixed header. + +11. Записывается creator_login. + +12. Записывается FULL PreviousBigBlockReferences. + +13. Для каждой blockchain_name: + - читается frozen range; + - создаётся ровно один UserBlockchainChunk; + - внутрь кладутся все новые records; + - добавляется один PreviousBlockchainChunkRef; + - сохраняются new_chunk_offset/new_chunk_size. + +14. Записывается closer_login. + +15. Вычисляется SHA-256 block_hash. + +16. Создаётся Ed25519 closer_signature. + +17. Файл закрывается. + +18. Файл загружается в Arweave. + +19. TX ID сохраняется в archive_publish_job. + +20. Сервер ждёт требуемое число Arweave confirmations. + +21. Обычным update_user_pda обновляется block type 100: + archive_tx_id + archive_hash + +22. Сервер ждёт Solana finalized. + +23. В одной DB transaction обновляются archive_chain_cursor. + +24. Job получает CURSORS_COMMITTED. +``` + +--- + +# 53. Cursor commit + +Для каждой blockchain из job: + +```text +last_archived_source_block_number = to_source_block_number +last_archived_source_block_hash = last_source_block_hash + +last_archive_big_block_number = current big_block_number +last_archive_big_block_hash = current block_hash + +last_chunk_offset = new_chunk_offset +last_chunk_size = new_chunk_size +``` + +Следующая публикация сразу знает, куда должен вести backlink. + +--- + +# 54. Arweave service + +Старый `TestFreeAvatarArweaveService` удаляется из активного протокола; archive publisher использует отдельный `ArweaveArchiveService`. + +Его следует переделать/переименовать, например в: + +```text +ArweaveArchiveService +``` + +Он должен: + +- использовать Arweave JWK; +- подписывать и оплачивать upload; +- поддерживать большие/chunked uploads; +- возвращать TX ID; +- ждать подтверждения; +- опрашивать confirmations. + +Рекомендуемые настройки: + +```properties +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 +``` + +--- + +# 55. Solana finalization + +После обычного `update_user_pda` сервер должен дождаться: + +```text +finalized +``` + +Только после этого разрешается сдвинуть локальные курсоры. + +--- + +# 56. Crash recovery + +## После `SNAPSHOT_CREATED` + +Возобновить тот же frozen job. + +## После `FILE_BUILT` + +Использовать тот же локальный файл. + +## После `ARWEAVE_UPLOADED` + +Не загружать второй раз. Продолжить по сохранённому TX ID. + +## После `ARWEAVE_CONFIRMED` + +Продолжить Solana update. + +## После `SOLANA_SUBMITTED` + +Сначала проверить transaction / текущий PDA. + +## После `SOLANA_FINALIZED`, но до cursor commit + +Проверить: + +```text +PDA.archive_tx_id == expected_tx +PDA.archive_hash == expected_hash +``` + +После этого безопасно выполнить cursor commit. + +--- + +# 57. Нет новых данных + +Если новых записей нет ни в одной blockchain: + +```text +не создавать пустой SHINE-ARCHIVE +не загружать Arweave +не обновлять Solana +``` + +--- + +# 58. FULL references как стартовая стратегия + +v1.0 сознательно хранит ВСЕ previous big blocks в каждом новом big block. + +Плюсы: + +- актуальный блок сразу даёт карту всей предыдущей истории; +- не нужно идти parent-by-parent; +- backlink может использовать компактный ref index; +- reader очень простой. + +При одном big block в сутки: + +```text +1 год: ~24.8 KiB +5 лет: ~121 KiB +20 лет: ~485 KiB +``` + +--- + +# 59. PARTIAL references + +`references_mode = 1` зарезервирован на будущее. + +v1.0 writer его не реализует. + +В будущем PARTIAL может означать: + +- только реально используемые previous blocks; +- последние N blocks; +- checkpoint references; +- смешанную стратегию. + +--- + +# 60. Чтение истории одной blockchain + +Имея актуальный chunk `alice-001`, клиент: + +1. читает все records chunk; +2. читает `PreviousBlockchainChunkRef`; +3. если ref = `0xFFFFFFFF`, история закончилась; +4. иначе берёт соответствующий `PreviousBigBlockReference`; +5. получает TX ID + hash previous big block; +6. Range-запросом читает `previous_chunk_offset/previous_chunk_size`; +7. получает сразу всю предыдущую пачку записей `alice-001`; +8. повторяет. + +Так можно читать историю конкретной blockchain, не скачивая чужие chunks. + +--- + +# 61. Дубликаты + +Если исходная SHiNE-запись уже есть у получателя, она пропускается/дедуплицируется стандартной логикой. + +--- + +# 62. Конфликты исходной blockchain + +Архивная ветка сама по себе не является пользовательским конфликтом. + +Конфликт возникает, когда одна `blockchain_name` имеет два несовместимых валидно подписанных продолжения одной предыдущей точки. + +Для v1 безопасно: + +```text +сохранить обе ветки +пометить blockchain CONFLICTED +остановить автоматическое продолжение +``` + +Механизм разрешения fork можно добавить позже. + +--- + +# 63. Авторство большого блока + +Автор указан дважды: + +## Header + +```text +creator_login +``` + +## Footer + +```text +closer_login +block_hash +closer_signature +``` + +Для v1.0: + +```text +creator_login == closer_login +``` + +Это означает: именно этот SHiNE-аккаунт сформировал и криптографически закрыл big block. + +--- + +# 64. Подпись сервера не заменяет подписи пользователей + +`closer_signature` доказывает авторство большого archive block. + +Каждая пользовательская raw record всё равно проверяется существующей криптографией SHiNE. + +--- + +# 65. Независимость от Arweave + +Arweave TX ID — location. + +SHA-256 — identity. + +Поэтому архивы можно перенести на другой storage и всё равно проверять. + +--- + +# 66. Block type 100 в Solana — текущая голова + +User PDA publisher-а хранит только: + +```text +current archive TX ID +current archive SHA-256 +``` + +Детальная история находится внутри big blocks. + +--- + +# 67. Startup preflight + +Перед запуском archive publisher сервер SHOULD проверить: + +```text +derivePublic(root_private) == UserPDA.root_public_key +``` + +и: + +```text +derivePublic(client_private) == UserPDA.client_key +``` + +При несовпадении archive publisher не запускается. + +--- + +# 68. Рекомендуемые настройки + +```properties +archive.publish.enabled=false +archive.publish.time=00:00 +archive.publish.zoneId= +archive.workDir=data/archive + +archive.maxFileBytes=4000000000 + +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 + +archive.solana.rootKeyPath=/opt/shine/secrets/root.key +archive.solana.clientKeyPath=/opt/shine/secrets/client.key +archive.solana.commitment=finalized + +# Отдельного archive.solana.rpcUrl нет. +# Используется solana.users.sync.rpcUrl, а если он пуст — обычный solana.rpcUrl. +``` + +--- + +# 69. Требование к `AppConfig` + +Archive settings должны читаться из: + +- Java system properties; +- environment variables; +- `application.properties`. + +`getInt()` и `getBoolean()` должны использовать общую логику `getParam()`. + +--- + +# 70. Что переиспользуется из текущего сервера + +Существующие механизмы: + +```text +BlockchainStateDAO.listAll() +BlocksDAO.listRangeByNumber(...) +``` + +дают необходимые heads и raw block ranges. + +Используются существующие: + +```text +block_bytes +block_hash +block_signature +blockchain_name +block_number +``` + +Server-to-server sync в v1.0 не меняется. + +--- + +# 71. Блокировки + +Можно использовать существующий blockchain lock только на коротком этапе: + +```text +проверить cursor hash +прочитать frozen range +проверить конечный hash +сохранить/сериализовать frozen data +``` + +Lock не удерживается во время Arweave/Solana ожиданий. + +--- + +# 72. Реализационный checklist + +## Database + +- [x] `archive_chain_cursor` +- [x] `archive_publish_job` +- [x] `archive_publish_job_chain` +- [x] schema migration +- [x] crash recovery + +## Archive writer + +- [x] magic `SHINE-ARCHIVE` +- [x] major/minor version +- [x] fixed header +- [x] creator login +- [x] FULL previous big block table +- [x] one chunk per `blockchain_name` +- [x] many raw records inside one chunk +- [x] one backlink per chunk +- [x] closer login +- [x] SHA-256 +- [x] Ed25519 signature +- [x] max file size < 4 GiB +- [x] local `.tmp.SHiNE-archive -> ..SHiNE-archive` lifecycle in `data/archive` + +## Arweave + +- [x] rename/refactor `TestFreeAvatarArweaveService` +- [x] remove avatar-specific logic +- [x] large/chunked upload +- [x] confirmation polling + +## Solana + +- [x] add PDA block type `100` +- [x] update Rust codec +- [x] update Java codec +- [x] update JS codec/UI writer +- [x] use ordinary `update_user_pda` +- [x] server-side transaction writer +- [x] root signature +- [x] client fee payer +- [x] wait for `finalized` + +## Scheduler + +- [x] `archive.publish.enabled` +- [x] `archive.publish.time` +- [x] `archive.publish.zoneId` +- [x] no concurrent jobs +- [ ] future: автоматическое split > max size (v1.0 сейчас безопасно останавливается без cursor commit) +- [x] skip when no new blocks + +--- + +# 73. Итоговая бинарная схема v1.0 + +```text +SHINE-ARCHIVE +version_major u8 +version_minor u8 + +header_size u32 +big_block_number u32 +created_at_ms u64 + +creator_login_length u8 + +references_mode u8 +references_count u32 +reference_entry_size u16 +parent_reference_index u32 + +blockchain_chunks_count u32 +total_user_records_count u32 + +creator_login bytes + + +PreviousBigBlockReference[references_count]: + + big_block_number u32 + big_block_hash [32] + arweave_tx_id [32] + + +UserBlockchainChunk[blockchain_chunks_count]: + + chunk_size u32 + + blockchain_name_length u8 + blockchain_name bytes + + records_count u32 + + repeat records_count: + record_size u32 + raw_record_bytes + + previous_big_block_ref u32 + previous_chunk_offset u32 + previous_chunk_size u32 + + +FOOTER: + + closer_login_length u8 + closer_login bytes + + block_hash [32] + closer_signature [64] +``` + +--- + +# 74. Основная архитектура одним рисунком + +```text +SOLANA USER PDA + | + | block type 100 + | TX + HASH + v +BigBlock #100 + | + +-- Header + | creator = server-A + | + +-- References + | ref[0] -> BigBlock #1 / hash / TX + | ref[1] -> BigBlock #2 / hash / TX + | ... + | ref[98] -> BigBlock #99 / hash / TX + | + +-- alice-001 chunk + | records x4 + | | + | +--> BigBlock #95 / AliceChunk + | + +-- bob-001 chunk + | records x2 + | | + | +--> BigBlock #98 / BobChunk + | + +-- kate-002 chunk + | records x10 + | | + | +--> BigBlock #74 / KateChunk + | + +-- Footer + closer = server-A + SHA-256 + Ed25519 signature +``` + +--- + +# 75. Итоговые решения v1.0 + +- один большой блок = одна архивная публикация; +- в начале лежит полный список всех предыдущих больших блоков; +- каждый previous block reference содержит `number + SHA-256 + Arweave TX ID`; +- одна `blockchain_name` встречается в большом блоке максимум одним chunk; +- все новые записи этой blockchain объединяются в этот chunk; +- chunk имеет одну ссылку на предыдущий chunk этой же blockchain; +- backlink состоит из `previous big block ref + chunk offset + chunk size`; +- сервер сам создаёт backlink; +- пользовательские raw records не меняются; +- login сервера есть в header и footer; +- большой блок закрывается SHA-256 + Ed25519 signature сервера; +- Solana User PDA block `100` хранит текущий TX ID + archive hash; +- PDA обновляется обычным `update_user_pda`; +- Arweave используется как долговременное хранилище; +- публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. diff --git a/docs/Archive/02_IMPLEMENTATION_MAP.md b/docs/Archive/02_IMPLEMENTATION_MAP.md new file mode 100644 index 00000000..f0654733 --- /dev/null +++ b/docs/Archive/02_IMPLEMENTATION_MAP.md @@ -0,0 +1,98 @@ +# Карта реализации SHiNE Archive Publisher v1.0 + +Этот документ связывает протокол с конкретными файлами проекта. Он нужен, чтобы другой агент мог быстро понять, где искать каждую часть реализации. + +## 1. Новый Java-модуль `shine-server-archive` + +Путь: `SHiNE-server/shine-server-archive/`. + +Основные классы: + +- `ArchivePublisherScheduler` — включает publisher только при `archive.publish.enabled=true`, сразу продолжает незавершённый job после рестарта и планирует новый snapshot один раз в сутки в `archive.publish.time`. Следующая дата вычисляется по `ZoneId`, а не как `+24h`, поэтому DST не сдвигает локальную полночь. +- `ArchivePublisherService` — state machine: snapshot → локальный файл → Arweave → confirmations → User PDA → Solana finalized → cursor commit. +- `ArchivePublisherConfig` — читает настройки. Для Solana RPC отдельного archive URL нет: используется `solana.users.sync.rpcUrl`, затем fallback на `solana.rpcUrl`. +- `ShineArchiveWriter` — сериализует бинарный big block `SHINE-ARCHIVE v1.0`, FULL reference table, по одному chunk на `blockchain_name`, footer/hash/signature. +- `ArchiveFileNames` — временное и финальное локальные имена. +- `ArweaveArchiveService` + `ArweaveMerkle` — Arweave v2 transaction + chunk upload + polling confirmations. +- `SolanaArchiveHeadWriter` — обычный `update_user_pda`: root signature + текущая last-block signature + client key fee payer, затем ожидание `finalized`. +- `ArchiveKeyLoader` — читает Ed25519 seed/keypair из raw 32/64 bytes, Solana JSON 32/64 или Base64/PKCS8. + +## 2. Локальное состояние PostgreSQL + +Миграция: `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql`. + +Таблицы: + +### `archive_chain_cursor` +Одна строка на `blockchain_name`. Хранит только **последнее окончательно опубликованное** состояние: + +- последний source block number/hash; +- big block number/hash, где находится последний chunk; +- offset/size последнего chunk. + +Если blockchain не попала в новый big block, эта строка не меняется. + +### `archive_publish_job` +Crash-safe state одной большой публикации и путь к локальному файлу. + +Основные состояния: +`SNAPSHOT_CREATED → FILE_BUILT → ARWEAVE_UPLOADED → ARWEAVE_CONFIRMED → SOLANA_SUBMITTED → SOLANA_FINALIZED → CURSORS_COMMITTED`. + +### `archive_publish_job_chain` +Frozen range каждой blockchain текущего job + старые и новые координаты chunk. Пока job не finalized, `archive_chain_cursor` не двигается. + +`DatabaseInitializer` автоматически применяет migration v22 при старте существующей БД. Новая БД создаётся уже со схемой v22. + +## 3. Как считается первая дельта + +`ArchivePublisherService.createFrozenJob()` проходит по `BlockchainStateDAO.listAll()`. + +- Если cursor для `blockchain_name` отсутствует: `from = 0`, поэтому первый архив содержит всё локально известное состояние `0..head`. +- Если cursor существует: `from = last_archived + 1`. +- Перед продолжением проверяется hash cursor-блока. + +Папка `data/archive` сама по себе **не является источником истины** о том, был ли первый архив. Источник истины — БД cursor/job. Поэтому удаление локального файла не приводит к ошибочной повторной полной публикации. + +## 4. Локальный lifecycle файла + +До появления Arweave TX ID: + +`.<00001>..tmp.SHiNE-archive` + +После полной успешной загрузки transaction header + chunks в Arweave: + +`.<00001>...SHiNE-archive` + +Дата — реальная дата freeze snapshot в timezone archive publisher-а. Номер начинается с `00001`. Пять цифр — минимальная ширина, а не лимит. + +После rename файл остаётся локально. При crash после сохранения TX ID, но до rename, recovery переименует тот же файл и не загрузит его повторно. + +## 5. User PDA block type `100` + +Содержимое: + +```text +u8 block_type = 100 +u8 block_version = 0 +bytes[32] archive_tx_id +bytes[32] archive_hash +``` + +Используется существующий `update_user_pda`; отдельной instruction нет. + +Совместимость: +- legacy update без archive extension должен сохранить старый archive head; +- новый update может заменить/очистить block `100`; +- Java/JS codecs и PostgreSQL Solana sync умеют читать новый блок. + +Ключевой Rust-файл: `shine-solana/shine/programs/shine_users/src/lib.rs`. + +## 6. Startup сервера + +`WsServer` после текущего Solana/users sync и inter-server blockchain sync вызывает `ArchivePublisherScheduler.startOrLog()`. + +При `archive.publish.enabled=false` scheduler пишет лог о выключенной функции и больше ничего не делает. Ключи/Arweave wallet на обычном сервере тогда не требуются. + +## 7. Legacy TestFreeAvatar + +Старый временный `TestFreeAvatarArweaveService` больше не является частью активного WS-протокола. Registry/API документация убраны. При наложении changed-files ZIP поверх старого дерева старые исходники физически останутся, поэтому их список для удаления находится в `05_PATCH_CONTENTS_AND_REMOVALS.md`. diff --git a/docs/Archive/03_DEPLOY_TEST_SERVER.md b/docs/Archive/03_DEPLOY_TEST_SERVER.md new file mode 100644 index 00000000..5b738ad7 --- /dev/null +++ b/docs/Archive/03_DEPLOY_TEST_SERVER.md @@ -0,0 +1,415 @@ +# Деплой SHiNE Archive Publisher v1.0 на тестовый сервер + +Документ рассчитан на человека или автономного coding/deploy агента. Выполнять шаги по порядку. Не включать publisher до проверки Solana-программы и ключей. + +## 0. Что именно меняется + +Нужны изменения одновременно в: + +1. серверном Java-коде; +2. PostgreSQL schema v22; +3. Solana-программе `shine_users` (PDA block type `100` + backward-compatible update parser); +4. Java/JS User PDA codecs. + +**Критично:** новый серверный writer отправляет расширенный обычный `update_user_pda`. Если в целевом кластере работает старая `shine_users`, включать publisher нельзя. + +--- + +# 1. Применить пакет к исходникам + +ZIP из этой поставки содержит только новые/изменённые файлы с путями относительно корня репозитория. + +Сделать backup текущего проекта, затем распаковать ZIP поверх рабочего дерева. + +После распаковки удалить legacy-файлы из списка `05_PATCH_CONTENTS_AND_REMOVALS.md`. + +Проверить: + +```bash +git status --short +``` + +или, если это не git checkout, сравнить список файлов с manifest из той же документации. + +--- + +# 2. Обязательно обновить `shine_users` в нужном Solana-кластере + +## 2.1. Проверить целевой кластер + +Не деплоить вслепую. Сначала: + +```bash +solana config get +``` + +и проверить RPC/кластер, upgrade authority и Program ID. В проекте `shine_users` использует Program ID: + +```text +SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 +``` + +Если тестовый сервер использует mainnet RPC, обновляется именно mainnet-программа. Если тестовый контур использует devnet — сначала убедиться, что программа с нужным ID действительно существует в devnet. + +## 2.2. Собрать Solana program + +```bash +cd shine-solana/shine +anchor build +``` + +Минимальная host-проверка Rust, если Anchor/SBF toolchain временно недоступен: + +```bash +cargo build -p shine_users +``` + +Но для реального deploy нужен SBF/Anchor build. + +## 2.3. Обновить существующую программу + +Использовать существующий project deploy/upgrade authority. Типовой вариант: + +```bash +solana program deploy target/deploy/shine_users.so \ + --program-id target/deploy/shine_users-keypair.json \ + --upgrade-authority /PATH/TO/UPGRADE_AUTHORITY.json \ + --url +``` + +Если в проекте используется рабочий Anchor deploy workflow, допустимо использовать его вместо прямого `solana program deploy`; главное — сохранить тот же Program ID. + +После обновления: + +```bash +solana program show SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 --url +``` + +--- + +# 3. Собрать серверный JAR + +Из корня репозитория: + +```bash +./gradlew clean shadowJar +``` + +Ожидаемый файл: + +```text +SHiNE-server/build/libs/shine-server.jar +``` + +Если Gradle wrapper не может скачать зависимости, сборку выполнять на машине/CI с доступом к Maven/Gradle или с уже заполненным cache. + +--- + +# 4. Подготовить PostgreSQL backup + +Перед первым стартом версии со schema v22 сделать backup тестовой БД. Например: + +```bash +pg_dump -Fc -d '' -f shine-before-archive-v22.dump +``` + +Точная команда зависит от текущей схемы доступа PostgreSQL. + +При старте сервер сам применит `migration_v22.sql`, если `db_schema_version < 22`. Вручную migration выполнять обычно не нужно. + +После старта проверить: + +```sql +SELECT * FROM db_schema_version WHERE id=1; +``` + +Ожидается: + +```text +schema_version = 22 +``` + +И наличие: + +```sql +SELECT to_regclass('public.archive_chain_cursor'); +SELECT to_regclass('public.archive_publish_job'); +SELECT to_regclass('public.archive_publish_job_chain'); +``` + +--- + +# 5. Подготовить секреты на тестовом сервере + +Пример: + +```bash +sudo -u player mkdir -p /home/player/SHiNE/secrets +sudo chmod 700 /home/player/SHiNE/secrets +``` + +Положить: + +```text +/home/player/SHiNE/secrets/archive-arweave-wallet.json +/home/player/SHiNE/secrets/server-root.key +/home/player/SHiNE/secrets/server-client.key +``` + +Права: + +```bash +sudo chown player:player /home/player/SHiNE/secrets/* +sudo chmod 600 /home/player/SHiNE/secrets/* +``` + +### Форматы root/client key + +Поддерживаются: +- raw seed 32 bytes; +- raw keypair 64 bytes; +- Solana JSON array на 32/64 байта; +- Base58 seed на 32 байта или Solana secret key на 64 байта; +- Base64 raw/PKCS8, где seed извлекается из последних 32 bytes. + +### Arweave wallet + +Ожидается RSA JWK с полями `n,e,d,p,q,dp,dq,qi`. Кошелёк должен иметь достаточно AR для размера первого полного архива. + +--- + +# 6. Настроить внешний `application.properties` + +Сервер читает внешний `application.properties` из **WorkingDirectory процесса** и накладывает его поверх встроенного конфига. Сохранять существующие DB/Solana/server параметры и добавить archive-секцию. + +Минимум: + +```properties +archive.publish.enabled=true +archive.publish.time=00:00 +archive.publish.zoneId=Europe/Warsaw +archive.workDir=data/archive +archive.maxFileBytes=4000000000 + +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath=/home/player/SHiNE/secrets/archive-arweave-wallet.json +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 + +archive.solana.rootKeyPath=/home/player/SHiNE/secrets/server-root.key +archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key +archive.solana.confirmPollSeconds=5 +archive.solana.confirmTimeoutMinutes=30 +archive.solana.commitment=finalized +``` + +`archive.publish.zoneId` выбрать осознанно. Если оставить пустым, используется timezone JVM/машины. Для ежедневного запуска ровно в нужную локальную полночь лучше задать ZoneId явно. + +### Solana RPC + +**Отдельного archive RPC нет.** Writer использует: + +1. `solana.users.sync.rpcUrl`, если он задан; +2. иначе `solana.rpcUrl`. + +Поэтому существующий рабочий RPC не дублировать в archive settings. + +--- + +# 7. Убедиться, что server login соответствует ключам + +`server.SHiNE.login` должен быть тем User PDA, чей archive head будет обновляться. + +На startup `SolanaArchiveHeadWriter` проверяет: + +```text +derive(root private) == UserPDA.root_key +derive(client private) == UserPDA.client_key +``` + +При несовпадении archive publisher не стартует. + +--- + +# 8. Развернуть JAR + +Можно использовать существующий `deploy/scripts/deploy_server.sh`. Он: + +- собирает `shadowJar`; +- копирует JAR; +- создаёт/обновляет systemd unit; +- перезапускает сервис. + +Типовой запуск задаётся уже существующими переменными проекта. Либо вручную заменить `shine-server.jar` в рабочей директории и перезапустить systemd service. + +После deploy убедиться, что WorkingDirectory содержит внешний `application.properties`. + +--- + +# 9. Первый startup + +Смотреть лог: + +```bash +sudo journalctl -u -f +``` + +Ожидаемые события: + +1. DB migration до v22; +2. обычный Solana users sync; +3. обычный server-to-server blockchain sync; +4. archive publisher preflight; +5. строка примерно: + +```text +Archive publisher включён: login=... dir=data/archive dailyAt=00:00 zone=... +Следующая архивная публикация запланирована на ... +``` + +Если остался незавершённый job, он будет продолжен **сразу после старта**, не ожидая полуночи. Новый snapshot создаётся только по расписанию. + +--- + +# 10. Что произойдёт в первую полночь + +Если `archive_chain_cursor` пуст: + +- сервер проходит все локально известные `blockchain_name`; +- для каждой берёт range `0..current_head`; +- создаёт первый big block `#1`; +- для каждой blockchain создаёт максимум один `UserBlockchainChunk`; +- внутри chunk лежат все её raw records из frozen range; +- backlink первого chunk пустой (`previous_big_block_ref = 0xFFFFFFFF`); +- создаёт локальный файл, например: + +```text +data/archive/archive01.00001.12.09.26.tmp.SHiNE-archive +``` + +- загружает его в Arweave; +- после успешной загрузки переименовывает тот же файл, например: + +```text +data/archive/archive01.00001.12.09.26..SHiNE-archive +``` + +- ждёт confirmations; +- обычным `update_user_pda` записывает block type `100`; +- ждёт Solana `finalized`; +- только затем commit-ит cursors. + +Если новых данных нет, пустой big block не создаётся. + +--- + +# 11. Проверка результата + +## Локальные файлы + +```bash +ls -lah data/archive/ +``` + +После успешного upload `.tmp.SHiNE-archive` для завершённого job оставаться не должен; должен быть файл с реальным TX ID в имени. + +## Job DB + +```sql +SELECT id, big_block_number, status, created_at_ms, local_archive_path, + arweave_confirmations, solana_signature, error_text +FROM archive_publish_job +ORDER BY id DESC +LIMIT 10; +``` + +Успех: + +```text +status = CURSORS_COMMITTED +``` + +## Cursors + +```sql +SELECT blockchain_name, + last_archived_source_block_number, + last_archive_big_block_number, + last_chunk_offset, + last_chunk_size +FROM archive_chain_cursor +ORDER BY blockchain_name; +``` + +## Локальная проекция User PDA + +После очередной Solana sync: + +```sql +SELECT login, record_number, archive_head_tx_id, archive_head_hash +FROM solana_user_pda_current +WHERE login = ''; +``` + +`archive_head_tx_id` и `archive_head_hash` должны быть непустыми. + +## Arweave + +TX берётся прямо из имени финального файла. Проверить: + +```bash +curl -sS 'https://arweave.net/tx//status' +``` + +--- + +# 12. Быстрый тест до полуночи + +Если не хочется ждать 00:00, на тестовом сервере временно установить `archive.publish.time` на ближайшие 5–10 минут в будущем в выбранной `archive.publish.zoneId`, затем перезапустить сервис. + +После проверки вернуть: + +```properties +archive.publish.time=00:00 +``` + +Не использовать интервал в минутах: scheduler специально работает по календарному локальному времени один раз в сутки. + +--- + +# 13. Откат / выключение + +Самый безопасный функциональный rollback: + +```properties +archive.publish.enabled=false +``` + +и рестарт сервера. Тогда обычная серверная работа продолжается, archive scheduler ничего не публикует. + +Не удалять `archive_chain_cursor`/job таблицы без причины: они нужны, чтобы после повторного включения publisher продолжил дельту, а не загрузил всю историю заново. + +Уже опубликованные Arweave данные являются постоянными и обычным rollback сервера не удаляются. + +--- + +# 14. Наиболее вероятные ошибки + +### `Root key archive publisher-а не совпадает с User PDA` +Положен неправильный root key или неверный `server.SHiNE.login`. + +### `Client key archive publisher-а не совпадает с User PDA` +Неверный client key. + +### `Недостаточно AR` +Пополнить Arweave wallet. Первый архив может быть существенно больше ежедневных дельт. + +### Arweave upload прошёл, Solana update не прошёл +Не удалять локальный файл/job. После исправления RPC/program/key причины restart продолжит незавершённый job. + +### `archive cursor hash не совпадает` +Локальная blockchain изменилась относительно уже зафиксированного cursor. Не форсировать публикацию; сначала разобраться с resync/fork. + +### Старый `shine_users` +Если новый update payload отклоняется программой, проверить, что целевая Solana `shine_users` действительно обновлена этой версией. diff --git a/docs/Archive/04_TEST_AND_OPERATIONS.md b/docs/Archive/04_TEST_AND_OPERATIONS.md new file mode 100644 index 00000000..7666ec5c --- /dev/null +++ b/docs/Archive/04_TEST_AND_OPERATIONS.md @@ -0,0 +1,86 @@ +# Проверка и эксплуатация Archive Publisher + +## Перед ночным тестом + +- [ ] Новый `shine_users` уже развёрнут на том Solana-кластере, который использует сервер. +- [ ] Серверный JAR собран из этого пакета. +- [ ] БД забэкаплена. +- [ ] `archive.publish.enabled=true`. +- [ ] `archive.publish.time=00:00`. +- [ ] `archive.publish.zoneId` соответствует желаемой локальной полуночи. +- [ ] `server.SHiNE.login` соответствует root/client keys. +- [ ] Arweave JWK читается пользователем процесса. +- [ ] На Arweave wallet достаточно AR. +- [ ] `data/archive` доступна на запись. +- [ ] В логе есть `Archive publisher включён` и точное время следующего запуска. + +## Во время job + +Нормальная последовательность логов/статусов: + +```text +SNAPSHOT_CREATED +FILE_BUILT +ARWEAVE_UPLOADED +ARWEAVE_CONFIRMED +SOLANA_SUBMITTED +SOLANA_FINALIZED +CURSORS_COMMITTED +``` + +После `FILE_BUILT` существует `.tmp.SHiNE-archive`. +После полного Arweave upload имя уже содержит настоящий TX ID. + +## После успешного первого job + +Проверить: + +```bash +find data/archive -maxdepth 1 -type f -name '*.SHiNE-archive' -ls +``` + +```sql +SELECT big_block_number, status, local_archive_path, arweave_confirmations +FROM archive_publish_job ORDER BY id DESC LIMIT 1; +``` + +```sql +SELECT count(*) AS archived_blockchains FROM archive_chain_cursor; +``` + +```sql +SELECT login, archive_head_tx_id, archive_head_hash +FROM solana_user_pda_current +WHERE login=''; +``` + +## Проверка второй публикации + +До следующей полуночи добавить несколько новых SHiNE records только в часть blockchain. После следующего job: + +- в новый big block должны попасть только изменившиеся blockchain; +- одна blockchain в новом big block должна иметь один chunk независимо от числа новых records; +- cursor blockchain, которая не изменилась, должен остаться на старом big block/chunk; +- backlink изменившегося chunk должен указывать на предыдущий chunk этой же blockchain; +- FULL reference table нового big block должна содержать все предыдущие finalized big blocks. + +## Crash/restart сценарии + +### Restart после FILE_BUILT +Должен использоваться тот же frozen job и тот же локальный файл. + +### Restart после ARWEAVE_UPLOADED +Не должно быть повторной оплаты/upload. Если TX сохранён, но rename не успел произойти, recovery переименует `.tmp` в имя с TX ID. + +### Restart после SOLANA_FINALIZED +При совпадении PDA head с job сервер должен только commit cursors. + +## Обычный сервер без публикации + +Проверить отдельно: + +```properties +archive.publish.enabled=false +``` + +Сервер должен запускаться без Arweave/root/client archive key files и не создавать `archive_publish_job`. diff --git a/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md b/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md new file mode 100644 index 00000000..9e461b61 --- /dev/null +++ b/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md @@ -0,0 +1,60 @@ +# Состав patch-пакета и удаление legacy-файлов + +ZIP `SHINE_archive_changed_files_with_docs.zip` предназначен для распаковки **поверх исходного дерева той версии сервера, из которой он был сделан**. Внутри находятся только новые и изменённые файлы, пути сохранены относительно корня репозитория. + +## Важное ограничение ZIP-overlay + +Распаковка ZIP может добавить/заменить файлы, но не удалит старые. Поэтому после распаковки нужно удалить legacy test-free-avatar исходники ниже. Они больше не зарегистрированы в `JsonHandlerRegistry`, однако физическое удаление сохраняет дерево в точном состоянии новой версии и не оставляет старый тестовый Arweave-код рядом с production archive publisher. + +## Удалить после распаковки + +```text +SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java +SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java +SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java +docs/API/14_Test_Free_Avatar_Upload_API.md +``` + +Linux-команда из корня репозитория: + +```bash +rm -f \ + 'SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java' \ + 'SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java' \ + 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java' \ + 'docs/API/14_Test_Free_Avatar_Upload_API.md' +``` + +## Что пакет принципиально добавляет + +- новый Gradle module `shine-server-archive`; +- migration/schema v22; +- archive state DAO/entities; +- server startup scheduler; +- Arweave chunk uploader; +- Solana User PDA writer; +- PDA block type `100` в Rust/Java/JS; +- обновлённую документацию формата User PDA; +- отдельную папку `docs/Archive/` с полным protocol/deploy/runbook. + +## После overlay + удаления + +Минимально проверить: + +```bash +./gradlew shadowJar +``` + +И отдельно собрать/задеплоить изменённую Solana `shine_users` согласно `03_DEPLOY_TEST_SERVER.md`. diff --git a/docs/Archive/06_FILE_MANIFEST.md b/docs/Archive/06_FILE_MANIFEST.md new file mode 100644 index 00000000..c2a81b85 --- /dev/null +++ b/docs/Archive/06_FILE_MANIFEST.md @@ -0,0 +1,73 @@ +# Manifest changed/new files + +Основа сравнения: последний исходный ZIP пользователя, на который рассчитан этот пакет. + +- Изменённых файлов: 20 +- Новых файлов до добавления этого manifest: 25 +- Удаляемых legacy-файлов: 10 + +## Изменённые файлы + +- `SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java` +- `SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java` +- `SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java` +- `SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java` +- `SHiNE-server/src/main/java/server/ws/WsServer.java` +- `SHiNE-server/src/main/resources/application.properties` +- `build.gradle` +- `docs/API/09_Operations_Index.md` +- `docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md` +- `docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md` +- `docs/Solana/user_pda/README.md` +- `docs/Solana_Architecture/details/shine_users.md` +- `settings.gradle` +- `shine-UI/js/services/auth-service.js` +- `shine-UI/js/services/shine-user-pda-service.js` +- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` +- `shine-solana/shine/doc/programs/shine_users.md` +- `shine-solana/shine/programs/shine_users/src/lib.rs` + +## Новые файлы + +- `SHiNE-server/shine-server-archive/build.gradle` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java` +- `SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java` +- `SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java` +- `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql` +- `docs/Archive/01_PROTOCOL_v1.0.md` +- `docs/Archive/02_IMPLEMENTATION_MAP.md` +- `docs/Archive/03_DEPLOY_TEST_SERVER.md` +- `docs/Archive/04_TEST_AND_OPERATIONS.md` +- `docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md` +- `docs/Archive/README.md` +- `docs/Archive/archive-publisher.example.properties` + +## Legacy-файлы, которые ZIP сам не удаляет + +- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java` +- `docs/API/14_Test_Free_Avatar_Upload_API.md` + +Подробная команда удаления находится в `05_PATCH_CONTENTS_AND_REMOVALS.md`. diff --git a/docs/Archive/README.md b/docs/Archive/README.md new file mode 100644 index 00000000..743c9bf8 --- /dev/null +++ b/docs/Archive/README.md @@ -0,0 +1,28 @@ +# SHiNE Archive Publisher — документация + +Эта папка — **актуальная точка входа** для механизма серверной архивации SHiNE в Arweave с фиксацией archive head в Solana User PDA. + +Если задачу выполняет другая нейронка/агент, читать документы нужно в таком порядке: + +1. `01_PROTOCOL_v1.0.md` — бинарный формат `SHINE-ARCHIVE`, big-block references, `UserBlockchainChunk`, подписи, PDA block `100`, crash-safety. +2. `02_IMPLEMENTATION_MAP.md` — как спецификация разложена по Java/Rust/JS/SQL файлам текущего проекта. +3. `03_DEPLOY_TEST_SERVER.md` — полный порядок установки на тестовый сервер, включая обязательный апгрейд `shine_users`, конфиг, ключи, сборку и запуск. +4. `04_TEST_AND_OPERATIONS.md` — что проверять до полуночи, после полуночи и при сбоях. +5. `05_PATCH_CONTENTS_AND_REMOVALS.md` — какие файлы содержит пакет и какие legacy test-free-avatar файлы нужно удалить при наложении ZIP поверх старого исходника. +6. `archive-publisher.example.properties` — минимальный конфиг архиватора. + +## Коротко + +- Архиватор **по умолчанию выключен**: `archive.publish.enabled=false`. +- При включении создаёт новый snapshot **один раз в сутки в заданное локальное время**, по умолчанию `00:00`. +- При первом успешном запуске, когда архивных курсоров ещё нет, в первый big block попадает **всё локально известное состояние всех blockchain, начиная с source block 0**. +- Далее публикуется только дельта. +- Один `blockchain_name` в одном big block представлен максимум одним `UserBlockchainChunk`; внутри него лежат все новые raw SHiNE records этой цепочки. +- В конце chunk одна ссылка на предыдущий chunk этой же blockchain. Если blockchain в текущем big block отсутствует, её cursor/head не меняется. +- Готовый файл сначала существует локально как `.<00001>..tmp.SHiNE-archive`. После успешной загрузки в Arweave он переименовывается в `.<00001>...SHiNE-archive` и остаётся локально. +- После Arweave confirmations обычным `update_user_pda` обновляется PDA block type `100`: `archive_tx_id[32] + archive_hash[32]`. +- Cursor commit выполняется только после Solana `finalized`. + +## Важно перед тестом + +Изменён формат/парсер `shine_users`. **Нельзя просто заменить серверный JAR и включить archive publisher, если целевая Solana-программа `shine_users` ещё не обновлена кодом из этого пакета.** Сначала обновить программу на нужном кластере, затем сервер. diff --git a/docs/Archive/archive-publisher.example.properties b/docs/Archive/archive-publisher.example.properties new file mode 100644 index 00000000..f9190f38 --- /dev/null +++ b/docs/Archive/archive-publisher.example.properties @@ -0,0 +1,23 @@ +# Минимальный пример для archive-capable сервера. +# Добавлять во внешний application.properties; существующие DB/Solana/server настройки не удалять. + +archive.publish.enabled=true +archive.publish.time=00:00 +archive.publish.zoneId=Europe/Warsaw +archive.workDir=data/archive +archive.maxFileBytes=4000000000 + +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath=/home/player/SHiNE/secrets/archive-arweave-wallet.json +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 + +# Отдельный archive Solana RPC НЕ задаётся. +# Используется solana.users.sync.rpcUrl, иначе solana.rpcUrl. +archive.solana.rootKeyPath=/home/player/SHiNE/secrets/server-root.key +archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key +# Эти файлы могут содержать Base58 seed 32 bytes или Base58 Solana secret key 64 bytes. +archive.solana.confirmPollSeconds=5 +archive.solana.confirmTimeoutMinutes=30 +archive.solana.commitment=finalized diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md index 52a0afd2..ce7950e2 100644 --- a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md +++ b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md @@ -1,113 +1,333 @@ # SHiNE Archive Protocol v1.0 -## Полная русская спецификация серверной фиксации больших блоков, Arweave, Solana User PDA и навигации по предыдущим записям пользователя +## Полная спецификация серверной архивации SHiNE-блоков в Arweave и фиксации головы архива в Solana -**Статус:** рабочая спецификация v1.0 -**Формат:** бинарный +**Статус:** согласованная спецификация v1.0 +**Язык:** русский **Magic:** `SHINE-ARCHIVE` -**Версия:** два отдельных байта `major` / `minor` +**Версия протокола:** `1.0` +**Endian:** big-endian для всех фиксированных целых чисел --- # 1. Назначение -Протокол предназначен для периодической долговременной фиксации новых SHiNE-блоков. +Этот документ описывает механизм, по которому SHiNE-сервер периодически: -Стартовая схема: +1. синхронизирует обычные SHiNE-блоки существующим механизмом; +2. определяет новые локальные блоки, ещё не попавшие в архив; +3. фиксирует неизменяемую дельту; +4. группирует все новые записи одной `blockchain_name` в один блок пользователя внутри большого архивного блока; +5. добавляет к каждому такому пользовательскому блоку одну ссылку на предыдущий пользовательский блок этой же `blockchain_name`; +6. формирует один большой бинарный блок `SHINE-ARCHIVE`; +7. загружает его в Arweave; +8. ждёт закрепления/подтверждения Arweave-транзакции; +9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; +10. только после Solana `finalized` переводит локальные курсоры на новую точку. + +На первом этапе предполагается один архивный Raspberry-сервер, но формат изначально не должен запрещать появление других архиваторов. + +--- + +# 2. Основные понятия + +## 2.1. Исходный SHiNE-блок / запись + +Это существующие в серверной БД оригинальные `block_bytes`, относящиеся к конкретной `blockchain_name`. + +Они уже содержат пользовательские данные и существующую криптографию SHiNE. + +Архиватор: + +- не изменяет их; +- не переподписывает их; +- не переводит их в Base64; +- не пересобирает их содержимое. + +## 2.2. `blockchain_name` + +В архивном формате основной идентификатор пользовательской цепочки — именно `blockchain_name`. + +Отдельно хранить `login` для пользовательского блока не требуется. + +`blockchain_name` уже однозначно идентифицирует конкретную пользовательскую blockchain и логически включает пользовательскую идентичность + номер/вариант цепочки. + +## 2.3. Большой архивный блок + +Один файл/объект `SHINE-ARCHIVE`, который сервер формирует за один цикл архивной публикации. + +Он содержит: + +- header; +- список предыдущих больших архивных блоков; +- по одному `UserBlockchainChunk` для каждой `blockchain_name`, у которой есть новые записи; +- footer; +- SHA-256 большого блока; +- подпись сервера, который этот большой блок сформировал и закрыл. + +Один большой архивный блок после публикации соответствует одной Arweave-транзакции. + +## 2.4. `UserBlockchainChunk` + +В одном большом архивном блоке для одной `blockchain_name` существует максимум один такой chunk. + +Если за текущую дельту у пользователя/цепочки накопилось 1, 3, 5 или 20 записей, все они помещаются внутрь одного `UserBlockchainChunk`. + +Навигационная ссылка одна на весь chunk. + +--- + +# 3. Главная модель пользовательской истории + +Допустим `alice-001` имела записи: ```text -Основной SHiNE-сервер - | - | существующая межсерверная синхронизация - v -Raspberry / архивный SHiNE-сервер - | - +--> видит все новые локальные SHiNE-блоки - +--> фиксирует стабильную дельту - +--> собирает большой SHINE-ARCHIVE блок - +--> загружает его в Arweave - +--> ждёт закрепления Arweave-транзакции - +--> обычным update своего User PDA записывает новый archive head +BigBlock 70: + AliceChunk = 2 записи + +BigBlock 81: + AliceChunk = 5 записей + +BigBlock 95: + AliceChunk = 3 записи + +BigBlock 100: + AliceChunk = 4 записи ``` -Существующую межсерверную синхронизацию в v1 менять не требуется. +Тогда история `alice-001` выглядит: -Архивный сервер не создаёт и не переподписывает пользовательские сообщения. Он берёт уже существующие пользовательские записи и добавляет только серверную навигационную метаинформацию большого блока. +```text +BigBlock 100 + AliceChunk + 4 записи + | + +--> PreviousBlockchainChunkRef + | + v +BigBlock 95 + AliceChunk + 3 записи + | + +--> PreviousBlockchainChunkRef + | + v +BigBlock 81 + AliceChunk + 5 записей + | + v +BigBlock 70 +``` + +Навигация идёт пачками записей одной blockchain, а не по одной записи за раз. --- -# 2. Основные принципы +# 4. Сервер сам добавляет навигацию -1. Публикуется только новая дельта. -2. Дельта замораживается до начала загрузки. -3. Новые данные, пришедшие во время публикации, попадают в следующий большой блок. -4. После загрузки сервер ждёт закрепления Arweave-транзакции. -5. Только после закрепления Arweave сервер обновляет свой User PDA в Solana. -6. Только после `finalized` в Solana локальные курсоры считаются окончательно сдвинутыми. -7. Большой блок имеет собственный SHA-256 и подпись составившего его SHiNE-аккаунта. -8. Каждый большой блок содержит список предыдущих больших блоков с номером, hash и Arweave TX ID. -9. В v1 в начало каждого нового большого блока записывается полный список всех известных предыдущих больших блоков данной ветки. -10. Для каждого пользователя сервер строит обратную цепочку не на одну предыдущую запись, а на **предыдущий большой блок, где были записи этого пользователя, плюс список всех его записей внутри того блока**. +Пользователь не обязан знать: + +- в какой большой архивный блок попадут его записи; +- какой будет Arweave TX ID; +- какие будут offsets; +- где находится предыдущий chunk. + +Все эти данные сервер добавляет при формировании большого блока. + +Исходные подписанные пользовательские `block_bytes` остаются неизменными. + +`PreviousBlockchainChunkRef` является внешней серверной архивной метаинформацией и защищается hash + подписью всего большого архивного блока. --- -# 3. Ключи архивного сервера +# 5. Необходимые ключи архивного сервера -Для первой реализации серверу требуются: +Архивный Raspberry/server хранит: -- `root private key` SHiNE-аккаунта; -- `client private key` SHiNE-аккаунта; -- Arweave JWK/private wallet key. +## 5.1. SHiNE root private key -`blockchain private key` для самой архивной публикации не требуется: пользовательские записи уже существуют и уже подписаны. +Используется: -В v1 большой блок может подписываться root key того SHiNE-аккаунта, который его сформировал. +- для подписания новой версии User PDA при обычном `update_user_pda`; +- в v1.0 — для подписи закрытого большого архивного блока. + +## 5.2. SHiNE client private key + +Используется как Solana transaction signer / fee payer в соответствии с текущей логикой `shine_users`. + +## 5.3. Arweave JWK / private wallet key + +Используется для загрузки и оплаты Arweave-транзакции. + +## 5.4. Blockchain private key + +Для архивной публикации не требуется. + +Архиватор не создаёт новые пользовательские blockchain-блоки. --- -# 4. Локальное состояние дельты +# 6. Расписание публикации + +Архивная публикация запускается один раз в сутки в заданное локальное время. Она НЕ использует интервал «каждые N минут». + +Пример: + +```properties +archive.publish.enabled=true +archive.publish.time=00:00 +archive.publish.zoneId= +``` + +Для v1.0 значение по умолчанию: + +```text +00:00 +``` + +То есть новый snapshot и новый большой архивный блок создаются один раз в сутки в полночь. + +Если `archive.publish.zoneId` пуст, используется системная timezone сервера. При необходимости её можно задать явно, например `Europe/Warsaw`. Это сохраняет публикацию ровно в указанное локальное время даже при переходах летнего/зимнего времени. + +Незавершённый archive job после рестарта не ждёт следующей полуночи: сервер продолжает именно его сразу. Новый snapshot при старте вне назначенного времени не создаётся. + +## 6.1. Первая архивная публикация + +Если у данного archive publisher ещё нет подтверждённых архивных курсоров, первая публикация берёт ВСЁ локально известное состояние: + +```text +для каждой blockchain_name: +source block 0 .. current local head +``` + +То есть первый большой архивный блок содержит все SHiNE-блоки, которые сервер успел узнать к моменту первого суточного snapshot. После успешной публикации следующие большие блоки содержат только дельту относительно подтверждённых курсоров. + +## 6.2. Локальная папка и имена файлов + +Перед любой сетевой загрузкой большой блок сначала полностью создаётся на локальном диске. По умолчанию каталог: + +```text +data/archive/ +``` + +До получения Arweave TX ID файл имеет временное имя: + +```text +.<00001>.<дд.мм.гг>.tmp.SHiNE-archive +``` + +Например: + +```text +archive01.00001.11.09.26.tmp.SHiNE-archive +``` + +Дата — реальная дата создания/freeze snapshot большого блока в timezone archive publisher-а. Номер имеет минимальную ширину 5 цифр. Пять цифр — форматирование, а не лимит: блок `100000` получает шестизначный номер. + +После успешной загрузки Arweave возвращает реальный TX ID. Сервер сначала надёжно сохраняет TX ID в БД, затем атомарно переименовывает тот же локальный файл в: + +```text +.<00001>.<дд.мм.гг>..SHiNE-archive +``` + +Например: + +```text +archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive +``` + +Поле `` — не слово `trx`, а настоящий Base64URL TX ID загруженного объекта в Arweave. Финальный файл остаётся локально как постоянная копия. + +Crash recovery обязан продолжать работу с этим же файлом. Если TX ID уже сохранён, но процесс упал до rename, при следующем запуске сервер вычисляет финальное имя из сохранённого TX ID и завершает переименование без повторной сборки дельты. + +--- + +# 7. Состояние, относительно которого считается дельта Нужно различать: 1. уже окончательно опубликованное состояние; -2. текущую замороженную дельту, которая ещё проходит Arweave/Solana. +2. текущий замороженный job, который ещё проходит Arweave/Solana. -Рекомендуются три таблицы. +Нельзя считать дельту от живой головы во время уже запущенной публикации. -## 4.1. `archive_chain_cursor` +--- + +# 8. Таблица `archive_chain_cursor` + +Хранит окончательно подтверждённое архивное состояние для каждой `blockchain_name`. ```sql CREATE TABLE archive_chain_cursor ( - blockchain_name TEXT PRIMARY KEY, - last_archived_block_number BIGINT NOT NULL, - last_archived_block_hash BYTEA NOT NULL, - updated_at_ms BIGINT NOT NULL + blockchain_name TEXT PRIMARY KEY, + + last_archived_source_block_number BIGINT NOT NULL, + last_archived_source_block_hash BYTEA NOT NULL, + + last_archive_big_block_number BIGINT, + last_archive_big_block_hash BYTEA, + + last_chunk_offset BIGINT, + last_chunk_size BIGINT, + + updated_at_ms BIGINT NOT NULL ); ``` -Она хранит точку, до которой конкретная локальная blockchain уже окончательно вошла в опубликованный и зафиксированный archive head. +Назначение: -## 4.2. `archive_publish_job` +- `last_archived_source_block_number` — до какого исходного SHiNE-блока этой blockchain всё успешно опубликовано; +- `last_archived_source_block_hash` — hash этого исходного блока; +- `last_archive_big_block_number` и `last_archive_big_block_hash` — в каком большом `SHINE-ARCHIVE` находится последний chunk этой blockchain; +- `last_chunk_offset` и `last_chunk_size` — точное положение последнего chunk внутри большого блока. + +Этого достаточно, чтобы следующая публикация создала один backlink на предыдущий chunk. + +--- + +# 9. Отдельный индекс по каждой записи не нужен + +Поскольку все новые записи одной `blockchain_name` агрегируются в один `UserBlockchainChunk`, не нужно хранить offsets каждой отдельной записи в истории. + +Для следующей публикации достаточно помнить: + +```text +предыдущий big block +предыдущий big block hash +chunk offset +chunk size +``` + +--- + +# 10. Таблица `archive_publish_job` + +Хранит crash-safe состояние одной архивной публикации. ```sql CREATE TABLE archive_publish_job ( - id BIGSERIAL PRIMARY KEY, - archive_number BIGINT NOT NULL, - status TEXT NOT NULL, - parent_arweave_tx_id BYTEA, - parent_archive_hash BYTEA, - created_at_ms BIGINT NOT NULL, - local_archive_path TEXT, - archive_hash BYTEA, - arweave_tx_id BYTEA, - arweave_confirmations INTEGER, - solana_signature TEXT, - error_text TEXT, - updated_at_ms BIGINT NOT NULL + id BIGSERIAL PRIMARY KEY, + + big_block_number BIGINT NOT NULL, + status TEXT NOT NULL, + + created_at_ms BIGINT NOT NULL, + + local_archive_path TEXT, + + archive_hash BYTEA, + arweave_tx_id BYTEA, + + arweave_confirmations INTEGER, + solana_signature TEXT, + + error_text TEXT, + updated_at_ms BIGINT NOT NULL ); ``` -Рекомендуемые состояния: +Рекомендуемые статусы: ```text SNAPSHOT_CREATED @@ -120,716 +340,675 @@ CURSORS_COMMITTED FAILED ``` -## 4.3. `archive_publish_job_chain` +Завершённые rows этой таблицы одновременно дают локальную историю больших блоков: + +```text +big_block_number +archive_hash +arweave_tx_id +``` + +По ним строится FULL-список предыдущих больших блоков следующей публикации. + +--- + +# 11. Таблица `archive_publish_job_chain` + +Хранит frozen range каждой blockchain в текущем job и координаты создаваемого chunk. ```sql CREATE TABLE archive_publish_job_chain ( - job_id BIGINT NOT NULL, - blockchain_name TEXT NOT NULL, - from_block_number BIGINT NOT NULL, - to_block_number BIGINT NOT NULL, - previous_block_hash BYTEA NOT NULL, - last_block_hash BYTEA NOT NULL, + job_id BIGINT NOT NULL, + blockchain_name TEXT NOT NULL, + + from_source_block_number BIGINT NOT NULL, + to_source_block_number BIGINT NOT NULL, + + previous_source_block_hash BYTEA NOT NULL, + last_source_block_hash BYTEA NOT NULL, + + previous_archive_big_block_number BIGINT, + previous_archive_big_block_hash BYTEA, + previous_chunk_offset BIGINT, + previous_chunk_size BIGINT, + + new_chunk_offset BIGINT, + new_chunk_size BIGINT, + PRIMARY KEY (job_id, blockchain_name) ); ``` -Сырые блоки в staging-таблицы копировать не нужно. В job фиксируются только точные диапазоны и крайние hash. - --- -# 5. Заморозка дельты +# 12. Заморозка дельты Пример: ```text -локально сейчас: -Alice = 180 -Bob = 94 +локально: +alice-001 = source block 180 +bob-001 = source block 94 -уже опубликовано: -Alice = 160 -Bob = 90 +опубликовано: +alice-001 = 160 +bob-001 = 90 ``` Новый job фиксирует: ```text -Alice: 161..180 -Bob: 91..94 +alice-001: 161..180 +bob-001: 91..94 ``` -Если во время Arweave upload локальное состояние стало: +Если во время загрузки появятся: ```text -Alice = 185 -Bob = 97 +alice-001 181..185 +bob-001 95..97 ``` -текущий job НЕ меняется. - -Следующая публикация начнётся с: - -```text -Alice: 181..185 -Bob: 95..97 -``` +они попадут только в следующий job. --- -# 6. Общая структура большого блока +# 13. Проверка курсора перед публикацией -Большой опубликованный файл одновременно является: - -- архивной дельтой; -- индексом предыдущих больших блоков; -- контейнером пользовательских записей; -- навигационным узлом для истории каждого пользователя; -- подписанным объектом того SHiNE-аккаунта, который его сформировал. - -Структура: +Перед созданием frozen range сервер проверяет: ```text -+------------------------------------------------+ -| PREFIX / HEADER | -| magic = SHINE-ARCHIVE | -| version major/minor | -| размеры/счётчики | -| номер большого блока | -| время | -| LOGIN составителя | -| режим таблицы предыдущих блоков | -| число предыдущих блоков | -+------------------------------------------------+ -| PREVIOUS BLOCK REFERENCES TABLE | -| | -| block_number + block_hash + arweave_tx_id | -| block_number + block_hash + arweave_tx_id | -| ... | -+------------------------------------------------+ -| USER RECORDS / SERVER NAVIGATION METADATA | -| ... | -+------------------------------------------------+ -| FOOTER | -| LOGIN закрывшего блок | -| block_hash | -| signature | -+------------------------------------------------+ +local hash(last_archived_source_block_number) +== +archive_chain_cursor.last_archived_source_block_hash ``` -Логин в header и footer должен быть одинаковым. - -Дублирование логина намеренное: - -- header позволяет сразу при скачивании начала файла увидеть, кто составил блок; -- footer делает закрытие блока человекочитаемым рядом с hash и подписью. +При несовпадении публикация этой chain останавливается до resync/fork handling. --- -# 7. Magic и версия +# 14. Ограничение размера большого блока -Magic: +Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. + +Поэтому один большой archive-файл MUST быть меньше `4 GiB`. + +Рекомендуемый запас: + +```properties +archive.maxFileBytes=4000000000 +``` + +Если собранный frozen job превышает этот лимит, v1.0 останавливает публикацию с явной ошибкой `ArchiveTooLargeException` и не двигает курсоры. Практически лимит очень велик; для такого сервера следует уменьшить объём данных между суточными закрытиями или реализовать деление snapshot на несколько big blocks. Автоматическое деление одного snapshot на несколько big blocks оставлено как совместимое будущее расширение. + +--- + +# 15. Magic и версия + +Файл начинается ASCII: ```text SHINE-ARCHIVE ``` -Это ровно 13 ASCII-байт: +Размер: 13 байт. -```text -53 48 49 4E 45 2D 41 52 43 48 49 56 45 -``` - -Версия: +Далее: ```text u8 version_major u8 version_minor ``` -Для этой спецификации: +Для v1.0: ```text 1 0 ``` -то есть `v1.0`. - --- -# 8. Порядок байтов - -Все fixed-size integer-поля v1 хранятся в: +# 16. Общий layout большого блока ```text -big-endian ++------------------------------------------+ +| FIXED HEADER | ++------------------------------------------+ +| creator_login | ++------------------------------------------+ +| PREVIOUS BIG BLOCK REFERENCES | +| ref[0] | +| ref[1] | +| ... | ++------------------------------------------+ +| UserBlockchainChunk #1 | +| UserBlockchainChunk #2 | +| ... | ++------------------------------------------+ +| FOOTER: closer_login | +| block_hash | +| signature | ++------------------------------------------+ ``` --- -# 9. Header большого блока v1.0 +# 17. Fixed Header v1.0 -Header должен быть устроен так, чтобы клиент мог сначала скачать только небольшой префикс файла, узнать размер всей начальной индексной области и затем докачать только её. +Все integers — big-endian. -Формат: +| Поле | Тип | Размер | +|---|---:|---:| +| magic | bytes[13] | 13 | +| version_major | u8 | 1 | +| version_minor | u8 | 1 | +| header_size | u32 | 4 | +| big_block_number | u32 | 4 | +| created_at_ms | u64 | 8 | +| creator_login_length | u8 | 1 | +| references_mode | u8 | 1 | +| references_count | u32 | 4 | +| reference_entry_size | u16 | 2 | +| parent_reference_index | u32 | 4 | +| blockchain_chunks_count | u32 | 4 | +| total_user_records_count | u32 | 4 | + +После fixed header немедленно идут `creator_login` bytes. + +`header_size` — byte offset первого `PreviousBigBlockReference`. + +--- + +# 18. `creator_login` + +SHiNE login пользователя/сервера, который сформировал большой блок. ```text -bytes[13] magic = "SHINE-ARCHIVE" - -u8 version_major -u8 version_minor - -u32 header_size -u32 block_number -u64 created_at_ms - -u8 creator_login_length -bytes[N] creator_login UTF-8 - -u8 references_mode -u32 references_count -u16 reference_entry_size - -u32 records_count +u8 creator_login_length +bytes[N] creator_login UTF-8 ``` -## 9.1. `header_size` +Этот login находится в начале файла, поэтому его можно узнать без скачивания payload. -`header_size` — абсолютный byte offset от начала файла до первого байта области пользовательских записей. +--- -То есть включает: - -```text -header prefix -+ creator_login -+ всю таблицу PreviousBlockReference -``` - -Клиент может: - -1. скачать первые условные 64–128 байт; -2. прочитать `header_size`; -3. докачать `0 .. header_size-1`; -4. уже иметь все адреса предыдущих больших блоков; -5. после этого скачивать только нужные user records. - -## 9.2. `block_number` +# 19. `big_block_number` ```text u32 ``` -Номер большого итогового блока данной ветки. - -Диапазон: +Рекомендуемая нумерация опубликованных больших блоков: ```text -0 .. 4 294 967 295 +first = 1 +next = 2 +next = 3 +... ``` -Для предполагаемой частоты закрытия это более чем достаточный запас. +Первый реально публикуемый большой блок имеет номер `1`, поэтому его локальное имя содержит `00001`. Неудачная незавершённая попытка не становится частью опубликованной archive-цепочки. -## 9.3. `created_at_ms` +--- + +# 20. `created_at_ms` ```text u64 ``` -Unix Epoch UTC в миллисекундах. +Unix Epoch UTC milliseconds. -Это время фиксации snapshot текущего большого блока. +Это время заморозки содержимого big block. -## 9.4. `creator_login` +--- -Логин SHiNE-пользователя/сервера, который сформировал этот большой блок. +# 21. Режим ссылок на предыдущие большие блоки + +```text +references_mode u8 +``` + +Значения: + +```text +0 = FULL +1 = PARTIAL, зарезервировано на будущее +``` + +Writer v1.0 MUST использовать `FULL`. + +--- + +# 22. FULL previous-block table + +Новый big block содержит ссылки на ВСЕ предыдущие большие блоки своей ветки. Пример: ```text -raspberry-archive +BigBlock #365 + +References: +#1 +#2 +#3 +... +#364 ``` -Это поле находится именно в header, поэтому клиент видит автора уже после скачивания начала блока. +При одном big block в день через год таблица занимает примерно 24.8 KiB. -`creator_login` входит в hash большого блока и тем самым криптографически привязан к его подписи. +--- -## 9.5. `references_mode` +# 23. `PreviousBigBlockReference` + +Фиксированный размер: 68 байт. + +| Поле | Тип | Размер | +|---|---:|---:| +| big_block_number | u32 | 4 | +| big_block_hash | bytes[32] | 32 | +| arweave_tx_id | bytes[32] | 32 | + +Семантика: ```text -u8 +big_block_number = логический номер +big_block_hash = криптографическая идентичность +arweave_tx_id = где скачать блок ``` -Зарезервированные значения: +--- -```text -0 = FULL_HISTORY -1 = PARTIAL_HISTORY -``` +# 24. `references_count` -Для v1 publisher MUST использовать: - -```text -FULL_HISTORY -``` - -То есть каждый новый большой блок содержит ссылки на все известные предыдущие большие блоки своей ветки. - -`PARTIAL_HISTORY` резервируется на будущее, если когда-нибудь полный список станет слишком большим. - -## 9.6. `references_count` +Количество entries в таблице ссылок. ```text u32 ``` -Количество элементов в таблице предыдущих больших блоков. +--- -## 9.7. `reference_entry_size` +# 25. `reference_entry_size` ```text u16 ``` -Для v1: +Для v1.0: ```text 68 ``` -Поле оставлено явно, чтобы будущая minor/major версия могла расширить `PreviousBlockReference`, а клиент всё ещё мог корректно пропускать неизвестные дополнительные байты. +Поле оставлено для будущего расширения entry. -## 9.8. `records_count` +--- + +# 26. `parent_reference_index` ```text u32 ``` -Количество пользовательских record-контейнеров внутри большого блока. +Индекс непосредственного родителя внутри reference table. ---- - -# 10. Таблица всех предыдущих больших блоков - -Сразу после header идёт: - -```text -PreviousBlockReference[references_count] -``` - -В v1 это **полная история предыдущих больших блоков данной ветки**. - -При одном закрытии в сутки даже через год это около 365 элементов, поэтому простота полного списка важнее преждевременной оптимизации. - ---- - -# 11. `PreviousBlockReference v1` - -Формат одного элемента: - -```text -u32 block_number -bytes[32] block_hash -bytes[32] arweave_tx_id -``` - -Размер: - -```text -4 + 32 + 32 = 68 байт -``` - -Смысл: - -```text -block_number --> логический номер большого блока - -block_hash --> SHA-256 / канонический hash точного содержимого большого блока - -arweave_tx_id --> raw 32-byte TX ID, по которому этот большой блок можно скачать из Arweave -``` - -То есть: - -```text -HASH = идентичность содержимого -TXID = место хранения -``` - -Оба поля обязательны. - -Внешнее строковое Base64URL-представление TX ID внутрь бинарного файла не записывается. - -Таблица v1 SHOULD быть отсортирована детерминированно по `block_number`, затем по `block_hash`. - ---- - -# 12. Почему в v1 записываются все предыдущие блоки - -Если закрывать примерно один большой блок в сутки: - -```text -365 × 68 ≈ 24.8 KB в год -``` - -Даже через много лет это остаётся небольшим overhead по сравнению с полезными данными. - -Главное преимущество: любой новый большой блок сразу является индексом всей предыдущей ветки. - -Клиент не обязан идти: - -```text -365 -> 364 -> 363 -> ... -> 95 -``` - -Чтобы получить адрес блока 95. - -Он получает его из начала текущего блока. - ---- - -# 13. Пользовательская обратная навигация - -Навигация пользователя строится не на одну предыдущую запись. - -Правило v1: - -> Для каждого пользователя сервер находит **предыдущий большой блок**, в котором существовала хотя бы одна запись этого пользователя, и сохраняет ссылку на этот большой блок вместе со списком `offset + size` **всех записей этого пользователя в том предыдущем большом блоке**. - -Например: - -```text -Большой block 100: -Alice сегодня имеет 2 записи. - -Предыдущий большой блок, где Alice присутствовала: -block 95. - -В block 95 у Alice было 3 записи: - offset 8123 / size 417 - offset 9001 / size 351 - offset 14020 / size 692 -``` - -Тогда server-generated ссылка Alice из block 100 содержит все три диапазона. - ---- - -# 14. `PreviousUserRecordsRef v1` - -Формат: - -```text -u32 previous_block_ref -u32 previous_records_count - -repeat previous_records_count times: - u32 previous_record_offset - u32 previous_record_size -``` - -Минимальный размер: - -```text -8 байт -``` - -Плюс: - -```text -8 байт на каждую предыдущую запись пользователя -``` - -Пример для 3 сообщений: - -```text -previous_block_ref = 94 -previous_records_count = 3 - -8123 417 -9001 351 -14020 692 -``` - -Если `PreviousBlockReference[94]` описывает big block 95, клиент получает: - -```text -block number = 95 -block hash = ... -Arweave TXID = ... -``` - -и может HTTP Range-запросами забрать только три нужных диапазона. - ---- - -# 15. `previous_block_ref` - -`previous_block_ref` — индекс в таблице `PreviousBlockReference` **текущего большого блока**. - -То есть пользовательская ссылка не повторяет 32-byte TX ID и 32-byte hash. - -Она хранит только компактный индекс. - -Специальное значение: +Для первого большого блока (`#1`), у которого нет родителя: ```text 0xFFFFFFFF ``` -означает: - -```text -у пользователя ещё не было записей в предыдущих больших блоках -``` - -В этом случае: - -```text -previous_records_count = 0 -``` +В простой FULL-линейной ветке обычно это последний entry. --- -# 16. Смещение и размер записи +# 27. Быстрое скачивание начала блока -`previous_record_offset`: +Клиент сначала скачивает fixed header + creator login. + +Из него узнаёт: + +```text +header_size +references_count +reference_entry_size +``` + +Затем скачивает только reference table и уже имеет адреса/hash всех предыдущих больших блоков. + +Payload пользователей можно пока не скачивать. + +--- + +# 28. `blockchain_chunks_count` ```text u32 ``` -Абсолютный offset от первого байта соответствующего большого `SHINE-ARCHIVE` файла до первого байта нужной архивированной user-record. +Количество `UserBlockchainChunk` в текущем big block. -`previous_record_size`: +Одна `blockchain_name` встречается максимум один раз. + +--- + +# 29. `total_user_records_count` ```text u32 ``` -Полный размер этой архивированной user-record в байтах. - -Таким образом, зная: - -```text -TX ID -block hash -offset -size -``` - -клиент может скачать ровно нужную пользовательскую запись. +Общее количество исходных SHiNE-записей во всех chunks. --- -# 17. Как сервер добавляет пользовательскую ссылку +# 30. Порядок chunks и records -`PreviousUserRecordsRef` создаёт **сервер, который формирует большой блок**, а не пользовательский клиент. +Для детерминированной сериализации chunks SHOULD сортироваться по `blockchain_name ASC`. -Оригинальные подписанные пользователем bytes изменять нельзя. +Внутри chunk исходные записи MUST идти по возрастанию номера исходного SHiNE-блока. -Поэтому server navigation metadata хранится как часть контейнера большого блока вокруг/после оригинальных user-record bytes и покрывается: +--- -- `block_hash` большого блока; -- подписью сервера/пользователя, который закрыл большой блок. - -Для каждого пользователя в текущем большом блоке достаточно одного `PreviousUserRecordsRef`, связанного с последней записью этого пользователя в текущем блоке. - -Если у пользователя сегодня в текущем большом блоке две записи, сервер после их сериализации добавляет один navigation tail, который указывает на предыдущий большой блок пользователя и перечисляет все его записи там. - -При чтении предыдущего большого блока та же схема даёт следующий переход назад. - -Получается: +# 31. Формат `UserBlockchainChunk` ```text -Alice in block 100 - | - | PreviousUserRecordsRef - | -> block 95 - | -> offsets of ALL Alice records in block 95 - v -Alice records in block 95 - | - | server navigation metadata из block 95 - | -> block 81 - | -> offsets of ALL Alice records in block 81 - v -... +u32 chunk_size + +u8 blockchain_name_length +bytes[N] blockchain_name UTF-8 + +u32 records_count + +repeat records_count: + u32 record_size + bytes[M] raw_record_bytes + +u32 previous_big_block_ref +u32 previous_chunk_offset +u32 previous_chunk_size ``` --- -# 18. Рекомендуемый контейнер архивированной пользовательской записи - -Чтобы сервер мог добавлять свою навигационную метаинформацию, не изменяя исходную пользовательскую подпись, v1 SHOULD использовать серверный envelope. - -Рекомендуемая модель: +# 32. `chunk_size` ```text -u32 archived_record_size -u32 raw_user_record_size -bytes[N] raw_user_record -u8 navigation_flags -[optional server navigation data] +u32 ``` -Где: +Полный размер chunk от первого байта `chunk_size` до последнего байта `previous_chunk_size` включительно. -```text -navigation_flags bit 0 = PreviousUserRecordsRef присутствует -``` +Это позволяет: -У большинства записей: - -```text -navigation_flags = 0 -``` - -У последней записи конкретного пользователя в текущем большом блоке: - -```text -navigation_flags bit0 = 1 -``` - -и далее сериализован `PreviousUserRecordsRef`. - -`archived_record_size` позволяет Range-reader получить и полностью декодировать конкретный archived record. - -Исходный `raw_user_record` остаётся байт-в-байт неизменным. +- быстро пропустить chunk; +- скачать его Range-запросом; +- сохранить его точные координаты для следующего backlink. --- -# 19. Пример пользовательской цепочки +# 33. `blockchain_name` внутри chunk -```text -BIG BLOCK 100 +Хранится только `blockchain_name`. -Alice record A -Alice record B + PreviousUserRecordsRef: - previous_block_ref -> block 95 - previous_records_count = 3 - [8123,417] - [9001,351] - [14020,692] - - | - v - -BIG BLOCK 95 - -Alice old record #1 -Alice old record #2 -Alice old record #3 + PreviousUserRecordsRef: - previous_block_ref -> block 81 - previous_records_count = 1 - [4412,380] - - | - v - -BIG BLOCK 81 -... -``` - -Так клиент получает всю историю пользователя назад блок за блоком, но скачивает только конкретные нужные byte ranges. +Отдельный `login` не нужен. --- -# 20. Hash большого блока - -В footer записывается: +# 34. `records_count` ```text -bytes[32] block_hash +u32 ``` -`block_hash` вычисляется как: +Если за период у `alice-001` появилось 7 записей: ```text -SHA256( - все байты большого блока - от magic - через header - через creator_login - через PreviousBlockReference table - через все user records и server navigation metadata - через footer closer_login_length + closer_login -) +records_count = 7 ``` -Само поле `block_hash` и `closer_signature` в hash НЕ входят. +Все 7 находятся в одном chunk. --- -# 21. Footer: кто закрыл блок, hash и подпись +# 35. Raw records -В конце каждого большого блока MUST быть: +Каждая исходная запись сериализуется: + +```text +u32 record_size +bytes[record_size] raw_record_bytes +``` + +`raw_record_bytes` — оригинальные данные SHiNE из БД. + +Сервер не меняет их содержимое. + +--- + +# 36. Один backlink на весь chunk + +После всех raw records находится ровно один `PreviousBlockchainChunkRef`. + +Он относится ко всей пачке записей blockchain. + +Он не повторяется у отдельных записей. + +--- + +# 37. `PreviousBlockchainChunkRef` + +| Поле | Тип | Размер | +|---|---:|---:| +| previous_big_block_ref | u32 | 4 | +| previous_chunk_offset | u32 | 4 | +| previous_chunk_size | u32 | 4 | + +Итого 12 байт на весь chunk. + +--- + +# 38. `previous_big_block_ref` + +Индекс в `PreviousBigBlockReferences[]` текущего big block. + +Он указывает на тот предыдущий big block, где находится предыдущий chunk этой же `blockchain_name`. + +Это именно reference index, а не номер блока. + +--- + +# 39. `previous_chunk_offset` + +```text +u32 +``` + +Byte offset от начала предыдущего `SHINE-ARCHIVE` до первого байта предыдущего chunk этой blockchain. + +Первый байт chunk — поле `chunk_size`. + +--- + +# 40. `previous_chunk_size` + +```text +u32 +``` + +Полный размер предыдущего chunk. + +Поэтому клиент может Range-запросом забрать сразу всю предыдущую пачку записей пользователя. + +--- + +# 41. Первая публикация blockchain + +Если у данной `blockchain_name` нет предыдущего архивного chunk: + +```text +previous_big_block_ref = 0xFFFFFFFF +previous_chunk_offset = 0 +previous_chunk_size = 0 +``` + +--- + +# 42. Пример backlink-цепочки + +В `BigBlock #100` есть `alice-001` chunk из 4 записей. + +Его backlink: + +```text +previous_big_block_ref = 94 +previous_chunk_offset = 8123 +previous_chunk_size = 1770 +``` + +`ref[94]` содержит: + +```text +big_block_number = 95 +big_block_hash = ... +arweave_tx_id = ... +``` + +Клиент: + +1. получает TX big block #95; +2. проверяет hash; +3. делает Range на `8123 .. 8123+1770`; +4. получает весь предыдущий `alice-001` chunk; +5. читает его записи; +6. берёт следующий backlink. + +--- + +# 43. Почему TX ID не хранится в каждом chunk + +TX ID и hash предыдущих больших блоков уже один раз записаны в общей reference table. + +Поэтому в chunk достаточно компактного `u32 previous_big_block_ref`. + +--- + +# 44. Footer большого блока + +После последнего chunk: ```text u8 closer_login_length bytes[N] closer_login UTF-8 + bytes[32] block_hash bytes[64] closer_signature ``` -Логические значения: +--- + +# 45. `closer_login` + +Login пользователя/сервера, который сформировал и закрыл big block. + +В v1.0: ```text -1. кто сформировал/закрыл блок; -2. hash точных bytes блока; -3. подпись закрывающего аккаунта. +closer_login MUST == creator_login ``` -`closer_login` MUST точно совпадать с `creator_login` из header. +Login повторяется намеренно: -Если значения отличаются — блок невалиден. +- header даёт автора сразу; +- footer явно фиксирует того, кто закрыл блок; +- hash покрывает footer login; +- verifier проверяет совпадение. --- -# 22. Подпись большого блока - -Алгоритм v1: +# 46. `block_hash` ```text +bytes[32] +SHA-256 +``` + +Хэшируется всё от первого байта magic до последнего байта `closer_login` включительно. + +Не входят: + +```text +block_hash +closer_signature +``` + +Псевдокод: + +```text +hash_input = + fixed_header + + creator_login + + references + + all UserBlockchainChunks + + closer_login_length + + closer_login + +block_hash = SHA256(hash_input) +``` + +--- + +# 47. `closer_signature` + +```text +bytes[64] Ed25519 ``` -Подписываемый payload: +Подписывается: ```text ASCII("SHINE-ARCHIVE-V1") || block_hash ``` -То есть: - -```text -closer_signature = -Ed25519Sign( - closer_private_key, - "SHINE-ARCHIVE-V1" || block_hash -) -``` - -Для первой реализации `closer_private_key` может быть root private key SHiNE-аккаунта `creator_login` / `closer_login`. - -Public key для проверки берётся из соответствующего User PDA в Solana. +Для v1.0 используется root private key аккаунта `creator_login`. --- -# 23. Archive head в Solana User PDA +# 48. Проверка подписи -Отдельный PDA создавать не требуется. +Verifier: -Используется тот же существующий User PDA. +1. читает `creator_login`; +2. проверяет `creator_login == closer_login`; +3. получает root public key аккаунта из User PDA / исторического состояния Solana; +4. пересчитывает `block_hash`; +5. проверяет Ed25519 signature. -Новый block type: +Старые архивы должны оставаться проверяемыми после ротации root key, поэтому исторические PDA-состояния нужно сохранять/уметь получать. -```text -100 -``` +--- + +# 49. Собственный Arweave TX ID текущего блока + +Текущий файл не может заранее знать свой будущий TX ID. + +Поэтому собственный TX ID: + +- не входит в сам текущий файл; +- сохраняется после upload; +- записывается в Solana PDA; +- появляется как previous-block reference уже в следующем big block. + +--- + +# 50. User PDA block type `100` + +Archive head хранится прямо в существующем User PDA publisher-а. Формат: @@ -840,185 +1019,360 @@ bytes[32] archive_tx_id bytes[32] archive_hash ``` -Размер: +Итого 66 байт. + +`archive_hash` = `block_hash` последнего успешно опубликованного big block. + +--- + +# 51. Обновление Solana + +Отдельная новая Solana instruction не требуется. + +Используется существующий `update_user_pda`. + +Нужно расширить существующие serializer/parser/codec так, чтобы block type 100: + +- читался; +- записывался; +- сохранялся обычными обновлениями; +- мог быть изменён архивным сервером обычным update User PDA. + +--- + +# 52. Полный порядок публикации ```text -66 байт -``` +1. Существующая server-to-server sync независимо обновляет локальные blockchains. -Solana тем самым хранит: +2. Scheduler запускает archive job. -```text -где находится текущая голова -> Arweave TX ID -какие exact bytes являются правильными -> SHA-256 +3. Проверяется отсутствие другого активного archive job. + +4. Читаются archive_chain_cursor. + +5. Читаются текущие локальные blockchain heads. + +6. Для каждой blockchain вычисляется frozen delta. + +7. Создаётся archive_publish_job. + +8. Создаются archive_publish_job_chain rows. + +9. Загружается история всех успешно finalized previous big blocks. + +10. Формируется fixed header. + +11. Записывается creator_login. + +12. Записывается FULL PreviousBigBlockReferences. + +13. Для каждой blockchain_name: + - читается frozen range; + - создаётся ровно один UserBlockchainChunk; + - внутрь кладутся все новые records; + - добавляется один PreviousBlockchainChunkRef; + - сохраняются new_chunk_offset/new_chunk_size. + +14. Записывается closer_login. + +15. Вычисляется SHA-256 block_hash. + +16. Создаётся Ed25519 closer_signature. + +17. Файл закрывается. + +18. Файл загружается в Arweave. + +19. TX ID сохраняется в archive_publish_job. + +20. Сервер ждёт требуемое число Arweave confirmations. + +21. Обычным update_user_pda обновляется block type 100: + archive_tx_id + archive_hash + +22. Сервер ждёт Solana finalized. + +23. В одной DB transaction обновляются archive_chain_cursor. + +24. Job получает CURSORS_COMMITTED. ``` --- -# 24. Обновление Solana — обычный User PDA update +# 53. Cursor commit -**Отдельная Solana instruction для archive head не требуется.** +Для каждой blockchain из job: -Архивный сервер использует существующий обычный механизм обновления собственного User PDA: +```text +last_archived_source_block_number = to_source_block_number +last_archived_source_block_hash = last_source_block_hash -1. читает текущий PDA; -2. сохраняет все существующие поля; -3. меняет/добавляет только block type `100`; -4. пересобирает новую PDA-запись; -5. выполняет существующую root-signature проверку; -6. `client key` подписывает/оплачивает Solana transaction; -7. ждёт `finalized`. +last_archive_big_block_number = current big_block_number +last_archive_big_block_hash = current block_hash -Важно: все новые Java/JS/Rust сериализаторы обязаны понимать block type `100` и сохранять его при обычных обновлениях PDA. +last_chunk_offset = new_chunk_offset +last_chunk_size = new_chunk_size +``` + +Следующая публикация сразу знает, куда должен вести backlink. --- -# 25. `TestFreeAvatarArweaveService` +# 54. Arweave service -Старый сервис больше не нужен как avatar test service. +Старый `TestFreeAvatarArweaveService` удаляется из активного протокола; archive publisher использует отдельный `ArweaveArchiveService`. -Его следует переименовать, например, в: +Его следует переделать/переименовать, например в: ```text ArweaveArchiveService ``` -и переделать только под серверную фиксацию. +Он должен: -Нужно оставить/переиспользовать: - -- JWK parsing; -- wallet address; -- reward calculation; -- balance check; -- RSA-PSS signing; -- Arweave HTTP. - -Удалить avatar-specific части: - -- PNG/JPEG/WebP проверки; -- avatar quota; -- лимит 128 KiB; -- avatar DAO; -- avatar-specific API. - -Новый сервис должен уметь: - -```text -publishArchive(...) -waitForConfirmation(...) -``` - -и корректно работать с большими/chunked uploads. - ---- - -# 26. Ожидание закрепления Arweave - -После получения TX ID сервер НЕ обновляет Solana сразу. - -Он ждёт заданное число подтверждений. +- использовать Arweave JWK; +- подписывать и оплачивать upload; +- поддерживать большие/chunked uploads; +- возвращать TX ID; +- ждать подтверждения; +- опрашивать confirmations. Рекомендуемые настройки: ```properties +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json archive.arweave.minConfirmations=1 archive.arweave.confirmPollSeconds=30 archive.arweave.confirmTimeoutMinutes=180 ``` -Только после `ARWEAVE_CONFIRMED` разрешён update User PDA. - --- -# 27. Порядок публикации +# 55. Solana finalization -Строго: +После обычного `update_user_pda` сервер должен дождаться: ```text -1. Синхронизировать/иметь актуальные локальные блоки. - -2. Прочитать archive_chain_cursor. - -3. Зафиксировать snapshot новых диапазонов. - -4. Создать archive_publish_job. - -5. Собрать большой SHINE-ARCHIVE v1.0. - -6. В header записать creator_login. - -7. В начало блока записать FULL_HISTORY список всех предыдущих больших блоков: - block_number + hash + Arweave TX ID. - -8. Для каждого пользователя сформировать server navigation metadata: - previous big block + список всех offset/size его записей там. - -9. Посчитать block_hash. - -10. В footer записать closer_login, block_hash, signature. - -11. Загрузить файл в Arweave. - -12. Сохранить TX ID в publish job. - -13. Дождаться закрепления Arweave. - -14. Обычным User PDA update записать block type 100: - archive_tx_id + archive_hash. - -15. Дождаться Solana finalized. - -16. В одной локальной DB transaction сдвинуть archive_chain_cursor. - -17. Отметить job CURSORS_COMMITTED. +finalized ``` -Если новых данных нет — пустой большой блок создавать не нужно. +Только после этого разрешается сдвинуть локальные курсоры. --- -# 28. Crash recovery +# 56. Crash recovery -## Crash после snapshot +## После `SNAPSHOT_CREATED` -Продолжить с теми же frozen ranges. +Возобновить тот же frozen job. -## Crash после построения файла +## После `FILE_BUILT` -Использовать тот же файл и тот же hash. +Использовать тот же локальный файл. -## Crash после Arweave upload +## После `ARWEAVE_UPLOADED` -Не загружать второй раз. Использовать сохранённый TX ID. +Не загружать второй раз. Продолжить по сохранённому TX ID. -## Crash после Arweave confirmation +## После `ARWEAVE_CONFIRMED` -Продолжить с User PDA update. +Продолжить Solana update. -## Crash после Solana submit +## После `SOLANA_SUBMITTED` -Сначала проверить текущий PDA/transaction. +Сначала проверить transaction / текущий PDA. -## Crash после Solana finalized, но до cursor commit +## После `SOLANA_FINALIZED`, но до cursor commit -Если PDA уже содержит ожидаемые: +Проверить: ```text -archive_tx_id -archive_hash +PDA.archive_tx_id == expected_tx +PDA.archive_hash == expected_hash ``` -то безопасно выполнить локальный cursor commit. +После этого безопасно выполнить cursor commit. --- -# 29. Настройки scheduler +# 57. Нет новых данных + +Если новых записей нет ни в одной blockchain: + +```text +не создавать пустой SHINE-ARCHIVE +не загружать Arweave +не обновлять Solana +``` + +--- + +# 58. FULL references как стартовая стратегия + +v1.0 сознательно хранит ВСЕ previous big blocks в каждом новом big block. + +Плюсы: + +- актуальный блок сразу даёт карту всей предыдущей истории; +- не нужно идти parent-by-parent; +- backlink может использовать компактный ref index; +- reader очень простой. + +При одном big block в сутки: + +```text +1 год: ~24.8 KiB +5 лет: ~121 KiB +20 лет: ~485 KiB +``` + +--- + +# 59. PARTIAL references + +`references_mode = 1` зарезервирован на будущее. + +v1.0 writer его не реализует. + +В будущем PARTIAL может означать: + +- только реально используемые previous blocks; +- последние N blocks; +- checkpoint references; +- смешанную стратегию. + +--- + +# 60. Чтение истории одной blockchain + +Имея актуальный chunk `alice-001`, клиент: + +1. читает все records chunk; +2. читает `PreviousBlockchainChunkRef`; +3. если ref = `0xFFFFFFFF`, история закончилась; +4. иначе берёт соответствующий `PreviousBigBlockReference`; +5. получает TX ID + hash previous big block; +6. Range-запросом читает `previous_chunk_offset/previous_chunk_size`; +7. получает сразу всю предыдущую пачку записей `alice-001`; +8. повторяет. + +Так можно читать историю конкретной blockchain, не скачивая чужие chunks. + +--- + +# 61. Дубликаты + +Если исходная SHiNE-запись уже есть у получателя, она пропускается/дедуплицируется стандартной логикой. + +--- + +# 62. Конфликты исходной blockchain + +Архивная ветка сама по себе не является пользовательским конфликтом. + +Конфликт возникает, когда одна `blockchain_name` имеет два несовместимых валидно подписанных продолжения одной предыдущей точки. + +Для v1 безопасно: + +```text +сохранить обе ветки +пометить blockchain CONFLICTED +остановить автоматическое продолжение +``` + +Механизм разрешения fork можно добавить позже. + +--- + +# 63. Авторство большого блока + +Автор указан дважды: + +## Header + +```text +creator_login +``` + +## Footer + +```text +closer_login +block_hash +closer_signature +``` + +Для v1.0: + +```text +creator_login == closer_login +``` + +Это означает: именно этот SHiNE-аккаунт сформировал и криптографически закрыл big block. + +--- + +# 64. Подпись сервера не заменяет подписи пользователей + +`closer_signature` доказывает авторство большого archive block. + +Каждая пользовательская raw record всё равно проверяется существующей криптографией SHiNE. + +--- + +# 65. Независимость от Arweave + +Arweave TX ID — location. + +SHA-256 — identity. + +Поэтому архивы можно перенести на другой storage и всё равно проверять. + +--- + +# 66. Block type 100 в Solana — текущая голова + +User PDA publisher-а хранит только: + +```text +current archive TX ID +current archive SHA-256 +``` + +Детальная история находится внутри big blocks. + +--- + +# 67. Startup preflight + +Перед запуском archive publisher сервер SHOULD проверить: + +```text +derivePublic(root_private) == UserPDA.root_public_key +``` + +и: + +```text +derivePublic(client_private) == UserPDA.client_key +``` + +При несовпадении archive publisher не запускается. + +--- + +# 68. Рекомендуемые настройки ```properties archive.publish.enabled=false -archive.publish.intervalMinutes=720 -archive.publish.initialDelayMinutes=15 +archive.publish.time=00:00 +archive.publish.zoneId= +archive.workDir=data/archive + +archive.maxFileBytes=4000000000 archive.arweave.gateway=https://arweave.net archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json @@ -1029,324 +1383,237 @@ archive.arweave.confirmTimeoutMinutes=180 archive.solana.rootKeyPath=/opt/shine/secrets/root.key archive.solana.clientKeyPath=/opt/shine/secrets/client.key archive.solana.commitment=finalized -``` -Для первой версии разумно начать с: - -```text -720 минут = раз в 12 часов -``` - -Частота обычной межсерверной синхронизации меняется независимо. - ---- - -# 30. Проверка большого блока клиентом - -Клиент SHOULD выполнять: - -```text -1. Скачать начало файла. -2. Проверить magic. -3. Прочитать major/minor. -4. Прочитать header_size. -5. Прочитать creator_login. -6. Докачать весь header + PreviousBlockReference table. -7. При необходимости получить нужные previous block TXID/hash. -8. Скачать нужные user record ranges. -9. При полной проверке файла пересчитать block_hash. -10. Проверить closer_login == creator_login. -11. Получить public key автора из User PDA. -12. Проверить Ed25519 signature. +# Отдельного archive.solana.rpcUrl нет. +# Используется solana.users.sync.rpcUrl, а если он пуст — обычный solana.rpcUrl. ``` --- -# 31. Пример навигации Alice +# 69. Требование к `AppConfig` -Допустим клиент уже имеет актуальную запись Alice из block 100. +Archive settings должны читаться из: -Server navigation metadata говорит: +- Java system properties; +- environment variables; +- `application.properties`. -```text -previous_block_ref = 94 -previous_records_count = 3 - -[8123, 417] -[9001, 351] -[14020, 692] -``` - -Из начала block 100: - -```text -PreviousBlockReference[94]: - block_number = 95 - block_hash = H95 - arweave_tx_id = TX95 -``` - -Клиент: - -```text -1. идёт в TX95; -2. проверяет H95; -3. делает Range на 8123/417; -4. Range на 9001/351; -5. Range на 14020/692; -6. получает все три Alice records из block 95; -7. из server navigation metadata последней Alice record получает следующий переход, например на block 81; -8. повторяет. -``` - -То есть история читается: - -```text -100 -> 95 -> 81 -> 63 -> ... -``` - -но на каждом шаге скачиваются только конкретные записи данного пользователя. +`getInt()` и `getBoolean()` должны использовать общую логику `getParam()`. --- -# 32. Почему hash и TX ID нужны одновременно +# 70. Что переиспользуется из текущего сервера -Нельзя оставить только TX ID. - -Правильная модель: +Существующие механизмы: ```text -block_number --> логический номер +BlockchainStateDAO.listAll() +BlocksDAO.listRangeByNumber(...) +``` +дают необходимые heads и raw block ranges. + +Используются существующие: + +```text +block_bytes block_hash --> криптографическая идентичность - -arweave_tx_id --> текущее физическое расположение +block_signature +blockchain_name +block_number ``` -Если в будущем большой блок будет перенесён из Arweave в другое хранилище, его hash останется тем же. +Server-to-server sync в v1.0 не меняется. --- -# 33. Будущее `PARTIAL_HISTORY` +# 71. Блокировки -В первой реализации используется только: +Можно использовать существующий blockchain lock только на коротком этапе: ```text -references_mode = FULL_HISTORY +проверить cursor hash +прочитать frozen range +проверить конечный hash +сохранить/сериализовать frozen data ``` -На будущее уже зарезервирован: - -```text -PARTIAL_HISTORY -``` - -Будущая версия сможет, например, хранить: - -- последние N больших блоков; -- checkpoint-блоки; -- только реально используемые ссылки; -- многоуровневый индекс. - -Но это НЕ требуется для v1. +Lock не удерживается во время Arweave/Solana ожиданий. --- -# 34. Итоговые бинарные структуры v1.0 - -## Header - -```text -bytes[13] magic = "SHINE-ARCHIVE" -u8 version_major -u8 version_minor -u32 header_size -u32 block_number -u64 created_at_ms -u8 creator_login_length -bytes[N] creator_login -u8 references_mode -u32 references_count -u16 reference_entry_size -u32 records_count -``` - -## PreviousBlockReference - -```text -u32 block_number -bytes[32] block_hash -bytes[32] arweave_tx_id -``` - -Размер v1: - -```text -68 bytes -``` - -## PreviousUserRecordsRef - -```text -u32 previous_block_ref -u32 previous_records_count - -repeat previous_records_count: - u32 previous_record_offset - u32 previous_record_size -``` - -## User archived envelope — рекомендуемая форма - -```text -u32 archived_record_size -u32 raw_user_record_size -bytes[N] raw_user_record -u8 navigation_flags -[optional PreviousUserRecordsRef] -``` - -## Footer - -```text -u8 closer_login_length -bytes[N] closer_login -bytes[32] block_hash -bytes[64] closer_signature -``` - -## Solana User PDA block 100 - -```text -u8 block_type = 100 -u8 block_version = 0 -bytes[32] archive_tx_id -bytes[32] archive_hash -``` - ---- - -# 35. Обязательные проверки v1 - -Publisher MUST: - -- не изменять frozen job после snapshot; -- проверять cursor hash; -- не двигать cursor до Solana finalized; -- не публиковать пустую дельту; -- записывать полный список предыдущих больших блоков; -- хранить hash и TX ID каждого previous block; -- записывать creator login в header; -- повторять тот же login в footer; -- подписывать hash большого блока; -- добавлять для пользователя ссылку на предыдущий большой блок и offsets/sizes всех его записей там. - -Reader MUST: - -- проверять magic/version; -- проверять границы offsets; -- проверять hash скачанного большого блока; -- проверять `closer_login == creator_login`; -- проверять подпись; -- проверять, что скачанные user records действительно принадлежат ожидаемому пользователю; -- не считать Arweave TX ID самостоятельным доказательством корректности содержимого. - ---- - -# 36. Реализационный checklist - -## Solana - -- [ ] Добавить User PDA block type `100`. -- [ ] Добавить `archive_tx_id [32]`. -- [ ] Добавить `archive_hash [32]`. -- [ ] Научить обычный User PDA update читать и сохранять block 100. -- [ ] Серверу дать возможность обычным update менять block 100. -- [ ] Ждать `finalized`. +# 72. Реализационный checklist ## Database -- [ ] `archive_chain_cursor`. -- [ ] `archive_publish_job`. -- [ ] `archive_publish_job_chain`. +- [x] `archive_chain_cursor` +- [x] `archive_publish_job` +- [x] `archive_publish_job_chain` +- [x] schema migration +- [x] crash recovery + +## Archive writer + +- [x] magic `SHINE-ARCHIVE` +- [x] major/minor version +- [x] fixed header +- [x] creator login +- [x] FULL previous big block table +- [x] one chunk per `blockchain_name` +- [x] many raw records inside one chunk +- [x] one backlink per chunk +- [x] closer login +- [x] SHA-256 +- [x] Ed25519 signature +- [x] max file size < 4 GiB +- [x] local `.tmp.SHiNE-archive -> ..SHiNE-archive` lifecycle in `data/archive` ## Arweave -- [ ] Переименовать `TestFreeAvatarArweaveService` в `ArweaveArchiveService`. -- [ ] Удалить avatar-specific код. -- [ ] Реализовать большие/chunked uploads. -- [ ] Реализовать ожидание confirmations. -- [ ] Сохранять TX ID до ожидания подтверждений. +- [x] rename/refactor `TestFreeAvatarArweaveService` +- [x] remove avatar-specific logic +- [x] large/chunked upload +- [x] confirmation polling -## Большой блок +## Solana -- [ ] Magic `SHINE-ARCHIVE`. -- [ ] `version_major u8`. -- [ ] `version_minor u8`. -- [ ] `header_size`. -- [ ] `block_number u32`. -- [ ] `created_at_ms u64`. -- [ ] `creator_login` в header. -- [ ] `FULL_HISTORY` previous block table. -- [ ] `block_number + block_hash + txid` для каждого previous block. -- [ ] Server-side `PreviousUserRecordsRef`. -- [ ] Список всех `offset + size` пользовательских записей из предыдущего big block. -- [ ] `closer_login + block_hash + signature` в footer. +- [x] add PDA block type `100` +- [x] update Rust codec +- [x] update Java codec +- [x] update JS codec/UI writer +- [x] use ordinary `update_user_pda` +- [x] server-side transaction writer +- [x] root signature +- [x] client fee payer +- [x] wait for `finalized` -## Scheduler / recovery +## Scheduler -- [ ] Настраиваемый interval. -- [ ] Initial delay. -- [ ] Один active job. -- [ ] Resume после restart. -- [ ] Cursor commit только после Solana finalized. +- [x] `archive.publish.enabled` +- [x] `archive.publish.time` +- [x] `archive.publish.zoneId` +- [x] no concurrent jobs +- [ ] future: автоматическое split > max size (v1.0 сейчас безопасно останавливается без cursor commit) +- [x] skip when no new blocks --- -# 37. Краткая итоговая модель +# 73. Итоговая бинарная схема v1.0 + +```text +SHINE-ARCHIVE +version_major u8 +version_minor u8 + +header_size u32 +big_block_number u32 +created_at_ms u64 + +creator_login_length u8 + +references_mode u8 +references_count u32 +reference_entry_size u16 +parent_reference_index u32 + +blockchain_chunks_count u32 +total_user_records_count u32 + +creator_login bytes + + +PreviousBigBlockReference[references_count]: + + big_block_number u32 + big_block_hash [32] + arweave_tx_id [32] + + +UserBlockchainChunk[blockchain_chunks_count]: + + chunk_size u32 + + blockchain_name_length u8 + blockchain_name bytes + + records_count u32 + + repeat records_count: + record_size u32 + raw_record_bytes + + previous_big_block_ref u32 + previous_chunk_offset u32 + previous_chunk_size u32 + + +FOOTER: + + closer_login_length u8 + closer_login bytes + + block_hash [32] + closer_signature [64] +``` + +--- + +# 74. Основная архитектура одним рисунком ```text SOLANA USER PDA | - +--> block 100 - archive TX ID - archive SHA-256 - - | - v - -SHINE-ARCHIVE block N - | - | HEADER: - | version - | block number - | time - | CREATOR LOGIN - | - | PREVIOUS BLOCK TABLE: - | #0 number/hash/TXID - | #1 number/hash/TXID - | ... все предыдущие в v1 - | - | USER DATA: - | original signed user records - | server navigation metadata - | previous block ref - | ALL previous user record offsets/sizes - | - | FOOTER: - | same creator/closer login - | hash - | Ed25519 signature + | block type 100 + | TX + HASH v - -SHINE-ARCHIVE block N-1 +BigBlock #100 | - v -... + +-- Header + | creator = server-A + | + +-- References + | ref[0] -> BigBlock #1 / hash / TX + | ref[1] -> BigBlock #2 / hash / TX + | ... + | ref[98] -> BigBlock #99 / hash / TX + | + +-- alice-001 chunk + | records x4 + | | + | +--> BigBlock #95 / AliceChunk + | + +-- bob-001 chunk + | records x2 + | | + | +--> BigBlock #98 / BobChunk + | + +-- kate-002 chunk + | records x10 + | | + | +--> BigBlock #74 / KateChunk + | + +-- Footer + closer = server-A + SHA-256 + Ed25519 signature ``` -Это завершает согласованную спецификацию `SHiNE Archive Protocol v1.0`. +--- + +# 75. Итоговые решения v1.0 + +- один большой блок = одна архивная публикация; +- в начале лежит полный список всех предыдущих больших блоков; +- каждый previous block reference содержит `number + SHA-256 + Arweave TX ID`; +- одна `blockchain_name` встречается в большом блоке максимум одним chunk; +- все новые записи этой blockchain объединяются в этот chunk; +- chunk имеет одну ссылку на предыдущий chunk этой же blockchain; +- backlink состоит из `previous big block ref + chunk offset + chunk size`; +- сервер сам создаёт backlink; +- пользовательские raw records не меняются; +- login сервера есть в header и footer; +- большой блок закрывается SHA-256 + Ed25519 signature сервера; +- Solana User PDA block `100` хранит текущий TX ID + archive hash; +- PDA обновляется обычным `update_user_pda`; +- Arweave используется как долговременное хранилище; +- публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md index eb4db25a..ce7950e2 100644 --- a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md +++ b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md @@ -168,19 +168,78 @@ BigBlock 70 --- -# 6. Периодичность +# 6. Расписание публикации -Частота архивной публикации конфигурируется отдельно от существующей межсерверной синхронизации. +Архивная публикация запускается один раз в сутки в заданное локальное время. Она НЕ использует интервал «каждые N минут». Пример: ```properties archive.publish.enabled=true -archive.publish.intervalMinutes=720 -archive.publish.initialDelayMinutes=15 +archive.publish.time=00:00 +archive.publish.zoneId= ``` -`720` минут = раз в 12 часов. +Для v1.0 значение по умолчанию: + +```text +00:00 +``` + +То есть новый snapshot и новый большой архивный блок создаются один раз в сутки в полночь. + +Если `archive.publish.zoneId` пуст, используется системная timezone сервера. При необходимости её можно задать явно, например `Europe/Warsaw`. Это сохраняет публикацию ровно в указанное локальное время даже при переходах летнего/зимнего времени. + +Незавершённый archive job после рестарта не ждёт следующей полуночи: сервер продолжает именно его сразу. Новый snapshot при старте вне назначенного времени не создаётся. + +## 6.1. Первая архивная публикация + +Если у данного archive publisher ещё нет подтверждённых архивных курсоров, первая публикация берёт ВСЁ локально известное состояние: + +```text +для каждой blockchain_name: +source block 0 .. current local head +``` + +То есть первый большой архивный блок содержит все SHiNE-блоки, которые сервер успел узнать к моменту первого суточного snapshot. После успешной публикации следующие большие блоки содержат только дельту относительно подтверждённых курсоров. + +## 6.2. Локальная папка и имена файлов + +Перед любой сетевой загрузкой большой блок сначала полностью создаётся на локальном диске. По умолчанию каталог: + +```text +data/archive/ +``` + +До получения Arweave TX ID файл имеет временное имя: + +```text +.<00001>.<дд.мм.гг>.tmp.SHiNE-archive +``` + +Например: + +```text +archive01.00001.11.09.26.tmp.SHiNE-archive +``` + +Дата — реальная дата создания/freeze snapshot большого блока в timezone archive publisher-а. Номер имеет минимальную ширину 5 цифр. Пять цифр — форматирование, а не лимит: блок `100000` получает шестизначный номер. + +После успешной загрузки Arweave возвращает реальный TX ID. Сервер сначала надёжно сохраняет TX ID в БД, затем атомарно переименовывает тот же локальный файл в: + +```text +.<00001>.<дд.мм.гг>..SHiNE-archive +``` + +Например: + +```text +archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive +``` + +Поле `` — не слово `trx`, а настоящий Base64URL TX ID загруженного объекта в Arweave. Финальный файл остаётся локально как постоянная копия. + +Crash recovery обязан продолжать работу с этим же файлом. Если TX ID уже сохранён, но процесс упал до rename, при следующем запуске сервер вычисляет финальное имя из сохранённого TX ID и завершает переименование без повторной сборки дельты. --- @@ -380,7 +439,7 @@ Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. archive.maxFileBytes=4000000000 ``` -Если данных больше, один scheduler-run формирует несколько последовательных больших блоков. +Если собранный frozen job превышает этот лимит, v1.0 останавливает публикацию с явной ошибкой `ArchiveTooLargeException` и не двигает курсоры. Практически лимит очень велик; для такого сервера следует уменьшить объём данных между суточными закрытиями или реализовать деление snapshot на несколько big blocks. Автоматическое деление одного snapshot на несколько big blocks оставлено как совместимое будущее расширение. --- @@ -480,15 +539,17 @@ bytes[N] creator_login UTF-8 u32 ``` -Рекомендуемая нумерация: +Рекомендуемая нумерация опубликованных больших блоков: ```text -genesis = 0 -next = 1 -next = 2 +first = 1 +next = 2 +next = 3 ... ``` +Первый реально публикуемый большой блок имеет номер `1`, поэтому его локальное имя содержит `00001`. Неудачная незавершённая попытка не становится частью опубликованной archive-цепочки. + --- # 20. `created_at_ms` @@ -530,9 +591,9 @@ Writer v1.0 MUST использовать `FULL`. BigBlock #365 References: -#0 #1 #2 +#3 ... #364 ``` @@ -595,7 +656,7 @@ u32 Индекс непосредственного родителя внутри reference table. -Для genesis: +Для первого большого блока (`#1`), у которого нет родителя: ```text 0xFFFFFFFF @@ -1061,7 +1122,7 @@ last_chunk_size = new_chunk_size # 54. Arweave service -Старый `TestFreeAvatarArweaveService` больше не нужен как avatar-specific сервис. +Старый `TestFreeAvatarArweaveService` удаляется из активного протокола; archive publisher использует отдельный `ArweaveArchiveService`. Его следует переделать/переименовать, например в: @@ -1307,8 +1368,9 @@ derivePublic(client_private) == UserPDA.client_key ```properties archive.publish.enabled=false -archive.publish.intervalMinutes=720 -archive.publish.initialDelayMinutes=15 +archive.publish.time=00:00 +archive.publish.zoneId= +archive.workDir=data/archive archive.maxFileBytes=4000000000 @@ -1321,6 +1383,9 @@ archive.arweave.confirmTimeoutMinutes=180 archive.solana.rootKeyPath=/opt/shine/secrets/root.key archive.solana.clientKeyPath=/opt/shine/secrets/client.key archive.solana.commitment=finalized + +# Отдельного archive.solana.rpcUrl нет. +# Используется solana.users.sync.rpcUrl, а если он пуст — обычный solana.rpcUrl. ``` --- @@ -1381,54 +1446,55 @@ Lock не удерживается во время Arweave/Solana ожидани ## Database -- [ ] `archive_chain_cursor` -- [ ] `archive_publish_job` -- [ ] `archive_publish_job_chain` -- [ ] schema migration -- [ ] crash recovery +- [x] `archive_chain_cursor` +- [x] `archive_publish_job` +- [x] `archive_publish_job_chain` +- [x] schema migration +- [x] crash recovery ## Archive writer -- [ ] magic `SHINE-ARCHIVE` -- [ ] major/minor version -- [ ] fixed header -- [ ] creator login -- [ ] FULL previous big block table -- [ ] one chunk per `blockchain_name` -- [ ] many raw records inside one chunk -- [ ] one backlink per chunk -- [ ] closer login -- [ ] SHA-256 -- [ ] Ed25519 signature -- [ ] max file size < 4 GiB +- [x] magic `SHINE-ARCHIVE` +- [x] major/minor version +- [x] fixed header +- [x] creator login +- [x] FULL previous big block table +- [x] one chunk per `blockchain_name` +- [x] many raw records inside one chunk +- [x] one backlink per chunk +- [x] closer login +- [x] SHA-256 +- [x] Ed25519 signature +- [x] max file size < 4 GiB +- [x] local `.tmp.SHiNE-archive -> ..SHiNE-archive` lifecycle in `data/archive` ## Arweave -- [ ] rename/refactor `TestFreeAvatarArweaveService` -- [ ] remove avatar-specific logic -- [ ] large/chunked upload -- [ ] confirmation polling +- [x] rename/refactor `TestFreeAvatarArweaveService` +- [x] remove avatar-specific logic +- [x] large/chunked upload +- [x] confirmation polling ## Solana -- [ ] add PDA block type `100` -- [ ] update Rust codec -- [ ] update Java codec -- [ ] update JS codec/UI writer -- [ ] use ordinary `update_user_pda` -- [ ] server-side transaction writer -- [ ] root signature -- [ ] client fee payer -- [ ] wait for `finalized` +- [x] add PDA block type `100` +- [x] update Rust codec +- [x] update Java codec +- [x] update JS codec/UI writer +- [x] use ordinary `update_user_pda` +- [x] server-side transaction writer +- [x] root signature +- [x] client fee payer +- [x] wait for `finalized` ## Scheduler -- [ ] `archive.publish.enabled` -- [ ] `archive.publish.intervalMinutes` -- [ ] `archive.publish.initialDelayMinutes` -- [ ] no concurrent jobs -- [ ] split files > max size -- [ ] skip when no new blocks +- [x] `archive.publish.enabled` +- [x] `archive.publish.time` +- [x] `archive.publish.zoneId` +- [x] no concurrent jobs +- [ ] future: автоматическое split > max size (v1.0 сейчас безопасно останавливается без cursor commit) +- [x] skip when no new blocks --- @@ -1506,10 +1572,10 @@ BigBlock #100 | creator = server-A | +-- References - | #0 -> BigBlock #0 / hash / TX - | #1 -> BigBlock #1 / hash / TX + | ref[0] -> BigBlock #1 / hash / TX + | ref[1] -> BigBlock #2 / hash / TX | ... - | #99 -> BigBlock #99 / hash / TX + | ref[98] -> BigBlock #99 / hash / TX | +-- alice-001 chunk | records x4 diff --git a/docs/Solana/user_pda/README.md b/docs/Solana/user_pda/README.md index 877a827f..ac2e64c0 100644 --- a/docs/Solana/user_pda/README.md +++ b/docs/Solana/user_pda/README.md @@ -94,16 +94,17 @@ UserPdaRecordV1 | `40` | `AccessServersBlock` | Серверы доступа/relay. | | `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. | | `70` | `TrustedStateBlock` | Счетчик trusted-связей. | +| `100` | `ArchiveHeadBlock` | Текущая голова серверного SHINE-ARCHIVE: Arweave TX ID + SHA-256 архива. | | `255` | `ReservedBlock` | Зарезервировано, пока не используется. | Правила: - неизвестный `block_type` в `format_major = 1` считается ошибкой; - обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`; -- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`; +- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`; - каждый обязательный блок должен встречаться ровно один раз; - порядок блоков в записи фиксируется для простоты проверки: - `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`. + `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`, `ArchiveHead`. ## 6. RecoveryKeyBlock @@ -359,6 +360,28 @@ TrustedStateBlock Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. +## 15.1. ArchiveHeadBlock + +Необязательный блок текущей головы серверного архива. Он используется archive-capable сервером и хранится в том же User PDA. + +```text +ArchiveHeadBlock +- block_type: u8 = 100 +- block_version: u8 = 0 +- archive_tx_id: [u8; 32] +- archive_hash: [u8; 32] +``` + +Семантика: + +- `archive_tx_id` — raw 32-byte Arweave transaction id последнего опубликованного большого `SHINE-ARCHIVE`; текстовая Base64URL-форма получается вне PDA; +- `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`; +- отсутствие block `100` означает, что аккаунт ещё не объявлял archive head; +- обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**; +- расширенный `update_user_pda` может заменить archive head или явно очистить его; отдельной Solana instruction для архива нет. + +`ArchiveHeadBlock` входит в unsigned bytes User PDA и тем самым покрывается обычной root-подписью записи. + ## 16. Подпись user_pda Подписывается не вся PDA целиком, а unsigned-часть записи: @@ -392,6 +415,7 @@ Solana-программа проверяет подпись через встр - обязательные блоки присутствуют; - создается минимум один `BlockchainRecord`; - новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`; +- `ArchiveHeadBlock` при регистрации не обязателен; обычный пользователь/сервер может начать публиковать архив позже; - стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит; - `used_bytes <= paid_limit_bytes`; - пользователь платит регистрационную комиссию; @@ -408,6 +432,7 @@ Solana-программа проверяет подпись через встр - `prev_record_hash` равен хэшу unsigned-части предыдущей записи; - `updated_at_ms` обновляется; - unsigned-часть новой записи подписана `root_key`; +- если archive extension в instruction отсутствует (legacy client), старый `ArchiveHeadBlock` сохраняется; если extension присутствует, применяется переданное `archive_head_update`; - лимиты блокчейнов могут только увеличиваться; - занятый размер и номер последнего блока не могут уменьшаться; - при увеличении оплаченного лимита пользователь доплачивает комиссию; diff --git a/docs/Solana_Architecture/details/shine_users.md b/docs/Solana_Architecture/details/shine_users.md index aa107cf8..aa7f6f4f 100644 --- a/docs/Solana_Architecture/details/shine_users.md +++ b/docs/Solana_Architecture/details/shine_users.md @@ -135,3 +135,23 @@ - economy-настройки меняет DAO-authority; - upgrade-authority программы после проверки передается DAO; - пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате. + +## ArchiveHeadBlock и серверный SHINE-ARCHIVE + +Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`): + +```text +archive_tx_id [32] +archive_hash [32] +``` + +Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`. + +Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим: + +- legacy payload без archive extension сохраняет старый block `100`; +- новый payload может заменить/очистить archive head; +- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью. + +Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а. + diff --git a/settings.gradle b/settings.gradle index 008cebfb..518f00ae 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,6 +9,7 @@ include 'shine-server-db' include 'shine-server-net-protocol' include 'shine-server-net-server' include 'shine-server-solana-users-sync' +include 'shine-server-archive' project(':shine-server-log').projectDir = file('SHiNE-server/shine-server-log') project(':shine-server-config').projectDir = file('SHiNE-server/shine-server-config') @@ -19,3 +20,4 @@ project(':shine-server-db').projectDir = file('SHiNE-server/shine-server-db') project(':shine-server-net-protocol').projectDir = file('SHiNE-server/shine-server-net-protocol') project(':shine-server-net-server').projectDir = file('SHiNE-server/shine-server-net-server') project(':shine-server-solana-users-sync').projectDir = file('SHiNE-server/shine-server-solana-users-sync') +project(':shine-server-archive').projectDir = file('SHiNE-server/shine-server-archive') diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 3f4124f8..58e1c570 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -3157,22 +3157,6 @@ 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]; diff --git a/shine-UI/js/services/shine-user-pda-service.js b/shine-UI/js/services/shine-user-pda-service.js index 41e10ddf..b98cfc93 100644 --- a/shine-UI/js/services/shine-user-pda-service.js +++ b/shine-UI/js/services/shine-user-pda-service.js @@ -1,4 +1,4 @@ -import { base58ToBytes, base64ToBytes, bytesToBase58, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; +import { base58ToBytes, base64ToBytes, base64UrlToBytes, bytesToBase58, bytesToBase64Url, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; import { extractSeed32FromPkcs8B64 } from './client-key-utils.js'; import { SHINE_LOGIN_GUARD_PROGRAM_ID, @@ -27,6 +27,7 @@ const BLOCK_TYPE_SERVER_PROFILE = 30; const BLOCK_TYPE_ACCESS_SERVERS = 40; const BLOCK_TYPE_SESSIONS = 50; const BLOCK_TYPE_TRUSTED_STATE = 70; +const BLOCK_TYPE_ARCHIVE_HEAD = 100; const SESSIONS_MODE_MIXED = 1; const SESSION_TYPE_USER = 1; const SESSION_TYPE_HOMESERVER = 100; @@ -326,6 +327,8 @@ function createPdaState({ sessionsMode, sessions, trustedCount, + archiveHeadTxId = '', + archiveHeadHash = null, }) { const serverProfile = isServer ? { addressFormatType: Number(addressFormatType || 0), @@ -360,6 +363,8 @@ function createPdaState({ sessionPubKey32: x?.sessionPubKey32 instanceof Uint8Array ? x.sessionPubKey32 : new Uint8Array(x?.sessionPubKey32 || 32), })) : [], trustedCount: Number(trustedCount || 0) & 0xff, + archiveHeadTxId: String(archiveHeadTxId || '').trim(), + archiveHeadHash: archiveHeadHash instanceof Uint8Array ? archiveHeadHash : new Uint8Array(archiveHeadHash || 32), }; } @@ -445,6 +450,8 @@ export function parseShineUserPda(dataBytes) { let sessionsMode = SESSIONS_MODE_MIXED; let sessions = []; let trustedCount = 0; + let archiveHeadTxId = ''; + let archiveHeadHash = new Uint8Array(32); for (let i = 0; i < blocksCount; i += 1) { const blockType = reader.readU8(); @@ -528,6 +535,11 @@ export function parseShineUserPda(dataBytes) { trustedCount = reader.readU8(); continue; } + if (blockType === BLOCK_TYPE_ARCHIVE_HEAD) { + archiveHeadTxId = bytesToBase64Url(reader.readBytes(32)); + archiveHeadHash = reader.readBytes(32); + continue; + } throw new Error(`Неизвестный блок PDA: ${blockType}`); } @@ -556,6 +568,8 @@ export function parseShineUserPda(dataBytes) { sessionsMode, sessions, trustedCount, + archiveHeadTxId, + archiveHeadHash, }); return { @@ -586,6 +600,8 @@ export function serializeUnsignedRecordFromState(stateLike) { sessionsMode: stateLike.sessionsMode, sessions: stateLike.sessions, trustedCount: stateLike.trustedCount, + archiveHeadTxId: stateLike.archiveHeadTxId, + archiveHeadHash: stateLike.archiveHeadHash, }); const buf = [0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0]; @@ -594,7 +610,8 @@ export function serializeUnsignedRecordFromState(stateLike) { pushU32LE(buf, state.recordNumber); for (const x of state.prevRecordHash) buf.push(x); pushStrU8(buf, state.login); - buf.push(state.isServer ? 8 : 7); + const hasArchiveHead = Boolean(state.archiveHeadTxId); + buf.push((state.isServer ? 8 : 7) + (hasArchiveHead ? 1 : 0)); buf.push(BLOCK_TYPE_RECOVERY_KEY, 0); for (const x of state.recoveryKey) buf.push(x); @@ -636,6 +653,16 @@ export function serializeUnsignedRecordFromState(stateLike) { buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, state.trustedCount & 0xff); + if (hasArchiveHead) { + const txId32 = base64UrlToBytes(state.archiveHeadTxId); + if (txId32.length !== 32 || state.archiveHeadHash.length !== 32) { + throw new Error('Archive head должен содержать TX ID/hash по 32 байта'); + } + buf.push(BLOCK_TYPE_ARCHIVE_HEAD, 0); + for (const x of txId32) buf.push(x); + for (const x of state.archiveHeadHash) buf.push(x); + } + const recordLen = buf.length + 64; buf[7] = recordLen & 0xff; buf[8] = (recordLen >>> 8) & 0xff; @@ -1037,6 +1064,8 @@ export async function updateShineUserPdaOnSolana({ serverProfile, accessServers, trustedCount, + archiveHeadTxId = '', + archiveHeadHash = null, }) { const current = await readShineUserPda({ login, solanaEndpoint }); const cleanLogin = current.login; @@ -1134,6 +1163,8 @@ export async function updateShineUserPdaOnSolana({ sessionsMode: current.sessionsMode, sessions: current.sessions, trustedCount: trustedCount == null ? current.trustedCount : trustedCount, + archiveHeadTxId: current.archiveHeadTxId, + archiveHeadHash: current.archiveHeadHash, }); const unsignedNext = serializeUnsignedRecordFromState(nextState); diff --git a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md index 877a827f..ac2e64c0 100644 --- a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md +++ b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md @@ -94,16 +94,17 @@ UserPdaRecordV1 | `40` | `AccessServersBlock` | Серверы доступа/relay. | | `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. | | `70` | `TrustedStateBlock` | Счетчик trusted-связей. | +| `100` | `ArchiveHeadBlock` | Текущая голова серверного SHINE-ARCHIVE: Arweave TX ID + SHA-256 архива. | | `255` | `ReservedBlock` | Зарезервировано, пока не используется. | Правила: - неизвестный `block_type` в `format_major = 1` считается ошибкой; - обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`; -- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`; +- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`; - каждый обязательный блок должен встречаться ровно один раз; - порядок блоков в записи фиксируется для простоты проверки: - `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`. + `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`, `ArchiveHead`. ## 6. RecoveryKeyBlock @@ -359,6 +360,28 @@ TrustedStateBlock Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. +## 15.1. ArchiveHeadBlock + +Необязательный блок текущей головы серверного архива. Он используется archive-capable сервером и хранится в том же User PDA. + +```text +ArchiveHeadBlock +- block_type: u8 = 100 +- block_version: u8 = 0 +- archive_tx_id: [u8; 32] +- archive_hash: [u8; 32] +``` + +Семантика: + +- `archive_tx_id` — raw 32-byte Arweave transaction id последнего опубликованного большого `SHINE-ARCHIVE`; текстовая Base64URL-форма получается вне PDA; +- `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`; +- отсутствие block `100` означает, что аккаунт ещё не объявлял archive head; +- обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**; +- расширенный `update_user_pda` может заменить archive head или явно очистить его; отдельной Solana instruction для архива нет. + +`ArchiveHeadBlock` входит в unsigned bytes User PDA и тем самым покрывается обычной root-подписью записи. + ## 16. Подпись user_pda Подписывается не вся PDA целиком, а unsigned-часть записи: @@ -392,6 +415,7 @@ Solana-программа проверяет подпись через встр - обязательные блоки присутствуют; - создается минимум один `BlockchainRecord`; - новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`; +- `ArchiveHeadBlock` при регистрации не обязателен; обычный пользователь/сервер может начать публиковать архив позже; - стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит; - `used_bytes <= paid_limit_bytes`; - пользователь платит регистрационную комиссию; @@ -408,6 +432,7 @@ Solana-программа проверяет подпись через встр - `prev_record_hash` равен хэшу unsigned-части предыдущей записи; - `updated_at_ms` обновляется; - unsigned-часть новой записи подписана `root_key`; +- если archive extension в instruction отсутствует (legacy client), старый `ArchiveHeadBlock` сохраняется; если extension присутствует, применяется переданное `archive_head_update`; - лимиты блокчейнов могут только увеличиваться; - занятый размер и номер последнего блока не могут уменьшаться; - при увеличении оплаченного лимита пользователь доплачивает комиссию; diff --git a/shine-solana/shine/doc/programs/shine_users.md b/shine-solana/shine/doc/programs/shine_users.md index f65b07a9..aaea7bbd 100644 --- a/shine-solana/shine/doc/programs/shine_users.md +++ b/shine-solana/shine/doc/programs/shine_users.md @@ -753,3 +753,23 @@ signature = Ed25519(blockchain_private_key, message_hash) - Anchor discriminator'ы и Anchor-ABI инструкций; - старые seed'ы, которые конфликтовали с уже существующим Anchor-состоянием в devnet; - внутренние helper-функции старой реализации. + +## ArchiveHeadBlock и серверный SHINE-ARCHIVE + +Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`): + +```text +archive_tx_id [32] +archive_hash [32] +``` + +Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`. + +Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим: + +- legacy payload без archive extension сохраняет старый block `100`; +- новый payload может заменить/очистить archive head; +- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью. + +Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а. + diff --git a/shine-solana/shine/programs/shine_users/src/lib.rs b/shine-solana/shine/programs/shine_users/src/lib.rs index 3e64daaa..9c469fe0 100644 --- a/shine-solana/shine/programs/shine_users/src/lib.rs +++ b/shine-solana/shine/programs/shine_users/src/lib.rs @@ -36,6 +36,7 @@ const BLOCK_TYPE_SERVER_PROFILE: u8 = 30; const BLOCK_TYPE_ACCESS_SERVERS: u8 = 40; const BLOCK_TYPE_SESSIONS: u8 = 50; const BLOCK_TYPE_TRUSTED_STATE: u8 = 70; +const BLOCK_TYPE_ARCHIVE_HEAD: u8 = 100; const BLOCK_VERSION_0: u8 = 0; const BLOCKCHAIN_TYPE_MAIN_USER: u8 = 1; const SESSIONS_MODE_MIXED: u8 = 1; @@ -163,6 +164,8 @@ pub struct UpdateUserPdaArgs { pub prev_hash: [u8; 32], pub additional_limit: u64, pub fields: UserMutableFields, + /// None = legacy instruction, archive head сохранить; Some(None) = очистить; Some(Some) = заменить. + pub archive_head_update: Option>, pub signature: [u8; 64], } @@ -210,6 +213,12 @@ pub struct BlockchainRecord { pub arweave_tx_id: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArchiveHeadRecord { + pub arweave_tx_id: [u8; 32], + pub archive_hash: [u8; 32], +} + #[derive(Clone, Debug)] pub struct UserRecord { pub created_at_ms: u64, @@ -230,6 +239,7 @@ pub struct UserRecord { pub sessions_mode: u8, pub sessions: Vec, pub trusted_count: u8, + pub archive_head: Option, pub signature: [u8; 64], } @@ -349,17 +359,34 @@ fn parse_create_args(r: &mut Reader<'_>) -> Result) -> Result { + let login = r.read_string_u8()?; + let recovery_key = r.read_pubkey()?; + let root_key = r.read_pubkey()?; + let created_at_ms = r.read_u64()?; + let updated_at_ms = r.read_u64()?; + let version = r.read_u32()?; + let prev_hash = r.read_fixed_32()?; + let additional_limit = r.read_u64()?; + let fields = parse_fields(r)?; + + // Backward compatibility: legacy update после trusted_count содержит ровно 64-byte root signature. + let archive_head_update = if r.remaining() > 64 { + let present = r.read_u8()?; + match present { + 0 => Some(None), + 1 => Some(Some(ArchiveHeadRecord { + arweave_tx_id: r.read_fixed_32()?, + archive_hash: r.read_fixed_32()?, + })), + _ => return Err(ProgramError::from(ShineUsersError::InvalidInstruction)), + } + } else { + None + }; + Ok(UpdateUserPdaArgs { - login: r.read_string_u8()?, - recovery_key: r.read_pubkey()?, - root_key: r.read_pubkey()?, - created_at_ms: r.read_u64()?, - updated_at_ms: r.read_u64()?, - version: r.read_u32()?, - prev_hash: r.read_fixed_32()?, - additional_limit: r.read_u64()?, - fields: parse_fields(r)?, - signature: r.read_fixed_64()?, + login, recovery_key, root_key, created_at_ms, updated_at_ms, version, prev_hash, + additional_limit, fields, archive_head_update, signature: r.read_fixed_64()?, }) } @@ -590,6 +617,7 @@ fn process_create_user_pda<'a>(program_id: &Pubkey, accounts: &'a [AccountInfo<' sessions_mode: args.fields.sessions_mode, sessions: args.fields.sessions, trusted_count: args.fields.trusted_count, + archive_head: None, signature: [0; 64], }; validate_blockchain_limits(&record.blockchain, 0, 0, true)?; @@ -713,6 +741,10 @@ fn build_update_record(old_record: &UserRecord, args: &UpdateUserPdaArgs, new_ba sessions_mode: args.fields.sessions_mode, sessions: args.fields.sessions.clone(), trusted_count: args.fields.trusted_count, + archive_head: match &args.archive_head_update { + None => old_record.archive_head.clone(), + Some(value) => value.clone(), + }, signature: [0; 64], }) } @@ -857,6 +889,7 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { let mut sessions_mode = SESSIONS_MODE_MIXED; let mut sessions = Vec::new(); let mut trusted_count = 0u8; + let mut archive_head: Option = None; for _ in 0..blocks_count { let block_type = read_u8_from(useful, &mut cursor)?; @@ -907,6 +940,13 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { BLOCK_TYPE_TRUSTED_STATE => { trusted_count = read_u8_from(useful, &mut cursor)?; } + BLOCK_TYPE_ARCHIVE_HEAD => { + require!(archive_head.is_none(), ShineUsersError::InvalidRecordData); + archive_head = Some(ArchiveHeadRecord { + arweave_tx_id: read_fixed_32_from(useful, &mut cursor)?, + archive_hash: read_fixed_32_from(useful, &mut cursor)?, + }); + } _ => return Err(ProgramError::from(ShineUsersError::InvalidRecordFormat)), } } @@ -933,6 +973,7 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { sessions_mode, sessions, trusted_count, + archive_head, signature, }) } @@ -986,7 +1027,7 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result, ProgramErro out.push(login_bytes.len() as u8); out.extend_from_slice(login_bytes); - let blocks_count = if record.is_server { 8 } else { 7 }; + let blocks_count = (if record.is_server { 8 } else { 7 }) + if record.archive_head.is_some() { 1 } else { 0 }; out.push(blocks_count); write_recovery_key_block(&mut out, record); write_root_key_block(&mut out, record); @@ -996,6 +1037,7 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result, ProgramErro write_access_servers_block(&mut out, record)?; write_sessions_block(&mut out, record)?; write_trusted_state_block(&mut out, record); + if let Some(archive_head) = &record.archive_head { write_archive_head_block(&mut out, archive_head); } let record_len = out.len().checked_add(64).ok_or(ProgramError::from(ShineUsersError::MathOverflow))?; require!(record_len <= u16::MAX as usize, ShineUsersError::RecordTooLarge); @@ -1045,6 +1087,7 @@ fn write_sessions_block(out: &mut Vec, record: &UserRecord) -> Result<(), Pr out.push(BLOCK_TYPE_SESSIONS); out.push(BLOCK_VERSION_0); out.push(record.sessions_mode); require!(record.sessions.len() <= MAX_SESSIONS, ShineUsersError::InvalidRecordData); out.push(record.sessions.len() as u8); for session in &record.sessions { out.push(session.session_type); out.push(session.session_version); write_len_prefixed_string(out, &session.session_name)?; out.extend_from_slice(session.session_pub_key.as_ref()); } Ok(()) } fn write_trusted_state_block(out: &mut Vec, record: &UserRecord) { out.push(BLOCK_TYPE_TRUSTED_STATE); out.push(BLOCK_VERSION_0); out.push(record.trusted_count); } +fn write_archive_head_block(out: &mut Vec, archive: &ArchiveHeadRecord) { out.push(BLOCK_TYPE_ARCHIVE_HEAD); out.push(BLOCK_VERSION_0); out.extend_from_slice(&archive.arweave_tx_id); out.extend_from_slice(&archive.archive_hash); } fn write_len_prefixed_string(out: &mut Vec, value: &str) -> Result<(), ProgramError> { let bytes = value.as_bytes(); require!(bytes.len() <= u8::MAX as usize, ShineUsersError::InvalidRecordData); out.push(bytes.len() as u8); out.extend_from_slice(bytes); Ok(()) } fn verify_record_signature_hash(instructions_sysvar: &AccountInfo, root_key: &Pubkey, signature: &[u8; 64], message_hash: &[u8]) -> Result<[u8; 64], ProgramError> { From 2df0a78eb204d29d11352d919bd049f5e2234ba20ac4fe5db0671286a0b3a938 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 12 Sep 2026 16:49:47 +0300 Subject: [PATCH 18/30] =?UTF-8?q?=D0=BC=D0=B5=D0=BB=D0=BA=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Archive/01_PROTOCOL_v1.0.md | 4 ++-- docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md | 4 ++-- docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/Archive/01_PROTOCOL_v1.0.md b/docs/Archive/01_PROTOCOL_v1.0.md index ce7950e2..c4a2db63 100644 --- a/docs/Archive/01_PROTOCOL_v1.0.md +++ b/docs/Archive/01_PROTOCOL_v1.0.md @@ -25,7 +25,7 @@ 9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; 10. только после Solana `finalized` переводит локальные курсоры на новую точку. -На первом этапе предполагается один архивный Raspberry-сервер, но формат изначально не должен запрещать появление других архиваторов. +На первом этапе предполагается один архивный server, но формат изначально не должен запрещать появление других архиваторов. --- @@ -143,7 +143,7 @@ BigBlock 70 # 5. Необходимые ключи архивного сервера -Архивный Raspberry/server хранит: +Архивный server хранит: ## 5.1. SHiNE root private key diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md index ce7950e2..c4a2db63 100644 --- a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md +++ b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md @@ -25,7 +25,7 @@ 9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; 10. только после Solana `finalized` переводит локальные курсоры на новую точку. -На первом этапе предполагается один архивный Raspberry-сервер, но формат изначально не должен запрещать появление других архиваторов. +На первом этапе предполагается один архивный server, но формат изначально не должен запрещать появление других архиваторов. --- @@ -143,7 +143,7 @@ BigBlock 70 # 5. Необходимые ключи архивного сервера -Архивный Raspberry/server хранит: +Архивный server хранит: ## 5.1. SHiNE root private key diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md index ce7950e2..c4a2db63 100644 --- a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md +++ b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md @@ -25,7 +25,7 @@ 9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; 10. только после Solana `finalized` переводит локальные курсоры на новую точку. -На первом этапе предполагается один архивный Raspberry-сервер, но формат изначально не должен запрещать появление других архиваторов. +На первом этапе предполагается один архивный server, но формат изначально не должен запрещать появление других архиваторов. --- @@ -143,7 +143,7 @@ BigBlock 70 # 5. Необходимые ключи архивного сервера -Архивный Raspberry/server хранит: +Архивный server хранит: ## 5.1. SHiNE root private key From 1be1d56599f5d86c7302d8638c37955fec00e484655e47b43a6270147a4df6d5 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 12 Sep 2026 16:59:35 +0300 Subject: [PATCH 19/30] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D0=B0?= =?UTF-8?q?=D1=80=D1=85=D0=B8=D0=B2=D0=BE=D0=B2=20=D1=81=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/archive/ArchiveImportConfig.java | 37 + .../archive/ArchiveImportScheduler.java | 54 + .../server/archive/ArchiveImportService.java | 229 ++ .../archive/ArchivePublisherService.java | 39 + .../server/archive/ShineArchiveReader.java | 101 + .../java/shine/db/DatabaseInitializer.java | 6 + .../db/archive/ArchiveBlockchainLocation.java | 14 + .../db/archive/ArchivePublisherHead.java | 14 + .../java/shine/db/dao/ArchiveImportDAO.java | 150 + .../main/resources/postgres/migration_v23.sql | 32 + .../src/main/resources/postgres/schema_v1.sql | 19 +- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../blockchain/Net_AddBlock_Handler.java | 45 +- ..._GetArchiveBlockchainLocation_Handler.java | 52 + ..._GetArchiveBlockchainLocation_Request.java | 9 + ...GetArchiveBlockchainLocation_Response.java | 16 + .../sync/service/SolanaUsersSyncService.java | 2 +- .../postgres/PostgresStorageRepository.java | 16 +- .../src/main/java/server/ws/WsServer.java | 5 +- .../src/main/resources/application.properties | 6 + docs/Archive/01_PROTOCOL_v1.0.md | 28 + docs/Archive/02_IMPLEMENTATION_MAP.md | 39 + docs/Archive/03_DEPLOY_TEST_SERVER.md | 59 +- docs/Archive/04_TEST_AND_OPERATIONS.md | 27 + .../Archive/05_PATCH_CONTENTS_AND_REMOVALS.md | 68 +- docs/Archive/06_FILE_MANIFEST.md | 79 +- docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md | 303 ++ docs/Archive/README.md | 16 +- .../archive-publisher.example.properties | 10 + shine-UI/Blockchain-Viewer.html | 3416 +++++++++++++++++ shine-UI/js/app.js | 2 + shine-UI/js/pages/blockchain-archive-view.js | 144 + shine-UI/js/pages/settings-view.js | 7 + shine-UI/js/services/auth-service.js | 8 + 34 files changed, 4942 insertions(+), 114 deletions(-) create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java create mode 100644 docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md create mode 100644 shine-UI/Blockchain-Viewer.html create mode 100644 shine-UI/js/pages/blockchain-archive-view.js diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java new file mode 100644 index 00000000..f4c58918 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java @@ -0,0 +1,37 @@ +package server.archive; + +import utils.config.AppConfig; + +import java.nio.file.Path; +import java.util.*; + +/** Настройки чтения архивов других доверенных SHiNE-серверов. Пустой whitelist = импорт выключен. */ +public record ArchiveImportConfig( + Set allowedPublishers, + int intervalMinutes, + String arweaveGateway, + Path workDir +) { + public static ArchiveImportConfig load() { + AppConfig c = AppConfig.getInstance(); + Set approved = new LinkedHashSet<>(); + String raw = c.getParam("archive.import.allowedPublishers"); + if (raw != null) { + for (String item : raw.split(",")) { + String login = item.trim().toLowerCase(Locale.ROOT); + if (!login.isEmpty()) approved.add(login); + } + } + String gateway = c.getParam("archive.arweave.gateway"); + if (gateway == null || gateway.isBlank()) gateway = "https://arweave.net"; + String dir = c.getParam("archive.import.workDir"); + if (dir == null || dir.isBlank()) dir = "data/archive-import"; + return new ArchiveImportConfig( + Set.copyOf(approved), + Math.max(1, c.getInt("archive.import.intervalMinutes", 60)), + gateway.trim(), + Path.of(dir)); + } + + public boolean enabled() { return !allowedPublishers.isEmpty(); } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java new file mode 100644 index 00000000..1fad1e6c --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java @@ -0,0 +1,54 @@ +package server.archive; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.*; + +/** + * Фоновая проверка уже синхронизированной локальной таблицы User PDA. + * Solana отдельно здесь не опрашивается: первый запуск через 10 секунд после старта, + * затем по умолчанию раз в час. + */ +public final class ArchiveImportScheduler { + private static final Logger log = LoggerFactory.getLogger(ArchiveImportScheduler.class); + private static ScheduledExecutorService executor; + + private ArchiveImportScheduler() {} + + public static synchronized void startOrLog() { + if (executor != null) return; + ArchiveImportConfig cfg = ArchiveImportConfig.load(); + if (!cfg.enabled()) { + log.info("Archive importer выключен: archive.import.allowedPublishers пуст"); + return; + } + try { + ArchiveImportService service = new ArchiveImportService(cfg); + executor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "archive-importer"); + t.setDaemon(true); + return t; + }); + executor.scheduleWithFixedDelay( + () -> { + try { service.pollOnce(); } + catch (Exception e) { log.error("Archive importer cycle failed", e); } + }, + 10, + Math.multiplyExact((long) cfg.intervalMinutes(), 60L), + TimeUnit.SECONDS); + log.info("Archive importer включён. approvedPublishers={} interval={}min", + cfg.allowedPublishers(), cfg.intervalMinutes()); + } catch (Exception e) { + log.error("Archive importer не запущен", e); + } + } + + public static synchronized void close() { + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java new file mode 100644 index 00000000..06b6cf80 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java @@ -0,0 +1,229 @@ +package server.archive; + +import blockchain.BchBlockEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; +import shine.db.archive.*; +import shine.db.dao.*; +import shine.db.entities.BlockchainStateEntry; + +import java.io.IOException; +import java.net.URI; +import java.net.http.*; +import java.nio.file.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.locks.ReentrantLock; + +/** Импорт archive head только от серверов из server-side whitelist. */ +public final class ArchiveImportService { + private static final Logger log = LoggerFactory.getLogger(ArchiveImportService.class); + private final ArchiveImportConfig cfg; + private final ArchiveImportDAO dao = ArchiveImportDAO.getInstance(); + private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance(); + private final BlocksDAO blocksDao = BlocksDAO.getInstance(); + private final ShineArchiveReader reader = new ShineArchiveReader(); + private final Net_AddBlock_Handler addBlockHandler = new Net_AddBlock_Handler(); + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + public ArchiveImportService(ArchiveImportConfig cfg) throws IOException { + this.cfg = Objects.requireNonNull(cfg); + Files.createDirectories(cfg.workDir()); + } + + /** + * Никаких отдельных Solana RPC запросов здесь нет. + * Читаем только solana_user_pda_current, которую уже обновляет обычный Solana users sync. + */ + public void pollOnce() throws Exception { + if (!cfg.enabled()) return; + for (ArchivePublisherHead head : dao.listPendingPublisherHeads(cfg.allowedPublishers())) { + try { + importHead(head); + } catch (Exception e) { + log.error("Archive import publisher={} tx={} failed", head.login(), head.archiveHeadTxId(), e); + } + } + } + + private void importHead(ArchivePublisherHead publisher) throws Exception { + String headTxId = publisher.archiveHeadTxId(); + byte[] headHash = hex(publisher.archiveHeadHash()); + + // Если процесс успел импортировать head, но упал до установки archive_imported=true, + // следующий цикл просто завершает отметку без повторного скачивания всей истории. + if (headTxId.equals(publisher.lastImportedArchiveTxId())) { + if (!dao.markHeadImported(publisher.login(), headTxId)) { + log.info("Archive head changed while finalizing import marker publisher={} oldHead={}", + publisher.login(), headTxId); + } + return; + } + + Path headFile = download(headTxId); + ShineArchiveReader.Document head; + try { + head = reader.readAndVerify(headFile, publisher.login(), headHash, publisher.rootKey()); + } finally { + Files.deleteIfExists(headFile); + } + + // FULL reference table позволяет догнать все archive blocks, пропущенные пока сервер был выключен. + List previous = new ArrayList<>(head.references()); + previous.sort(Comparator.comparingLong(ShineArchiveReader.Reference::bigBlockNumber)); + + int startIndex = findResumeIndex(previous, publisher.lastImportedArchiveTxId(), headTxId); + for (int i = startIndex; i < previous.size(); i++) { + ShineArchiveReader.Reference ref = previous.get(i); + importOne(publisher, ref.arweaveTxId(), ref.archiveHash()); + if (!dao.advanceImportCursor(publisher.login(), headTxId, ref.arweaveTxId())) { + throw new IllegalStateException("Archive head changed during import for publisher " + publisher.login()); + } + } + + // Head уже скачан и проверен выше — второй раз его из Arweave не качаем. + importDocument(publisher, headTxId, head); + if (!dao.advanceImportCursor(publisher.login(), headTxId, headTxId)) { + throw new IllegalStateException("Archive head changed during final import for publisher " + publisher.login()); + } + if (!dao.markHeadImported(publisher.login(), headTxId)) { + throw new IllegalStateException("Archive head changed before imported marker for publisher " + publisher.login()); + } + + log.info("Archive head fully imported publisher={} bigBlock={} tx={}", + publisher.login(), head.bigBlockNumber(), headTxId); + } + + /** + * Возвращает индекс первого ещё не импортированного previous big block. + * Пустой cursor = новый сервер, импортируем всю FULL историю. + */ + private static int findResumeIndex(List previous, + String lastImportedTxId, + String currentHeadTxId) { + String last = lastImportedTxId == null ? "" : lastImportedTxId.trim(); + if (last.isEmpty()) return 0; + if (last.equals(currentHeadTxId)) return previous.size(); + for (int i = 0; i < previous.size(); i++) { + if (last.equals(previous.get(i).arweaveTxId())) return i + 1; + } + throw new IllegalStateException( + "Последний импортированный archive TX не найден в FULL history текущего head: " + last); + } + + private void importOne(ArchivePublisherHead publisher, String txId, byte[] expectedHash) throws Exception { + Path file = download(txId); + try { + ShineArchiveReader.Document doc = reader.readAndVerify( + file, publisher.login(), expectedHash, publisher.rootKey()); + importDocument(publisher, txId, doc); + } finally { + Files.deleteIfExists(file); + } + } + + private void importDocument(ArchivePublisherHead publisher, String txId, + ShineArchiveReader.Document doc) throws Exception { + for (ShineArchiveReader.Chunk chunk : doc.chunks()) { + processChunk(publisher.login(), txId, doc, chunk); + } + log.info("Archive imported publisher={} bigBlock={} tx={} chunks={}", + publisher.login(), doc.bigBlockNumber(), txId, doc.chunks().size()); + } + + private void processChunk(String publisherLogin, String txId, + ShineArchiveReader.Document doc, + ShineArchiveReader.Chunk chunk) throws Exception { + ReentrantLock lock = BlockchainLocks.lockFor(chunk.blockchainName()); + lock.lock(); + try { + ensureState(chunk.blockchainName()); + BlockchainStateEntry state = stateDao.getByBlockchainName(chunk.blockchainName()); + int localLast = state == null ? -1 : state.getLastBlockNumber(); + long archiveLast = -1; + + for (byte[] raw : chunk.rawRecords()) { + BchBlockEntry block = new BchBlockEntry(raw); + archiveLast = Integer.toUnsignedLong(block.blockNumber); + + if (block.blockNumber <= localLast) { + byte[] localHash = blocksDao.getHashByNumber(chunk.blockchainName(), block.blockNumber); + if (!Arrays.equals(localHash, block.getHash32())) { + throw new IllegalStateException( + "Archive conflict " + chunk.blockchainName() + "#" + block.blockNumber); + } + continue; + } + + if (block.blockNumber != localLast + 1) { + throw new IllegalStateException( + "Archive gap " + chunk.blockchainName() + ": local=" + localLast + + " next=" + block.blockNumber); + } + + var result = addBlockHandler.addBlockFromArchive(chunk.blockchainName(), raw); + if (!result.ok()) { + throw new IllegalStateException( + "Archive AddBlock rejected " + chunk.blockchainName() + "#" + + block.blockNumber + ": " + result.reasonCode()); + } + localLast = block.blockNumber; + } + + if (archiveLast >= 0) { + dao.upsertLocation(new ArchiveBlockchainLocation( + chunk.blockchainName(), publisherLogin, txId, doc.archiveHash(), doc.bigBlockNumber(), + chunk.offset(), chunk.size(), archiveLast, System.currentTimeMillis())); + } + } finally { + lock.unlock(); + } + } + + private void ensureState(String blockchainName) throws Exception { + if (stateDao.getByBlockchainName(blockchainName) != null) return; + ArchiveImportDAO.BlockchainIdentity id = dao.getBlockchainIdentity(blockchainName); + if (id == null) throw new IllegalStateException("Нет User PDA для blockchain " + blockchainName); + + BlockchainStateEntry s = new BlockchainStateEntry(); + s.setBlockchainName(id.blockchainName()); + s.setLogin(id.login()); + s.setBlockchainKey(id.blockchainKey()); + s.setSizeLimit(id.sizeLimit()); + s.setFileSizeBytes(0); + s.setLastBlockNumber(-1); + s.setLastBlockHash(null); + s.setUpdatedAtMs(System.currentTimeMillis()); + stateDao.insertIfMissing(s); + } + + private Path download(String txId) throws Exception { + String gateway = cfg.arweaveGateway().replaceAll("/+$", ""); + Path target = Files.createTempFile(cfg.workDir(), "archive-", ".download"); + HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/" + txId)) + .timeout(Duration.ofMinutes(10)) + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofFile(target)); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + Files.deleteIfExists(target); + throw new IOException("Arweave HTTP " + resp.statusCode() + " tx=" + txId); + } + return target; + } + + private static byte[] hex(String value) { + String s = String.valueOf(value == null ? "" : value).trim(); + if (s.length() != 64) throw new IllegalArgumentException("archive_head_hash должен быть HEX64"); + byte[] out = new byte[32]; + for (int i = 0; i < 32; i++) { + out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16); + } + return out; + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java index a523f849..0826fc8d 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java @@ -8,6 +8,7 @@ import shine.db.archive.ArchiveChainCursor; import shine.db.archive.ArchivePublishJob; import shine.db.archive.ArchivePublishJobChain; import shine.db.dao.ArchivePublicationDAO; +import shine.db.dao.ArchiveImportDAO; import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlocksDAO; import shine.db.entities.BlockEntry; @@ -24,6 +25,7 @@ public final class ArchivePublisherService { private final ArchivePublisherConfig cfg; private final ArchivePublicationDAO archiveDao = ArchivePublicationDAO.getInstance(); + private final ArchiveImportDAO archiveImportDao = ArchiveImportDAO.getInstance(); private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance(); private final BlocksDAO blocksDao = BlocksDAO.getInstance(); private final ShineArchiveWriter writer = new ShineArchiveWriter(); @@ -40,6 +42,32 @@ public final class ArchivePublisherService { this.arweave = new ArweaveArchiveService(cfg); this.solana = new SolanaArchiveHeadWriter(cfg); this.solana.validateKeysAgainstPda(); + backfillOwnLocationIndex(); + } + + /** + * После обновления старого publisher-сервера заполняет универсальный location index + * из уже существующих finalized cursors, не дожидаясь следующего суточного archive block. + */ + private void backfillOwnLocationIndex() throws Exception { + Map finalized = new HashMap<>(); + for (ArchiveBigBlockRef ref : archiveDao.listFinalizedBigBlocks()) finalized.put(ref.bigBlockNumber(), ref); + if (finalized.isEmpty()) return; + long now = System.currentTimeMillis(); + int updated = 0; + for (BlockchainStateEntry state : stateDao.listAll()) { + if (state == null || state.getBlockchainName() == null || state.getBlockchainName().isBlank()) continue; + ArchiveChainCursor cursor = archiveDao.getCursor(state.getBlockchainName()); + if (cursor == null) continue; + ArchiveBigBlockRef ref = finalized.get(cursor.lastArchiveBigBlockNumber()); + if (ref == null || ref.arweaveTxId() == null || ref.arweaveTxId().length != 32) continue; + archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation( + cursor.blockchainName(), cfg.serverLogin(), b64url(ref.arweaveTxId()), + cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(), + cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), now)); + updated++; + } + if (updated > 0) log.info("Archive publisher: восстановлен location index для {} blockchain", updated); } /** После рестарта продолжает только уже существующий незавершённый job; новый snapshot не создаёт. */ @@ -285,6 +313,17 @@ public final class ArchivePublisherService { private void commitCursors(ArchivePublishJob job) throws Exception { archiveDao.commitCursors(job.id(), job.archiveHash()); + + // Тот же универсальный индекс используется и для собственных опубликованных архивов. + // Поэтому UI publisher-сервера получает ссылку даже если он не импортирует самого себя через whitelist. + String txId = b64url(job.arweaveTxId()); + long now = System.currentTimeMillis(); + for (ArchivePublishJobChain ch : archiveDao.listJobChains(job.id())) { + if (ch.newChunkOffset() == null || ch.newChunkSize() == null) continue; + archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation( + ch.blockchainName(), cfg.serverLogin(), txId, job.archiveHash(), job.bigBlockNumber(), + ch.newChunkOffset(), ch.newChunkSize(), ch.toSourceBlockNumber(), now)); + } } private static Path requireLocalFile(ArchivePublishJob job) { diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java new file mode 100644 index 00000000..9d705429 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java @@ -0,0 +1,101 @@ +package server.archive; + +import sync.util.Base58Util; +import utils.crypto.Ed25519Util; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; + +/** Строгий reader/verifier формата SHINE-ARCHIVE v1.0. */ +public final class ShineArchiveReader { + public record Reference(long bigBlockNumber, byte[] archiveHash, String arweaveTxId) {} + public record Chunk(long offset, long size, String blockchainName, List rawRecords, + long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {} + public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin, + List references, List chunks, byte[] archiveHash) {} + + public Document readAndVerify(Path file, String expectedPublisherLogin, byte[] expectedHash, String rootPublicKeyBase58) throws Exception { + long fileSize = Files.size(file); + if (fileSize < 13 + 38 + 1 + 32 + 64) throw new IOException("SHINE-ARCHIVE слишком короткий"); + if (fileSize >= 0x1_0000_0000L) throw new IOException("SHINE-ARCHIVE v1 >= 4 GiB не поддерживается"); + + byte[] computedHash = digestPrefix(file, fileSize - 96); + try (RandomAccessFile in = new RandomAccessFile(file.toFile(), "r")) { + byte[] magic = new byte[ShineArchiveWriter.MAGIC.length]; + in.readFully(magic); + if (!Arrays.equals(magic, ShineArchiveWriter.MAGIC)) throw new IOException("Bad SHINE-ARCHIVE magic"); + int major=in.readUnsignedByte(), minor=in.readUnsignedByte(); + if (major != 1 || minor != 0) throw new IOException("Unsupported SHINE-ARCHIVE version " + major + "." + minor); + long headerSize=u32(in); long bigBlock=u32(in); long createdAt=in.readLong(); + int creatorLen=in.readUnsignedByte(); int referencesMode=in.readUnsignedByte(); + long refsCount=u32(in); int refSize=in.readUnsignedShort(); long parentRef=u32(in); + long chunksCount=u32(in); long recordsCount=u32(in); + if (referencesMode != ShineArchiveWriter.REFERENCES_FULL || refSize != ShineArchiveWriter.REFERENCE_ENTRY_SIZE) { + throw new IOException("Unsupported references table"); + } + String creator=readUtf8(in,creatorLen); + if (!creator.equalsIgnoreCase(String.valueOf(expectedPublisherLogin))) { + throw new SecurityException("Archive creator " + creator + " != trusted publisher " + expectedPublisherLogin); + } + if (headerSize != in.getFilePointer()) throw new IOException("Некорректный header_size"); + if (refsCount > 10_000_000L || chunksCount > 10_000_000L) throw new IOException("Слишком много refs/chunks"); + + List refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE)); + for(long i=0;i= refs.size()) throw new IOException("parent_reference_index вне таблицы"); + + List chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE)); + long parsedRecords=0; + for(long ci=0;ci fileSize-96) throw new IOException("Некорректный chunk_size"); + int nameLen=in.readUnsignedByte(); String bch=readUtf8(in,nameLen); long count=u32(in); + if (count > 10_000_000L) throw new IOException("Слишком много records в chunk " + bch); + List records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE)); + for(long ri=0;riInteger.MAX_VALUE || in.getFilePointer()+n>chunkEnd-12) throw new IOException("Некорректный record_size"); + records.add(readFixed(in,(int)n)); parsedRecords++; + } + long prevRef=u32(in), prevOffset=u32(in), prevSize=u32(in); + if (prevRef != ShineArchiveWriter.NO_REFERENCE_U32 && prevRef >= refs.size()) throw new IOException("previous_big_block_ref вне таблицы"); + if (in.getFilePointer()!=chunkEnd) throw new IOException("Chunk framing mismatch: " + bch); + chunks.add(new Chunk(chunkOffset,chunkSize,bch,List.copyOf(records),prevRef,prevOffset,prevSize)); + } + if (parsedRecords != recordsCount) throw new IOException("total_user_records_count mismatch"); + + int closerLen=in.readUnsignedByte(); String closer=readUtf8(in,closerLen); + if(!closer.equals(creator)) throw new SecurityException("creator_login != closer_login"); + if(in.getFilePointer()!=fileSize-96) throw new IOException("Некорректная граница footer/hash"); + byte[] storedHash=readFixed(in,32); byte[] signature=readFixed(in,64); + if(in.getFilePointer()!=fileSize) throw new IOException("Лишние данные после signature"); + if(!Arrays.equals(storedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с footer"); + if(expectedHash!=null && !Arrays.equals(expectedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с ожидаемым hash"); + byte[] root=Base58Util.decode(rootPublicKeyBase58); + if(root.length!=32) throw new SecurityException("Некорректный root public key publisher-а"); + byte[] signed=new byte[ShineArchiveWriter.SIGNATURE_DOMAIN.length+32]; + System.arraycopy(ShineArchiveWriter.SIGNATURE_DOMAIN,0,signed,0,ShineArchiveWriter.SIGNATURE_DOMAIN.length); + System.arraycopy(computedHash,0,signed,ShineArchiveWriter.SIGNATURE_DOMAIN.length,32); + if(!Ed25519Util.verify(signed,signature,root)) throw new SecurityException("Не прошла подпись SHINE-ARCHIVE publisher-а"); + return new Document(bigBlock,createdAt,creator,List.copyOf(refs),List.copyOf(chunks),computedHash); + } + } + + private static byte[] digestPrefix(Path file,long bytes) throws Exception { + MessageDigest md=MessageDigest.getInstance("SHA-256"); + try(InputStream in=new BufferedInputStream(Files.newInputStream(file))) { + byte[] buf=new byte[1024*1024]; long left=bytes; + while(left>0){int n=in.read(buf,0,(int)Math.min(buf.length,left)); if(n<0) throw new EOFException(); md.update(buf,0,n); left-=n;} + } + return md.digest(); + } + private static long u32(RandomAccessFile in) throws IOException { return Integer.toUnsignedLong(in.readInt()); } + private static byte[] readFixed(RandomAccessFile in,int n) throws IOException { byte[] b=new byte[n]; in.readFully(b); return b; } + private static String readUtf8(RandomAccessFile in,int n) throws IOException { return new String(readFixed(in,n), StandardCharsets.UTF_8); } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 52a19ca8..fa153599 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -39,6 +39,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_20 = 20; public static final int SCHEMA_VERSION_21 = 21; public static final int SCHEMA_VERSION_22 = 22; + public static final int SCHEMA_VERSION_23 = 23; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -61,6 +62,7 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql"; public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql"; public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql"; + public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql"; private DatabaseInitializer() {} @@ -218,6 +220,10 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V22_RESOURCE); currentVersion = SCHEMA_VERSION_22; } + if (currentVersion < SCHEMA_VERSION_23) { + runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE); + currentVersion = SCHEMA_VERSION_23; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java new file mode 100644 index 00000000..111afa2e --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java @@ -0,0 +1,14 @@ +package shine.db.archive; + +/** Последний известный архивный chunk конкретной blockchain на этом сервере. */ +public record ArchiveBlockchainLocation( + String blockchainName, + String publisherLogin, + String arweaveTxId, + byte[] archiveHash, + long bigBlockNumber, + long chunkOffset, + long chunkSize, + long sourceLastBlockNumber, + long updatedAtMs +) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java new file mode 100644 index 00000000..a3ddeb6a --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java @@ -0,0 +1,14 @@ +package shine.db.archive; + +/** + * Текущая archive head trusted publisher-а из локальной таблицы, + * которую уже заполнил обычный Solana users sync. + */ +public record ArchivePublisherHead( + String login, + String rootKey, + String archiveHeadTxId, + String archiveHeadHash, + boolean archiveImported, + String lastImportedArchiveTxId +) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java new file mode 100644 index 00000000..6fd3def3 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java @@ -0,0 +1,150 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.archive.ArchiveBlockchainLocation; +import shine.db.archive.ArchivePublisherHead; + +import java.sql.*; +import java.util.*; + +/** БД индекс последних archive chunks и локальное состояние импорта archive head. */ +public final class ArchiveImportDAO { + private static volatile ArchiveImportDAO instance; + private final DbController db = DbController.getInstance(); + + private ArchiveImportDAO() {} + + public static ArchiveImportDAO getInstance() { + if (instance == null) synchronized (ArchiveImportDAO.class) { + if (instance == null) instance = new ArchiveImportDAO(); + } + return instance; + } + + /** + * Возвращает только доверенных publisher-ов, для которых обычный Solana users sync + * уже увидел archive head, но этот head ещё не был полностью импортирован локально. + */ + public List listPendingPublisherHeads(Set approvedLogins) throws SQLException { + if (approvedLogins == null || approvedLogins.isEmpty()) return List.of(); + List out = new ArrayList<>(); + String sql = """ + SELECT login, root_key, archive_head_tx_id, archive_head_hash, + archive_imported, archive_last_imported_tx_id + FROM solana_user_pda_current + WHERE LOWER(login)=LOWER(?) + AND is_server=TRUE + AND archive_head_tx_id<>'' + AND archive_head_hash<>'' + AND archive_imported=FALSE + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + for (String login : approvedLogins) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + out.add(new ArchivePublisherHead( + rs.getString("login"), + rs.getString("root_key"), + rs.getString("archive_head_tx_id"), + rs.getString("archive_head_hash"), + rs.getBoolean("archive_imported"), + rs.getString("archive_last_imported_tx_id"))); + } + } + } + } + return out; + } + + /** + * Сдвигает локальный курсор импорта внутри FULL archive chain. + * Update выполняется только если текущий head всё ещё тот же, с которым работает importer. + */ + public boolean advanceImportCursor(String publisherLogin, String expectedHeadTxId, String importedTxId) throws SQLException { + String sql = """ + UPDATE solana_user_pda_current + SET archive_last_imported_tx_id=? + WHERE LOWER(login)=LOWER(?) + AND archive_head_tx_id=? + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, importedTxId == null ? "" : importedTxId); + ps.setString(2, publisherLogin); + ps.setString(3, expectedHeadTxId); + return ps.executeUpdate() == 1; + } + } + + /** + * Помечает именно текущий head как полностью импортированный. + * Если Solana users sync успел заменить head на более новый, update не произойдёт. + */ + public boolean markHeadImported(String publisherLogin, String expectedHeadTxId) throws SQLException { + String sql = """ + UPDATE solana_user_pda_current + SET archive_imported=TRUE, + archive_last_imported_tx_id=archive_head_tx_id + WHERE LOWER(login)=LOWER(?) + AND archive_head_tx_id=? + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, publisherLogin); + ps.setString(2, expectedHeadTxId); + return ps.executeUpdate() == 1; + } + } + + public void upsertLocation(ArchiveBlockchainLocation e) throws SQLException { + String sql=""" + INSERT INTO archive_blockchain_location( + blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number, + chunk_offset,chunk_size,source_last_block_number,updated_at_ms) + VALUES(?,?,?,?,?,?,?,?,?) + ON CONFLICT(blockchain_name) DO UPDATE SET + publisher_login=EXCLUDED.publisher_login,arweave_tx_id=EXCLUDED.arweave_tx_id, + archive_hash=EXCLUDED.archive_hash,big_block_number=EXCLUDED.big_block_number, + chunk_offset=EXCLUDED.chunk_offset,chunk_size=EXCLUDED.chunk_size, + source_last_block_number=EXCLUDED.source_last_block_number,updated_at_ms=EXCLUDED.updated_at_ms + WHERE EXCLUDED.source_last_block_number >= archive_blockchain_location.source_last_block_number + """; + try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) { + ps.setString(1,e.blockchainName()); ps.setString(2,e.publisherLogin()); ps.setString(3,e.arweaveTxId()); + ps.setBytes(4,e.archiveHash()); ps.setLong(5,e.bigBlockNumber()); ps.setLong(6,e.chunkOffset()); + ps.setLong(7,e.chunkSize()); ps.setLong(8,e.sourceLastBlockNumber()); ps.setLong(9,e.updatedAtMs()); ps.executeUpdate(); + } + } + + public ArchiveBlockchainLocation getLocation(String blockchainName) throws SQLException { + String sql=""" + SELECT blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number, + chunk_offset,chunk_size,source_last_block_number,updated_at_ms + FROM archive_blockchain_location WHERE LOWER(blockchain_name)=LOWER(?) + """; + try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) { + ps.setString(1,blockchainName); + try(ResultSet rs=ps.executeQuery()) { + if(!rs.next()) return null; + return new ArchiveBlockchainLocation(rs.getString(1),rs.getString(2),rs.getString(3),rs.getBytes(4), + rs.getLong(5),rs.getLong(6),rs.getLong(7),rs.getLong(8),rs.getLong(9)); + } + } + } + + /** Данные PDA, нужные для создания пустого локального blockchain_state перед первым архивным импортом. */ + public BlockchainIdentity getBlockchainIdentity(String blockchainName) throws SQLException { + String sql=""" + SELECT login, blockchain_name, blockchain_key, paid_limit_bytes + FROM solana_user_pda_current WHERE LOWER(blockchain_name)=LOWER(?) LIMIT 1 + """; + try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) { + ps.setString(1,blockchainName); + try(ResultSet rs=ps.executeQuery()) { + if(!rs.next()) return null; + return new BlockchainIdentity(rs.getString(1),rs.getString(2),rs.getString(3),rs.getLong(4)); + } + } + } + + public record BlockchainIdentity(String login,String blockchainName,String blockchainKey,long sizeLimit) {} +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql new file mode 100644 index 00000000..664fcffe --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql @@ -0,0 +1,32 @@ +-- v23: trusted SHINE-ARCHIVE import state + last chunk location index. +-- Solana archive head itself is already copied by the normal Solana users sync (v22). + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending + ON solana_user_pda_current(is_server, archive_imported) + WHERE archive_head_tx_id <> ''; + +CREATE TABLE IF NOT EXISTS archive_blockchain_location ( + blockchain_name TEXT PRIMARY KEY, + publisher_login TEXT NOT NULL, + arweave_tx_id TEXT NOT NULL, + archive_hash BYTEA NOT NULL, + big_block_number BIGINT NOT NULL, + chunk_offset BIGINT NOT NULL, + chunk_size BIGINT NOT NULL, + source_last_block_number BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher + ON archive_blockchain_location(publisher_login); + +UPDATE db_schema_version +SET schema_version = 23, + updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) +WHERE id = 1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 337a4d71..d9c1365b 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -2083,8 +2083,25 @@ ALTER TABLE solana_user_pda_history ALTER TABLE solana_user_pda_history ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending + ON solana_user_pda_current(is_server, archive_imported) + WHERE archive_head_tx_id <> ''; + +CREATE TABLE IF NOT EXISTS archive_blockchain_location ( + blockchain_name TEXT PRIMARY KEY, publisher_login TEXT NOT NULL, arweave_tx_id TEXT NOT NULL, + archive_hash BYTEA NOT NULL, big_block_number BIGINT NOT NULL, chunk_offset BIGINT NOT NULL, + chunk_size BIGINT NOT NULL, source_last_block_number BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher ON archive_blockchain_location(publisher_login); + INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,22,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,23,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 10406a25..a7a9b21a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -42,8 +42,10 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetArchiveBlockchainLocation_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; +import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetArchiveBlockchainLocation_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; @@ -191,6 +193,7 @@ public final class JsonHandlerRegistry { // --- blockchain --- Map.entry("AddBlock", new Net_AddBlock_Handler()), Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()), + Map.entry("GetArchiveBlockchainLocation", new Net_GetArchiveBlockchainLocation_Handler()), // --- userParams --- Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()), @@ -283,6 +286,7 @@ public final class JsonHandlerRegistry { // --- blockchain --- Map.entry("AddBlock", Net_AddBlock_Request.class), Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class), + Map.entry("GetArchiveBlockchainLocation", Net_GetArchiveBlockchainLocation_Request.class), // --- userParams --- Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 0c10212e..8ccf7b43 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -102,7 +102,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { blockchainName, req.getBlockNumber(), // старое поле, пока оставляем req.getPrevBlockHash(), // старое поле, пока оставляем - req.getBlockBytesB64() + req.getBlockBytesB64(), + true ); // ✅ УСПЕХ: как раньше @@ -188,7 +189,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { String blockchainName, int globalNumberFromReq, String prevGlobalHashHexFromReq, - String blockBytesB64 + String blockBytesB64, + boolean replicateAfterWrite ) { if (blockchainName == null || blockchainName.isBlank()) { log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq); @@ -613,11 +615,48 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}", login, blockchainName, block.blockNumber, newHashHex); - addBlockSyncService.replicateAsync(blockchainName, block.blockNumber); + if (replicateAfterWrite) { + addBlockSyncService.replicateAsync(blockchainName, block.blockNumber); + } return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex); } + /** + * Архивный импорт использует ту же проверку и запись, что обычный AddBlock, + * но не запускает повторную межсерверную репликацию уже импортированного блока. + */ + public ArchiveImportResult addBlockFromArchive(String blockchainName, byte[] rawBlockBytes) { + if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) { + return new ArchiveImportResult(false, "chain_resync_in_progress", -1, ""); + } + if (rawBlockBytes == null || rawBlockBytes.length == 0) { + return new ArchiveImportResult(false, "empty_block", -1, ""); + } + final BchBlockEntry parsed; + try { + parsed = new BchBlockEntry(rawBlockBytes); + } catch (Exception e) { + return new ArchiveImportResult(false, "bad_block_format", -1, ""); + } + ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); + lock.lock(); + try { + AddBlockResult r = addBlock( + blockchainName, + parsed.blockNumber, + toHex(parsed.prevHash32), + Base64Ws.encode(rawBlockBytes), + false + ); + return new ArchiveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex); + } finally { + lock.unlock(); + } + } + + public record ArchiveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {} + /* ===================================================================== */ /* ====================== Helpers ====================================== */ /* ===================================================================== */ diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java new file mode 100644 index 00000000..210b2a6f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java @@ -0,0 +1,52 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.*; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.*; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.archive.ArchiveBlockchainLocation; +import shine.db.archive.ArchiveBigBlockRef; +import shine.db.archive.ArchiveChainCursor; +import shine.db.dao.ArchiveImportDAO; +import shine.db.dao.ArchivePublicationDAO; +import utils.config.AppConfig; + +import java.util.Base64; + +public final class Net_GetArchiveBlockchainLocation_Handler implements JsonMessageHandler { + @Override public Net_Response handle(Net_Request base, ConnectionContext ctx) { + Net_GetArchiveBlockchainLocation_Request req=(Net_GetArchiveBlockchainLocation_Request)base; + String bch=String.valueOf(req.getBlockchainName()==null?"":req.getBlockchainName()).trim(); + if(bch.isEmpty()) return NetExceptionResponseFactory.error(req,WireCodes.Status.BAD_REQUEST,"BAD_FIELDS","blockchainName обязателен"); + try { + ArchiveImportDAO importDao = ArchiveImportDAO.getInstance(); + ArchiveBlockchainLocation e=importDao.getLocation(bch); + if (e == null) { + // Backward-compatible fallback для publisher-а, который уже имел v22 cursors до появления v23 location index. + ArchivePublicationDAO publicationDao = ArchivePublicationDAO.getInstance(); + ArchiveChainCursor cursor = publicationDao.getCursor(bch); + if (cursor != null) { + ArchiveBigBlockRef matching = publicationDao.listFinalizedBigBlocks().stream() + .filter(ref -> ref.bigBlockNumber() == cursor.lastArchiveBigBlockNumber()) + .findFirst().orElse(null); + if (matching != null && matching.arweaveTxId() != null && matching.arweaveTxId().length == 32) { + String publisher = java.util.Objects.toString(AppConfig.getInstance().getParam("server.SHiNE.login"), "").trim(); + String txId = Base64.getUrlEncoder().withoutPadding().encodeToString(matching.arweaveTxId()); + e = new ArchiveBlockchainLocation( + bch, publisher, txId, cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(), + cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), System.currentTimeMillis()); + importDao.upsertLocation(e); + } + } + } + if(e==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.NOT_FOUND,"ARCHIVE_LOCATION_NOT_FOUND","Архивная ссылка пока не известна этому серверу"); + Net_GetArchiveBlockchainLocation_Response r=new Net_GetArchiveBlockchainLocation_Response(); + r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); + r.setBlockchainName(e.blockchainName()); r.setPublisherLogin(e.publisherLogin()); r.setArweaveTxId(e.arweaveTxId()); r.setArchiveHash(hex(e.archiveHash())); + r.setBigBlockNumber(e.bigBlockNumber()); r.setChunkOffset(e.chunkOffset()); r.setChunkSize(e.chunkSize()); r.setSourceLastBlockNumber(e.sourceLastBlockNumber()); return r; + } catch(Exception e) { return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"INTERNAL_ERROR",NetExceptionResponseFactory.detailedMessage("Не удалось получить архивную ссылку",e)); } + } + private static String hex(byte[] b){if(b==null)return"";StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java new file mode 100644 index 00000000..a8189cb4 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java @@ -0,0 +1,9 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public final class Net_GetArchiveBlockchainLocation_Request extends Net_Request { + private String blockchainName; + public String getBlockchainName(){return blockchainName;} + public void setBlockchainName(String value){this.blockchainName=value;} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java new file mode 100644 index 00000000..cbe64495 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java @@ -0,0 +1,16 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public final class Net_GetArchiveBlockchainLocation_Response extends Net_Response { + private String blockchainName,publisherLogin,arweaveTxId,archiveHash; + private long bigBlockNumber,chunkOffset,chunkSize,sourceLastBlockNumber; + public String getBlockchainName(){return blockchainName;} public void setBlockchainName(String v){blockchainName=v;} + public String getPublisherLogin(){return publisherLogin;} public void setPublisherLogin(String v){publisherLogin=v;} + public String getArweaveTxId(){return arweaveTxId;} public void setArweaveTxId(String v){arweaveTxId=v;} + public String getArchiveHash(){return archiveHash;} public void setArchiveHash(String v){archiveHash=v;} + public long getBigBlockNumber(){return bigBlockNumber;} public void setBigBlockNumber(long v){bigBlockNumber=v;} + public long getChunkOffset(){return chunkOffset;} public void setChunkOffset(long v){chunkOffset=v;} + public long getChunkSize(){return chunkSize;} public void setChunkSize(long v){chunkSize=v;} + public long getSourceLastBlockNumber(){return sourceLastBlockNumber;} public void setSourceLastBlockNumber(long v){sourceLastBlockNumber=v;} +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java index 5ef0b1c0..43325749 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java @@ -629,7 +629,7 @@ public final class SolanaUsersSyncService account.address(), account.slot(), account.dataBase64(), - state.lastSeenSignature() + "" ) ); } catch (Exception ignored) { diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java index 9f56f004..c8cbf1aa 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java @@ -299,13 +299,7 @@ public final class PostgresStorageRepository connection.setAutoCommit(false); - try (Statement statement = - connection.createStatement()) { - - statement.executeUpdate( - "TRUNCATE TABLE solana_user_pda_current" - ); - + try { upsertCurrentSnapshots( connection, snapshots @@ -494,6 +488,14 @@ public final class PostgresStorageRepository "last_block_hash = EXCLUDED.last_block_hash, " + "last_block_signature = EXCLUDED.last_block_signature, " + "arweave_tx_id = EXCLUDED.arweave_tx_id, " + + "archive_imported = CASE WHEN " + + "EXCLUDED.archive_head_tx_id IS DISTINCT FROM solana_user_pda_current.archive_head_tx_id " + + "OR EXCLUDED.archive_head_hash IS DISTINCT FROM solana_user_pda_current.archive_head_hash " + + "THEN FALSE ELSE solana_user_pda_current.archive_imported END, " + + "archive_last_imported_tx_id = CASE WHEN " + + "EXCLUDED.archive_head_tx_id = solana_user_pda_current.archive_head_tx_id " + + "AND EXCLUDED.archive_head_hash IS DISTINCT FROM solana_user_pda_current.archive_head_hash " + + "THEN '' ELSE solana_user_pda_current.archive_last_imported_tx_id END, " + "archive_head_tx_id = EXCLUDED.archive_head_tx_id, " + "archive_head_hash = EXCLUDED.archive_head_hash, " + "is_server = EXCLUDED.is_server, " + diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 7e76d99d..76ba4871 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -6,6 +6,7 @@ import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerI import org.slf4j.Logger; import org.slf4j.LoggerFactory; import server.archive.ArchivePublisherScheduler; +import server.archive.ArchiveImportScheduler; import server.debug.DebugApiConfigurator; import server.files.DmFileApiConfigurator; import server.sync.BlockchainResyncRecoveryOnStartup; @@ -85,6 +86,8 @@ public final class WsServer { // Опциональная серверная публикация больших SHINE-ARCHIVE блоков. ArchivePublisherScheduler.startOrLog(); + // Импорт больших архивов только от publisher-ов из archive.import.allowedPublishers. + ArchiveImportScheduler.startOrLog(); // ============================================================ // 2) Запуск Jetty WS @@ -114,7 +117,7 @@ public final class WsServer { log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); ServerConnectionPool.getInstance().startOrLog(); Runtime.getRuntime().addShutdownHook(new Thread( - () -> { ServerConnectionPool.getInstance().close(); ArchivePublisherScheduler.close(); }, + () -> { ServerConnectionPool.getInstance().close(); ArchivePublisherScheduler.close(); ArchiveImportScheduler.close(); }, "server-connection-pool-shutdown")); PeriodicDmDeliveryService.startOrLog(); server.join(); diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 6ce8cf97..577684e5 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -155,3 +155,9 @@ test.freeAvatar.limitPerUser=3 test.freeAvatar.maxBytes=131072 test.freeAvatar.walletAddress= test.freeAvatar.walletJwkPath= + +# Trusted SHINE-ARCHIVE import. Empty list = do not trust/import archives from anyone. +# Comma-separated SHiNE server logins, case-insensitive. +archive.import.allowedPublishers= +archive.import.intervalMinutes=60 +archive.import.workDir=data/archive-import diff --git a/docs/Archive/01_PROTOCOL_v1.0.md b/docs/Archive/01_PROTOCOL_v1.0.md index c4a2db63..8bef6d09 100644 --- a/docs/Archive/01_PROTOCOL_v1.0.md +++ b/docs/Archive/01_PROTOCOL_v1.0.md @@ -1617,3 +1617,31 @@ BigBlock #100 - PDA обновляется обычным `update_user_pda`; - Arweave используется как долговременное хранилище; - публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. + + +--- + +## Trusted archive reader / importer + +Бинарный формат v1.0 не меняется. Обычный сервер MAY использовать archive head из User PDA для восстановления недостающих SHiNE-блоков, но SHOULD принимать архивы только от явно разрешённых server logins. + +Рекомендуемая политика v1: + +```text +publisher login ∈ local whitelist +AND UserPDA.is_server = true +AND SHA256(file) == UserPDA.archive_head_hash / reference hash +AND creator_login == closer_login == publisher login +AND Ed25519 archive signature valid for publisher root key +AND every raw SHiNE block passes normal AddBlock validation +``` + +FULL reference table head-блока позволяет поздно подключившемуся серверу импортировать всю неизвестную историю publisher-а от старых big blocks к новым. + +Для быстрого пользовательского доступа сервер SHOULD поддерживать локальный индекс: + +```text +blockchain_name -> arweave_tx_id + chunk_offset + chunk_size +``` + +Он указывает на последний известный `UserBlockchainChunk`. Backlink внутри chunk делает эту одну ссылку достаточной для обхода всей архивной истории конкретной blockchain. diff --git a/docs/Archive/02_IMPLEMENTATION_MAP.md b/docs/Archive/02_IMPLEMENTATION_MAP.md index f0654733..e671af29 100644 --- a/docs/Archive/02_IMPLEMENTATION_MAP.md +++ b/docs/Archive/02_IMPLEMENTATION_MAP.md @@ -96,3 +96,42 @@ bytes[32] archive_hash ## 7. Legacy TestFreeAvatar Старый временный `TestFreeAvatarArweaveService` больше не является частью активного WS-протокола. Registry/API документация убраны. При наложении changed-files ZIP поверх старого дерева старые исходники физически останутся, поэтому их список для удаления находится в `05_PATCH_CONTENTS_AND_REMOVALS.md`. + + +## Trusted importer / location index / Viewer + +Server importer: + +```text +shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java +shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java +shine-server-archive/src/main/java/server/archive/ArchiveImportService.java +shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java +``` + +Database: + +```text +shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java +shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java +shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java +shine-server-db/src/main/resources/postgres/migration_v23.sql +shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java +``` + +`PostgresStorageRepository` сохраняет `archive_imported=true` при повторном sync того же head и автоматически сбрасывает флаг в `false`, если `archive_head_tx_id` или `archive_head_hash` изменились. + +WS API: + +```text +GetArchiveBlockchainLocation +``` + +UI: + +```text +shine-UI/js/pages/blockchain-archive-view.js +shine-UI/Blockchain-Viewer.html +``` + +`Blockchain-Viewer.html` получает `tx + offset + size + blockchain`, идёт назад по `PreviousBlockchainChunkRef` и использует существующий parser каналов старого Viewer-а. diff --git a/docs/Archive/03_DEPLOY_TEST_SERVER.md b/docs/Archive/03_DEPLOY_TEST_SERVER.md index 5b738ad7..6b6248f8 100644 --- a/docs/Archive/03_DEPLOY_TEST_SERVER.md +++ b/docs/Archive/03_DEPLOY_TEST_SERVER.md @@ -7,7 +7,7 @@ Нужны изменения одновременно в: 1. серверном Java-коде; -2. PostgreSQL schema v22; +2. PostgreSQL schema v23; 3. Solana-программе `shine_users` (PDA block type `100` + backward-compatible update parser); 4. Java/JS User PDA codecs. @@ -107,7 +107,7 @@ SHiNE-server/build/libs/shine-server.jar # 4. Подготовить PostgreSQL backup -Перед первым стартом версии со schema v22 сделать backup тестовой БД. Например: +Перед первым стартом версии со schema v23 сделать backup тестовой БД. Например: ```bash pg_dump -Fc -d '' -f shine-before-archive-v22.dump @@ -115,7 +115,7 @@ pg_dump -Fc -d '' -f shine-before-archive-v22.dump Точная команда зависит от текущей схемы доступа PostgreSQL. -При старте сервер сам применит `migration_v22.sql`, если `db_schema_version < 22`. Вручную migration выполнять обычно не нужно. +При старте сервер последовательно применит `migration_v22.sql` и `migration_v23.sql`, если это требуется текущей версии БД. Вручную migrations выполнять обычно не нужно. После старта проверить: @@ -126,7 +126,7 @@ SELECT * FROM db_schema_version WHERE id=1; Ожидается: ```text -schema_version = 22 +schema_version = 23 ``` И наличие: @@ -413,3 +413,54 @@ archive.publish.enabled=false ### Старый `shine_users` Если новый update payload отклоняется программой, проверить, что целевая Solana `shine_users` действительно обновлена этой версией. + + +## Trusted archive importer + +На обычном тестовом сервере publisher можно оставить выключенным, но разрешить импорт от конкретного архиватора: + +```properties +archive.publish.enabled=false +archive.import.allowedPublishers= +archive.import.intervalMinutes=60 +archive.import.workDir=data/archive-import +``` + +Несколько логинов: + +```properties +archive.import.allowedPublishers=server-a,server-b +``` + +Пустая строка означает, что importer не запускается. + +После старта проверить логи: + +```text +Archive importer включён. approvedPublishers=... +``` + +Если сервер подключается к publisher впервые, importer скачает head, прочитает FULL reference table и обработает все ещё не известные big blocks от старых к новым. + +Проверка БД: + +```sql +SELECT login, archive_head_tx_id, archive_imported, archive_last_imported_tx_id +FROM solana_user_pda_current +WHERE is_server=TRUE AND archive_head_tx_id<>'' +ORDER BY login; + +SELECT blockchain_name, publisher_login, arweave_tx_id, + big_block_number, chunk_offset, chunk_size, source_last_block_number +FROM archive_blockchain_location +ORDER BY updated_at_ms DESC +LIMIT 20; +``` + +После деплоя UI файл должен быть доступен по: + +```text +https:///Blockchain-Viewer.html +``` + +В приложении: `Настройки → Архив блокчейна`. diff --git a/docs/Archive/04_TEST_AND_OPERATIONS.md b/docs/Archive/04_TEST_AND_OPERATIONS.md index 7666ec5c..da32de67 100644 --- a/docs/Archive/04_TEST_AND_OPERATIONS.md +++ b/docs/Archive/04_TEST_AND_OPERATIONS.md @@ -84,3 +84,30 @@ archive.publish.enabled=false ``` Сервер должен запускаться без Arweave/root/client archive key files и не создавать `archive_publish_job`. + + +## Проверка trusted importer + +1. На принимающем сервере указать только тестовый publisher: + +```properties +archive.import.allowedPublishers= +``` + +2. Перезапустить сервер. +3. Дождаться Solana PDA sync и цикла importer-а. +4. Проверить, что у publisher в `solana_user_pda_current` после успешного цикла `archive_imported=true`, а `archive_last_imported_tx_id=archive_head_tx_id`. +5. Проверить `archive_blockchain_location`. +6. Для blockchain, которой локально не хватало блоков, убедиться, что `blockchain_state.last_block_number` вырос. +7. Для уже существующих блоков importer должен пропускать совпадающий hash, а не создавать дубликат. +8. Временно удалить publisher из whitelist и убедиться, что новые archive heads больше не скачиваются. + +### Проверка Viewer + +Открыть `Настройки → Архив блокчейна`, получить ссылку и проверить: + +- `tx`, `offset`, `size`, `blockchain` присутствуют; +- Viewer собирает несколько chunks по backlink; +- неправильный `blockchain` в URL приводит к ошибке проверки; +- `channel` открывает нужный канал; +- `message` прокручивает к нужному block number. diff --git a/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md b/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md index 9e461b61..fd2c6d5c 100644 --- a/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md +++ b/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md @@ -1,60 +1,38 @@ -# Состав patch-пакета и удаление legacy-файлов +# Состав текущего patch-пакета -ZIP `SHINE_archive_changed_files_with_docs.zip` предназначен для распаковки **поверх исходного дерева той версии сервера, из которой он был сделан**. Внутри находятся только новые и изменённые файлы, пути сохранены относительно корня репозитория. +Этот этап рассчитан **поверх последнего рабочего ZIP**, присланного после успешного запуска archive publisher. -## Важное ограничение ZIP-overlay +Пакет этого этапа добавляет: -Распаковка ZIP может добавить/заменить файлы, но не удалит старые. Поэтому после распаковки нужно удалить legacy test-free-avatar исходники ниже. Они больше не зарегистрированы в `JsonHandlerRegistry`, однако физическое удаление сохраняет дерево в точном состоянии новой версии и не оставляет старый тестовый Arweave-код рядом с production archive publisher. +- trusted archive importer; +- whitelist publisher-ов через настройки сервера; +- строгую проверку больших `SHINE-ARCHIVE`; +- импорт недостающих raw SHiNE blocks через обычный validator `AddBlock`; +- локальные поля состояния импорта в `solana_user_pda_current` и таблицу `archive_blockchain_location`; +- schema migration v23; +- WS API `GetArchiveBlockchainLocation`; +- экран `Настройки → Архив блокчейна`; +- `shine-UI/Blockchain-Viewer.html`; +- документацию importer/viewer. -## Удалить после распаковки +## Удаления -```text -SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java -SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java -docs/API/14_Test_Free_Avatar_Upload_API.md -``` +В **этом** обновлении удалять файлы не требуется. -Linux-команда из корня репозитория: +Старые test/free-avatar исходники, если они всё ещё физически присутствуют в рабочем дереве, этим patch-пакетом не затрагиваются. Они не относятся к trusted archive importer и не должны удаляться автоматически при наложении этого обновления. -```bash -rm -f \ - 'SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java' \ - 'SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java' \ - 'docs/API/14_Test_Free_Avatar_Upload_API.md' -``` +## Как накладывать ZIP changed-files -## Что пакет принципиально добавляет +ZIP содержит только новые/изменённые файлы с путями от корня репозитория. -- новый Gradle module `shine-server-archive`; -- migration/schema v22; -- archive state DAO/entities; -- server startup scheduler; -- Arweave chunk uploader; -- Solana User PDA writer; -- PDA block type `100` в Rust/Java/JS; -- обновлённую документацию формата User PDA; -- отдельную папку `docs/Archive/` с полным protocol/deploy/runbook. +Распаковать поверх той рабочей версии, из которой сделан пакет, с заменой совпадающих файлов. -## После overlay + удаления - -Минимально проверить: +После наложения: ```bash ./gradlew shadowJar ``` -И отдельно собрать/задеплоить изменённую Solana `shine_users` согласно `03_DEPLOY_TEST_SERVER.md`. +Если Gradle wrapper ещё не установлен локально, сначала обеспечить доступ к уже используемой версии Gradle/кэшу. + +При старте сервер сам должен поднять schema с v22 до v23. diff --git a/docs/Archive/06_FILE_MANIFEST.md b/docs/Archive/06_FILE_MANIFEST.md index c2a81b85..12662e6f 100644 --- a/docs/Archive/06_FILE_MANIFEST.md +++ b/docs/Archive/06_FILE_MANIFEST.md @@ -1,73 +1,50 @@ # Manifest changed/new files -Основа сравнения: последний исходный ZIP пользователя, на который рассчитан этот пакет. +Основа сравнения: последний присланный рабочий ZIP `3d14e34c-4249-4e11-8042-3f3349c8e9fd.zip`. -- Изменённых файлов: 20 -- Новых файлов до добавления этого manifest: 25 -- Удаляемых legacy-файлов: 10 +- Изменённых файлов: 19 +- Новых файлов: 14 +- Удаляемых файлов: 0 ## Изменённые файлы -- `SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java` - `SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java` - `SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql` - `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java` -- `SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java` - `SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java` - `SHiNE-server/src/main/java/server/ws/WsServer.java` - `SHiNE-server/src/main/resources/application.properties` -- `build.gradle` -- `docs/API/09_Operations_Index.md` -- `docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md` -- `docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md` -- `docs/Solana/user_pda/README.md` -- `docs/Solana_Architecture/details/shine_users.md` -- `settings.gradle` -- `shine-UI/js/services/auth-service.js` -- `shine-UI/js/services/shine-user-pda-service.js` -- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` -- `shine-solana/shine/doc/programs/shine_users.md` -- `shine-solana/shine/programs/shine_users/src/lib.rs` - -## Новые файлы - -- `SHiNE-server/shine-server-archive/build.gradle` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java` -- `SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java` -- `SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java` -- `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql` - `docs/Archive/01_PROTOCOL_v1.0.md` - `docs/Archive/02_IMPLEMENTATION_MAP.md` - `docs/Archive/03_DEPLOY_TEST_SERVER.md` - `docs/Archive/04_TEST_AND_OPERATIONS.md` - `docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md` +- `docs/Archive/06_FILE_MANIFEST.md` - `docs/Archive/README.md` - `docs/Archive/archive-publisher.example.properties` +- `shine-UI/js/app.js` +- `shine-UI/js/pages/settings-view.js` +- `shine-UI/js/services/auth-service.js` -## Legacy-файлы, которые ZIP сам не удаляет +## Новые файлы -- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java` -- `docs/API/14_Test_Free_Avatar_Upload_API.md` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java` +- `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java` +- `docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md` +- `shine-UI/Blockchain-Viewer.html` +- `shine-UI/js/pages/blockchain-archive-view.js` -Подробная команда удаления находится в `05_PATCH_CONTENTS_AND_REMOVALS.md`. +## Удаления + +На этом этапе файлов для удаления нет. diff --git a/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md b/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md new file mode 100644 index 00000000..51275c52 --- /dev/null +++ b/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md @@ -0,0 +1,303 @@ +# Импорт доверенных SHINE-ARCHIVE и Blockchain Viewer + +Этот документ описывает вторую половину архивной системы: как обычный SHiNE-сервер узнаёт о новых archive head других серверов, кому доверяет, как импортирует недостающие SHiNE-блоки и как UI получает ссылку на историю конкретной `blockchain_name`. + +## 1. Источник archive head + +Archive importer **не делает отдельные Solana RPC-запросы**. + +Уже существующий Solana Users Sync разбирает User PDA block type `100` и копирует его в локальную таблицу: + +```text +solana_user_pda_current.archive_head_tx_id +solana_user_pda_current.archive_head_hash +``` + +Для локального состояния importer v23 добавляет туда же: + +```text +archive_imported BOOLEAN +archive_last_imported_tx_id TEXT +``` + +Это локальные поля сервера, в Solana они не записываются. + +Когда обычный Solana Users Sync видит тот же archive head повторно, `archive_imported` сохраняется как есть. + +Когда `archive_head_tx_id` или `archive_head_hash` изменился: + +```text +archive_imported = false +``` + +а `archive_last_imported_tx_id` сохраняет последнюю успешно обработанную точку и позволяет продолжить после сбоя. + +## 2. Whitelist доверенных publisher-ов + +В `application.properties` задаётся список логинов серверов, архивы которых разрешено принимать: + +```properties +archive.import.allowedPublishers=archive-server-1,archive-server-2 +archive.import.intervalMinutes=60 +archive.import.workDir=data/archive-import +``` + +Правила: + +- логины разделяются запятыми; +- сравнение без учёта регистра; +- пустое `archive.import.allowedPublishers=` полностью выключает importer; +- принимаются только строки `solana_user_pda_current` с `is_server=true`; +- whitelist является только первым фильтром, криптографические проверки всё равно обязательны. + +## 3. Периодическая проверка + +После запуска сервера importer делает первую проверку примерно через 10 секунд, затем по умолчанию раз в 60 минут. + +Каждый цикл — дешёвый запрос только к локальной PostgreSQL: + +```text +approved publisher +AND is_server=true +AND archive_head_tx_id != '' +AND archive_imported=false +``` + +Если таких строк нет, Arweave не вызывается. + +## 4. Проверки archive block + +Для каждого pending publisher сервер проверяет: + +1. publisher находится в whitelist; +2. `archive_head_tx_id/archive_head_hash` уже пришли через обычный User PDA sync; +3. SHA-256 скачанного файла совпадает с `archive_head_hash`; +4. `creator_login == closer_login == publisher login`; +5. Ed25519 archive signature проверяется root key publisher-а из User PDA; +6. каждый вложенный raw SHiNE block проходит обычную SHiNE-проверку через существующий `AddBlock` path. + +Подпись большого архива защищает контейнер и навигацию, а подписи обычных SHiNE blocks защищают сами пользовательские данные. + +## 5. Как определяется, что head новый + +Отдельная таблица обработанных TX для основной логики не нужна. + +Текущий User PDA snapshot уже содержит: + +```text +archive_head_tx_id +archive_head_hash +archive_imported +archive_last_imported_tx_id +``` + +Пример: + +```text +archive_head_tx_id = TX100 +archive_imported = false +archive_last_imported_tx_id = TX97 +``` + +Это означает: Solana уже объявила `TX100` текущей головой publisher-а, но локальный сервер успел импортировать только до `TX97`. + +После полной успешной обработки `TX100`: + +```text +archive_imported = true +archive_last_imported_tx_id = TX100 +``` + +При следующем новом PDA head Users Sync сам сбросит `archive_imported=false`. + +## 6. Догон пропущенных больших блоков + +Если сервер был выключен и вместо `TX97` сразу увидел `TX100`, он скачивает и проверяет `TX100`, читает его FULL reference table и находит `TX97`. + +После этого импортирует только: + +```text +TX98 +TX99 +TX100 +``` + +После каждого полностью импортированного большого блока `archive_last_imported_tx_id` сдвигается вперёд. + +Если сервер впервые видит publisher и `archive_last_imported_tx_id` пустой, импортируются все previous refs от старых к новым, затем текущий head. + +Если непустой `archive_last_imported_tx_id` отсутствует в FULL history текущего head, importer останавливается: это рассматривается как возможная смена/fork archive chain, а не как повод молча забыть старый cursor. + +## 7. Crash recovery + +Если процесс упал после `TX98`, но до `TX100`: + +```text +archive_imported = false +archive_last_imported_tx_id = TX98 +``` + +Следующий часовой цикл продолжит с `TX99`. + +Если процесс успел импортировать head и записать `archive_last_imported_tx_id = TX100`, но упал до установки `archive_imported=true`, следующий цикл просто завершит отметку без повторной загрузки всей цепочки. + +Все cursor updates выполняются условно по ожидаемому `archive_head_tx_id`. Если обычный Solana Users Sync успел заменить head во время импорта, старый процесс не сможет пометить новый head импортированным. + +## 8. Импорт `UserBlockchainChunk` + +Для каждого chunk: + +1. берётся lock этой `blockchain_name`; +2. если локального `blockchain_state` нет, identity создаётся по синхронизированному User PDA; +3. raw records разбираются как обычные `BchBlockEntry`; +4. уже существующий block допускается только при совпадении hash; +5. новый block должен идти строго `localLast + 1`; +6. новый block добавляется существующим validator/write path; +7. конфликт hash или gap останавливает импорт этой archive chain. + +## 9. Индекс последнего archive chunk + +Таблица: + +```text +archive_blockchain_location +``` + +содержит для каждой `blockchain_name`: + +```text +blockchain_name +publisher_login +arweave_tx_id +archive_hash +big_block_number +chunk_offset +chunk_size +source_last_block_number +updated_at_ms +``` + +Если blockchain встретилась в новом archive block, её location обновляется. Если не встретилась — старая ссылка остаётся. + +Эту таблицу заполняют как trusted importer, так и локальный archive publisher. + +## 10. API для UI + +WS operation: + +```text +GetArchiveBlockchainLocation +``` + +Request: + +```json +{ + "op": "GetArchiveBlockchainLocation", + "blockchainName": "alice-001" +} +``` + +Response содержит: + +```text +blockchainName +publisherLogin +arweaveTxId +archiveHash +bigBlockNumber +chunkOffset +chunkSize +sourceLastBlockNumber +``` + +## 11. UI и ссылка Viewer + +В настройках пользователя есть экран `Архив блокчейна`. + +Viewer-файл: + +```text +shine-UI/Blockchain-Viewer.html +``` + +Основные параметры ссылки: + +```text +/Blockchain-Viewer.html + ?tx= + &offset= + &size= + &blockchain= +``` + +Дополнительно: + +```text +&channel= +&message= +``` + +`blockchain` используется также для проверки: если загруженный chunk имеет другое имя blockchain, Viewer прекращает обработку. + +`channel` открывает нужный канал, а `message` прокручивает к указанному сообщению/block number и выделяет его. + +## 12. Как Viewer собирает всю цепочку + +Viewer начинает с последнего `TX + offset + size`: + +```text +последний UserBlockchainChunk + ↓ +PreviousBlockchainChunkRef + ↓ +FULL reference table текущего big block + ↓ +TX предыдущего big block + ↓ +Range предыдущего chunk + ↓ +следующий backlink + ↓ +до NO_REFERENCE +``` + +Чужие chunks скачивать не требуется. + +## 13. Минимальная настройка принимающего сервера + +```properties +archive.publish.enabled=false +archive.import.allowedPublishers=server-a,server-b +archive.import.intervalMinutes=60 +archive.import.workDir=data/archive-import +``` + +Если импорт архивов не нужен: + +```properties +archive.import.allowedPublishers= +``` + +Тогда importer вообще не запускается. + +## 14. Диагностика PostgreSQL + +Pending archive heads: + +```sql +SELECT login, archive_head_tx_id, archive_imported, archive_last_imported_tx_id +FROM solana_user_pda_current +WHERE is_server = TRUE + AND archive_head_tx_id <> '' +ORDER BY login; +``` + +Последние известные пользовательские chunks: + +```sql +SELECT blockchain_name, publisher_login, arweave_tx_id, + big_block_number, chunk_offset, chunk_size, source_last_block_number +FROM archive_blockchain_location +ORDER BY updated_at_ms DESC; +``` diff --git a/docs/Archive/README.md b/docs/Archive/README.md index 743c9bf8..edf5b61d 100644 --- a/docs/Archive/README.md +++ b/docs/Archive/README.md @@ -9,7 +9,8 @@ 3. `03_DEPLOY_TEST_SERVER.md` — полный порядок установки на тестовый сервер, включая обязательный апгрейд `shine_users`, конфиг, ключи, сборку и запуск. 4. `04_TEST_AND_OPERATIONS.md` — что проверять до полуночи, после полуночи и при сбоях. 5. `05_PATCH_CONTENTS_AND_REMOVALS.md` — какие файлы содержит пакет и какие legacy test-free-avatar файлы нужно удалить при наложении ZIP поверх старого исходника. -6. `archive-publisher.example.properties` — минимальный конфиг архиватора. +6. `07_ARCHIVE_IMPORT_AND_VIEWER.md` — whitelist доверенных publisher-ов, импорт archive chain, индекс последнего chunk, UI и `Blockchain-Viewer.html`. +7. `archive-publisher.example.properties` — пример конфигурации publisher + importer. ## Коротко @@ -26,3 +27,16 @@ ## Важно перед тестом Изменён формат/парсер `shine_users`. **Нельзя просто заменить серверный JAR и включить archive publisher, если целевая Solana-программа `shine_users` ещё не обновлена кодом из этого пакета.** Сначала обновить программу на нужном кластере, затем сервер. + + +## Импорт архивов других серверов + +Импорт по умолчанию также выключен. Настройка: + +```properties +archive.import.allowedPublishers= +``` + +Пустой список означает: не доверять архивам ни одного внешнего сервера. Для разрешения перечислить логины через запятую. Подробности — `07_ARCHIVE_IMPORT_AND_VIEWER.md`. + +Текущая схема БД: **v23**. `v22` добавила publisher, `v23` добавляет локальные `archive_imported/archive_last_imported_tx_id`, trusted importer и универсальный `archive_blockchain_location`. diff --git a/docs/Archive/archive-publisher.example.properties b/docs/Archive/archive-publisher.example.properties index f9190f38..dd046cf0 100644 --- a/docs/Archive/archive-publisher.example.properties +++ b/docs/Archive/archive-publisher.example.properties @@ -21,3 +21,13 @@ archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key archive.solana.confirmPollSeconds=5 archive.solana.confirmTimeoutMinutes=30 archive.solana.commitment=finalized + + +# ============================================================= +# Trusted archive import (independent from publisher) +# Empty = do not import archives from any external server. +# Comma-separated SHiNE server logins, case-insensitive. +# ============================================================= +archive.import.allowedPublishers= +archive.import.intervalMinutes=60 +archive.import.workDir=data/archive-import diff --git a/shine-UI/Blockchain-Viewer.html b/shine-UI/Blockchain-Viewer.html new file mode 100644 index 00000000..7a54eda5 --- /dev/null +++ b/shine-UI/Blockchain-Viewer.html @@ -0,0 +1,3416 @@ + + + + + + SHiNE Blockchain Viewer + + + + + +
+
+ + + + + +
+ +
+ + +
+ +
Готово к загрузке.
+ +
+
+
После загрузки PDA viewer покажет список каналов.
+
+ + +
+
+ + diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index dcaf7fbf..bca3b962 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -73,6 +73,7 @@ import * as profileEditView from './pages/profile-edit-view.js'; import * as profilesView from './pages/profiles-view.js'; import * as walletView from './pages/wallet-view.js?v=202606281930'; import * as settingsView from './pages/settings-view.js'; +import * as blockchainArchiveView from './pages/blockchain-archive-view.js'; import * as accessServersView from './pages/access-servers-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as advancedSettingsView from './pages/advanced-settings-view.js'; @@ -140,6 +141,7 @@ const routes = { 'profiles-view': profilesView, 'wallet-view': walletView, 'settings-view': settingsView, + 'blockchain-archive-view': blockchainArchiveView, 'access-servers-view': accessServersView, 'developer-settings-view': developerSettingsView, 'advanced-settings-view': advancedSettingsView, diff --git a/shine-UI/js/pages/blockchain-archive-view.js b/shine-UI/js/pages/blockchain-archive-view.js new file mode 100644 index 00000000..14c73909 --- /dev/null +++ b/shine-UI/js/pages/blockchain-archive-view.js @@ -0,0 +1,144 @@ +import { createTopBar } from '../components/topbar.js'; +import { authService, state } from '../state.js'; + +export const pageMeta = { id: 'blockchain-archive-view', title: 'Архив блокчейна' }; + +function text(value) { + return String(value == null ? '' : value).trim(); +} + +function buildViewerUrl(location, blockchainName, channelName, messageNumber) { + const url = new URL('/Blockchain-Viewer.html', window.location.origin); + url.searchParams.set('tx', text(location.arweaveTxId)); + url.searchParams.set('offset', String(location.chunkOffset)); + url.searchParams.set('size', String(location.chunkSize)); + url.searchParams.set('blockchain', blockchainName); + const channel = text(channelName); + if (channel) url.searchParams.set('channel', channel); + const message = text(messageNumber); + if (message) url.searchParams.set('message', message); + return url.toString(); +} + +async function copyText(value) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + const area = document.createElement('textarea'); + area.value = value; + document.body.appendChild(area); + area.select(); + document.execCommand('copy'); + area.remove(); +} + +export function render({ navigate, chrome }) { + const screen = document.createElement('section'); + screen.className = 'stack'; + chrome?.setTopbar(createTopBar({ + title: 'Архив блокчейна', + back: { label: '←', onClick: () => navigate('settings-view') }, + })); + + const card = document.createElement('div'); + card.className = 'card stack'; + card.innerHTML = ` +
+ Последний архивный блок +
Загружаю...
+
+ + + + +
+ + +
+
+ Технические данные +

+    
+ `; + screen.appendChild(card); + + const status = card.querySelector('#archive-status'); + const blockchainInput = card.querySelector('#archive-blockchain'); + const channelInput = card.querySelector('#archive-channel'); + const messageInput = card.querySelector('#archive-message'); + const linkInput = card.querySelector('#archive-link'); + const copyButton = card.querySelector('#archive-copy'); + const openButton = card.querySelector('#archive-open'); + const tech = card.querySelector('#archive-tech'); + + let location = null; + let blockchainName = ''; + + function refreshLink() { + if (!location || !blockchainName) { + linkInput.value = ''; + copyButton.disabled = true; + openButton.disabled = true; + return; + } + linkInput.value = buildViewerUrl(location, blockchainName, channelInput.value, messageInput.value); + copyButton.disabled = false; + openButton.disabled = false; + } + + channelInput.addEventListener('input', refreshLink); + messageInput.addEventListener('input', refreshLink); + copyButton.addEventListener('click', async () => { + if (!linkInput.value) return; + await copyText(linkInput.value); + status.textContent = 'Ссылка скопирована.'; + }); + openButton.addEventListener('click', () => { + if (linkInput.value) window.open(linkInput.value, '_blank', 'noopener,noreferrer'); + }); + + void (async () => { + try { + const login = text(state.session.login); + if (!login) throw new Error('Нет активного пользователя'); + const user = await authService.getUser(login); + blockchainName = text(user.blockchainName); + if (!blockchainName) throw new Error('У пользователя не найден blockchainName'); + blockchainInput.value = blockchainName; + location = await authService.getArchiveBlockchainLocation(blockchainName); + if (!text(location.arweaveTxId) || !Number.isFinite(Number(location.chunkOffset)) || !Number.isFinite(Number(location.chunkSize))) { + throw new Error('Сервер вернул неполную архивную ссылку'); + } + status.textContent = `Известна архивная история до блока ${location.sourceLastBlockNumber ?? '—'}.`; + tech.textContent = [ + `publisher: ${text(location.publisherLogin) || '—'}`, + `archive big block: ${location.bigBlockNumber ?? '—'}`, + `Arweave TX: ${text(location.arweaveTxId)}`, + `archive hash: ${text(location.archiveHash) || '—'}`, + `chunk offset: ${location.chunkOffset}`, + `chunk size: ${location.chunkSize}`, + `source last block: ${location.sourceLastBlockNumber ?? '—'}`, + ].join('\n'); + refreshLink(); + } catch (error) { + status.textContent = `Архивная ссылка пока недоступна: ${error?.message || error}`; + tech.textContent = ''; + refreshLink(); + } + })(); + + return screen; +} diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index 20ad280e..44614949 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -71,6 +71,12 @@ export function render({navigate, chrome}) { Solana, SHiNE и Arweave для публичных данных + +
+ + + + + + +
—
+ + + +
—
+ +
+ Баланс + — + +
+ + +
+ + SOL +
+ +

+ + + + + `; + + const modal = root.querySelector('#channel-support-modal'); + const recipientSelect = root.querySelector('#channel-support-recipient-key'); + const senderSelect = root.querySelector('#channel-support-sender-key'); + const recipientAddressEl = root.querySelector('#channel-support-recipient-address'); + const senderAddressEl = root.querySelector('#channel-support-sender-address'); + const balanceEl = root.querySelector('#channel-support-balance'); + const amountEl = root.querySelector('#channel-support-amount'); + const statusEl = root.querySelector('#channel-support-status'); + const resultEl = root.querySelector('#channel-support-result'); + const submitEl = root.querySelector('#channel-support-submit'); + const refreshEl = root.querySelector('#channel-support-refresh'); + const walletCache = new Map(); + let closed = false; + let busy = false; + + const cleanupWallets = () => { + for (const wallet of walletCache.values()) { + try { + wallet?.keypair?.secretKey?.fill?.(0); + } catch { + // best effort: секрет существует только в памяти модального окна + } + } + walletCache.clear(); + }; + + const close = () => { + if (closed) return; + closed = true; + cleanupWallets(); + if (root.querySelector('#channel-support-modal') === modal) root.innerHTML = ''; + }; + + const setBusy = (nextBusy) => { + busy = Boolean(nextBusy); + if (submitEl) submitEl.disabled = busy || recipientChoices.length === 0; + if (refreshEl) refreshEl.disabled = busy; + if (recipientSelect) recipientSelect.disabled = busy || recipientChoices.length === 0; + if (senderSelect) senderSelect.disabled = busy; + if (amountEl) amountEl.disabled = busy; + }; + + const setStatus = (message, kind = '') => { + if (!statusEl) return; + statusEl.textContent = String(message || ''); + statusEl.classList.toggle('is-error', kind === 'error'); + }; + + const selectedRecipient = () => recipientChoices.find((choice) => choice.id === String(recipientSelect?.value || '')) || recipientChoices[0] || null; + + const updateRecipientAddress = () => { + const choice = selectedRecipient(); + if (recipientAddressEl) recipientAddressEl.textContent = choice?.address || 'Публичный ключ получателя недоступен'; + }; + + const resolveSenderWallet = async () => { + const keyId = String(senderSelect?.value || 'client-key'); + if (walletCache.has(keyId)) return walletCache.get(keyId); + + const login = String(state.session.login || '').trim(); + const storagePwd = String(state.session.storagePwdInMemory || '').trim(); + if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.'); + + let wallet; + if (keyId === 'root-key') { + try { + wallet = await getWalletFromStoredRootKey({ login, storagePwd }); + } catch { + const password = window.prompt( + 'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:', + '', + ); + if (password == null) throw new Error('Операция отменена.'); + const keyBundle = await authService.derivePasswordKeyBundle(login, password); + const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim(); + if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.'); + wallet = await getSolanaWalletFromStoredSecret(rootPrivate); + } + } else { + wallet = await getWalletFromStoredClientKey({ login, storagePwd }); + } + + walletCache.set(keyId, wallet); + return wallet; + }; + + const refreshBalance = async () => { + if (busy || closed) return; + setBusy(true); + setStatus('Загрузка баланса…'); + if (balanceEl) balanceEl.textContent = '—'; + try { + const wallet = await resolveSenderWallet(); + if (closed) return; + if (senderAddressEl) senderAddressEl.textContent = wallet.address; + const balance = await getBalanceSol({ + endpoint: state.entrySettings.solanaServer, + address: wallet.address, + }); + if (closed) return; + if (balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`; + setStatus(''); + } catch (error) { + if (closed) return; + if (senderAddressEl) senderAddressEl.textContent = '—'; + setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error'); + } finally { + if (!closed) setBusy(false); + } + }; + + recipientSelect?.addEventListener('change', updateRecipientAddress); + senderSelect?.addEventListener('change', () => { + if (senderAddressEl) senderAddressEl.textContent = '—'; + if (balanceEl) balanceEl.textContent = '—'; + setStatus(''); + void refreshBalance(); + }); + refreshEl?.addEventListener('click', () => void refreshBalance()); + root.querySelector('#channel-support-close')?.addEventListener('click', close); + modal?.addEventListener('pointerdown', (event) => { + if (event.target === modal) close(); }); + submitEl?.addEventListener('click', async () => { + if (busy || closed) return; + const recipient = selectedRecipient(); + if (!recipient?.address) { + setStatus('Не удалось определить счёт получателя.', 'error'); + return; + } + const amount = Number(String(amountEl?.value || '').replace(',', '.')); + if (!Number.isFinite(amount) || amount <= 0) { + setStatus('Введите сумму перевода больше 0.', 'error'); + return; + } + + setBusy(true); + setStatus('Отправляем перевод…'); + if (resultEl) { + resultEl.hidden = true; + resultEl.innerHTML = ''; + } + try { + const wallet = await resolveSenderWallet(); + const result = await transferSol({ + endpoint: state.entrySettings.solanaServer, + fromKeypair: wallet.keypair, + toAddress: recipient.address, + amountSol: amount, + }); + if (closed) return; + setStatus(''); + if (resultEl) { + resultEl.hidden = false; + resultEl.innerHTML = ` + Перевод выполнен ✓ + ${escapeHtml(formatSol(amount))} SOL → ${escapeHtml(recipient.label)} + ${escapeHtml(String(result.signature || ''))} + `; + } + showToast('Перевод отправлен'); + const balance = await getBalanceSol({ + endpoint: state.entrySettings.solanaServer, + address: wallet.address, + }); + if (!closed && balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`; + } catch (error) { + if (!closed) setStatus(toUserMessage(error, 'Не удалось выполнить перевод.'), 'error'); + } finally { + if (!closed) setBusy(false); + } + }); + + updateRecipientAddress(); + if (!recipientChoices.length) { + setStatus('У владельца канала нет доступных публичных ключей для перевода.', 'error'); + } else { + void refreshBalance(); + } + return close; +} + +function confirmUnsubscribeModal({ channelTitle }) { + const root = document.getElementById('modal-root'); + if (!root) return Promise.resolve(window.confirm('Отписаться от канала?')); + + return new Promise((resolve) => { + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + if (root.querySelector('#channel-unsubscribe-confirm-modal')) root.innerHTML = ''; + resolve(Boolean(value)); + }; + + root.innerHTML = ` + + `; + + const modal = root.querySelector('#channel-unsubscribe-confirm-modal'); + root.querySelector('#channel-unsubscribe-no')?.addEventListener('click', () => finish(false)); + root.querySelector('#channel-unsubscribe-yes')?.addEventListener('click', () => finish(true)); + modal?.addEventListener('pointerdown', (event) => { + if (event.target === modal) finish(false); + }); + }); +} + +export function render({ navigate, route, chrome }) { const screen = document.createElement('section'); screen.className = 'stack channels-screen channels-screen--channel-about'; const topbar = createTopBar({ - title: 'О канале', + title: '', back: { label: '←', onClick: () => { @@ -72,7 +423,7 @@ export function render({ navigate, route, chrome }) { navigateBack(); return; } - if (channelRoute) navigate(channelRoute); + navigate('channels-list'); }, ariaLabel: 'Назад', title: 'Назад', @@ -82,97 +433,210 @@ export function render({ navigate, route, chrome }) { const card = document.createElement('div'); card.className = 'card stack channel-about-card'; - card.innerHTML = ` -
-
Загрузка данных канала…
-
- `; - + const content = document.createElement('div'); + content.className = 'stack channel-about-content'; + content.innerHTML = '
Загрузка данных канала…
'; + card.append(content); screen.append(card); - const renderContent = (channel) => { + let disposed = false; + let refreshSeq = 0; + let closeSupportModal = null; + + const requireSigningSession = () => { + const login = String(state.session.login || '').trim(); + const storagePwd = String(state.session.storagePwdInMemory || '').trim(); + if (!login || !storagePwd) { + state.authReturnHash = window.location.pathname || '/channels'; + navigate('login-view'); + throw new Error('Для этого действия нужно войти.'); + } + return { login, storagePwd }; + }; + + const renderContent = ({ channel, selector, ownerUser, ownerProfile, isOwnChannel, isSubscribed }) => { const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim(); - const ownerName = String(channel?.ownerLogin || channel?.ownerName || 'автор').trim(); + const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector?.ownerBlockchainName) || '').trim(); + const channelName = String(channel?.channelName || '').trim(); const description = String(channel?.channelDescription || channel?.description || '').trim(); const subscribersCount = Number(channel?.subscribersCount || 0); - const aboutRoute = makeShineChannelRootRoute({ - ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName, - channelRootBlockNumber: channel?.channelRoot?.blockNumber ?? channelRootBlockNumber, - channelRootBlockHash: channel?.channelRoot?.blockHash ?? channelRootBlockHash, + const shortRoute = makeShineChannelShortRoute({ + ownerLogin, + ownerBlockchainName: selector?.ownerBlockchainName, + channelName, }); - const channelLinkRoute = makeShineChannelShortRoute({ - ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName, - channelName: channel?.channelName || '', + const donateRoute = makeShineChannelDonateRoute({ + ownerBlockchainName: selector?.ownerBlockchainName, + channelRootBlockNumber: selector?.channelRootBlockNumber, + channelRootBlockHash: selector?.channelRootBlockHash, }); - const channelLink = buildChannelLink(channelLinkRoute); - const changedAtMs = Number(channel?.metaUpdatedAtMs || 0); - const changedAtLabel = changedAtMs ? new Date(changedAtMs).toLocaleString('ru-RU') : '—'; - const avatarState = String(channel?.avaAr || '').trim() ? 'Установлен' : 'Не установлен'; + const serverLink = buildChannelLink(shortRoute); + const ownerName = ownerDisplayName(ownerProfile, ownerLogin); - const content = card.querySelector('#channel-about-content'); - if (!content) return; content.innerHTML = ` -
- -
-
- Дата${escapeHtml(changedAtLabel)} - Владелец${escapeHtml(ownerName)} - Подписчиков${escapeHtml(statsText(subscribersCount))} - Системное имя${escapeHtml(String(channel?.channelName || '').trim() || 'channel')} - Название${escapeHtml(cleanName)} - Описание${escapeHtml(description || 'Описание не задано.')} - Аватар${escapeHtml(avatarState)} - Ссылка${escapeHtml(channelLink)} -
-
- - +
+
+

${escapeHtml(cleanName)}

+
${escapeHtml(ownerLogin)} / ${escapeHtml(channelName)}
+
${escapeHtml(statsText(subscribersCount))} подписчиков
+ +
+

О канале

+

${escapeHtml(description || 'Описание не задано.')}

+
+ +
+

Владелец канала

+ + +
+ + +
+

Ссылка на этом сервере

+ +
+ + + ${isOwnChannel ? '' : ` + + `} `; + if (String(channel?.avaAr || '').trim()) { + const avatar = renderAvatar({ + initials: cleanName.slice(0, 1).toUpperCase() || 'К', + avatar: { ar: String(channel.avaAr || '').trim() }, + size: 'xl', + className: 'channel-about-avatar channel-profile-avatar', + title: cleanName, + alt: 'Аватар канала', + }); + avatar.style.setProperty('--channel-avatar-size', '112px'); + content.querySelector('#channel-about-avatar-slot')?.append(avatar); + } else { + content.querySelector('#channel-about-avatar-slot')?.remove(); + } + + content.querySelector('#channel-about-owner')?.addEventListener('click', () => { + const profileRoute = makeProfileRoute(ownerLogin); + if (profileRoute) navigate(profileRoute); + }); + content.querySelector('#channel-about-support')?.addEventListener('click', () => { + if (donateRoute) navigate(donateRoute); + }); content.querySelector('#channel-about-open')?.addEventListener('click', () => { - if (!channelLinkRoute) return; - navigate(channelLinkRoute); + if (shortRoute) navigate(shortRoute); }); content.querySelector('#channel-about-copy')?.addEventListener('click', async () => { - if (!channelLink) return; + if (!serverLink) return; try { - await navigator.clipboard.writeText(channelLink); + await navigator.clipboard.writeText(serverLink); showToast('Ссылка скопирована'); } catch (error) { showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' }); } }); + + const subscriptionButton = content.querySelector('#channel-about-subscription'); + subscriptionButton?.addEventListener('click', async () => { + if (subscriptionButton.disabled) return; + try { + const { login, storagePwd } = requireSigningSession(); + if (isSubscribed) { + const confirmed = await confirmUnsubscribeModal({ channelTitle: cleanName }); + if (!confirmed) return; + } + subscriptionButton.disabled = true; + await authService.addBlockFollowChannel({ + login, + storagePwd, + targetBlockchainName: selector.ownerBlockchainName, + targetBlockNumber: selector.channelRootBlockNumber, + targetBlockHashHex: selector.channelRootBlockHash, + unfollow: isSubscribed, + }); + if (disposed) return; + const feed = await authService.listSubscriptionsFeed(login, 1000); + if (disposed) return; + setChannelsFeed(feed, state.channelsIndex); + showToast(isSubscribed ? 'Вы отписались от канала' : 'Подписка на канал выполнена'); + void refresh(); + } catch (error) { + if (!disposed) { + showToast(toUserMessage(error, isSubscribed ? 'Не удалось отписаться от канала.' : 'Не удалось подписаться на канал.'), { kind: 'error' }); + subscriptionButton.disabled = false; + } + } + }); }; - const cached = findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash); - if (cached?.channel) { - renderContent({ - ...cached.channel, - subscribersCount: cached.channel.subscribersCount ?? cached.subscribersCount ?? 0, - }); - return screen; - } - - void (async () => { + const refresh = async () => { + const seq = ++refreshSeq; + content.innerHTML = '
Загрузка данных канала…
'; try { - const payload = await authService.getChannelMessages({ - ownerBlockchainName, - channelRootBlockNumber, - channelRootBlockHash, - }, 1, 'asc', String(state.session.login || '').trim()); - renderContent(payload?.channel || {}); - } catch (error) { - const content = card.querySelector('#channel-about-content'); - if (content) { - content.innerHTML = ` -
Не удалось загрузить данные канала.
-
${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}
- `; - } - } - })(); + const selector = await resolveChannelSelector(route); + const payload = await authService.getChannelMessages(selector, 1, 'asc', String(state.session.login || '').trim()); + if (disposed || seq !== refreshSeq) return; + const channel = payload?.channel || {}; + const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector.ownerBlockchainName) || '').trim(); + const currentLogin = String(state.session.login || '').trim(); + const isOwnChannel = Boolean(ownerLogin && currentLogin && ownerLogin.toLowerCase() === currentLogin.toLowerCase()); + + const [ownerUserResult, ownerProfileResult, feedResult] = await Promise.allSettled([ + authService.getUser(ownerLogin), + loadProfileSnapshot(ownerLogin), + currentLogin && !isOwnChannel ? authService.listSubscriptionsFeed(currentLogin, 1000) : Promise.resolve(null), + ]); + if (disposed || seq !== refreshSeq) return; + + const ownerUser = ownerUserResult.status === 'fulfilled' ? ownerUserResult.value : {}; + const ownerProfile = ownerProfileResult.status === 'fulfilled' ? ownerProfileResult.value : null; + const feed = feedResult.status === 'fulfilled' ? feedResult.value : null; + if (feed) setChannelsFeed(feed, state.channelsIndex); + const followedRows = Array.isArray(feed?.followedChannels) + ? feed.followedChannels + : (Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []); + const isSubscribed = !isOwnChannel && followedRows.some((row) => isSelectorMatch(row, selector)); + + renderContent({ + channel, + selector, + ownerUser, + ownerProfile, + isOwnChannel, + isSubscribed, + }); + } catch (error) { + if (disposed || seq !== refreshSeq) return; + content.innerHTML = ` +
Не удалось загрузить данные канала.
+
${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}
+ + `; + content.querySelector('#channel-about-retry')?.addEventListener('click', () => void refresh()); + } + }; + + screen.refresh = refresh; + screen.cleanup = () => { + if (disposed) return; + disposed = true; + refreshSeq += 1; + closeSupportModal?.(); + closeSupportModal = null; + }; + + void refresh(); return screen; } diff --git a/shine-UI/js/pages/channel-donate-view.js b/shine-UI/js/pages/channel-donate-view.js new file mode 100644 index 00000000..b6f5a2f0 --- /dev/null +++ b/shine-UI/js/pages/channel-donate-view.js @@ -0,0 +1,399 @@ +import { createTopBar } from '../components/topbar.js'; +import { authService, state } from '../state.js'; +import { navigateBack } from '../router.js'; +import { extractLoginFromBlockchainName } from '../services/shine-routes.js'; +import { + formatSol, + getBalanceSol, + getSolanaWalletFromStoredSecret, + getWalletFromStoredClientKey, + getWalletFromStoredRootKey, + solanaAddressFromPublicKeyBase64, + transferSol, +} from '../services/solana-wallet-service.js'; +import { showToast } from '../services/channels-ux.js'; +import { toUserMessage } from '../services/ui-error-texts.js'; + +export const pageMeta = { id: 'channel-donate-view', title: 'Донат автору' }; + +function escapeHtml(text) { + return String(text || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function normalizeHash(hash) { + const normalized = String(hash || '').trim().toLowerCase(); + return normalized || '0'; +} + +function toSafeInt(value) { + if (value === '' || value == null) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +async function resolveChannelSelector(route) { + const ownerRef = String(route?.params?.ownerBlockchainName || '').trim(); + const rootNo = toSafeInt(route?.params?.channelRootBlockNumber); + const rootHash = normalizeHash(route?.params?.channelRootBlockHash); + const channelName = String(route?.params?.channelName || '').trim(); + + if (ownerRef && rootNo != null) { + return { + ownerBlockchainName: ownerRef, + channelRootBlockNumber: rootNo, + channelRootBlockHash: rootHash, + channelName, + }; + } + + if (!ownerRef || !channelName) { + throw new Error('Не удалось определить канал из адреса страницы.'); + } + + const ownerLogin = extractLoginFromBlockchainName(ownerRef); + const ownerUser = await authService.getUser(ownerLogin); + if (!ownerUser?.exists) throw new Error('Владелец канала не найден.'); + + const ownerBlockchainName = String(ownerUser.blockchainName || ownerRef).trim(); + const ownerFeed = await authService.listSubscriptionsFeed(ownerLogin, 500); + const ownedRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; + const row = ownedRows.find((item) => ( + String(item?.channel?.channelName || '').trim().toLowerCase() === channelName.toLowerCase() + && String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBlockchainName.toLowerCase() + )); + if (!row?.channel?.channelRoot) throw new Error('Канал не найден.'); + + return { + ownerBlockchainName, + channelRootBlockNumber: Number(row.channel.channelRoot.blockNumber), + channelRootBlockHash: normalizeHash(row.channel.channelRoot.blockHash), + channelName, + }; +} + +function publicKeyChoice(ownerUser, field, id, label) { + const publicKeyB64 = String(ownerUser?.[field] || '').trim(); + if (!publicKeyB64) return null; + try { + return { + id, + label, + address: solanaAddressFromPublicKeyBase64(publicKeyB64), + }; + } catch { + return null; + } +} + +export function render({ navigate, route, chrome }) { + const screen = document.createElement('section'); + screen.className = 'stack channels-screen channels-screen--channel-donate'; + + const topbar = createTopBar({ + title: 'Донат автору', + back: { + label: '←', + onClick: () => { + if (window.history.length > 1) { + navigateBack(); + return; + } + navigate('channels-list'); + }, + ariaLabel: 'Назад', + title: 'Назад', + }, + }); + chrome?.setTopbar(topbar); + + const card = document.createElement('div'); + card.className = 'card stack channel-donate-card'; + const content = document.createElement('div'); + content.className = 'stack channel-donate-content'; + content.innerHTML = '
Загрузка данных канала…
'; + card.append(content); + screen.append(card); + + let disposed = false; + let refreshSeq = 0; + let busy = false; + const walletCache = new Map(); + + const cleanupWallets = () => { + for (const wallet of walletCache.values()) { + try { + wallet?.keypair?.secretKey?.fill?.(0); + } catch { + // Секретные ключи живут только в памяти этой страницы. + } + } + walletCache.clear(); + }; + + const renderDonationForm = ({ channel, selector, ownerUser }) => { + const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector?.ownerBlockchainName) || '').trim(); + const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim(); + const channelName = String(channel?.channelName || selector?.channelName || '').trim(); + const recipientChoices = [ + publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'), + publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'), + publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'), + ].filter(Boolean); + + content.innerHTML = ` +
+

Донат автору

+ ${escapeHtml(channelTitle)} + ${escapeHtml(ownerLogin)}${channelName ? ` / ${escapeHtml(channelName)}` : ''} +
+ +
+ + +
+ +
+ + +
—
+
+ +
+ + +
—
+ +
+ Баланс + — + +
+
+ +
+ +
+ + SOL +
+
+ +

+ + + `; + + const recipientSelect = content.querySelector('#channel-donate-recipient-key'); + const senderSelect = content.querySelector('#channel-donate-sender-key'); + const recipientAddressEl = content.querySelector('#channel-donate-recipient-address'); + const senderAddressEl = content.querySelector('#channel-donate-sender-address'); + const balanceEl = content.querySelector('#channel-donate-balance'); + const amountEl = content.querySelector('#channel-donate-amount'); + const statusEl = content.querySelector('#channel-donate-status'); + const resultEl = content.querySelector('#channel-donate-result'); + const submitEl = content.querySelector('#channel-donate-submit'); + const refreshEl = content.querySelector('#channel-donate-refresh'); + + const setBusy = (nextBusy) => { + busy = Boolean(nextBusy); + if (submitEl) submitEl.disabled = busy || recipientChoices.length === 0; + if (refreshEl) refreshEl.disabled = busy; + if (recipientSelect) recipientSelect.disabled = busy || recipientChoices.length === 0; + if (senderSelect) senderSelect.disabled = busy; + if (amountEl) amountEl.disabled = busy; + }; + + const setStatus = (message, kind = '') => { + if (!statusEl) return; + statusEl.textContent = String(message || ''); + statusEl.classList.toggle('is-error', kind === 'error'); + }; + + const selectedRecipient = () => ( + recipientChoices.find((choice) => choice.id === String(recipientSelect?.value || '')) + || recipientChoices[0] + || null + ); + + const updateRecipientAddress = () => { + const choice = selectedRecipient(); + if (recipientAddressEl) recipientAddressEl.textContent = choice?.address || 'Публичный ключ получателя недоступен'; + }; + + const resolveSenderWallet = async () => { + const keyId = String(senderSelect?.value || 'client-key'); + if (walletCache.has(keyId)) return walletCache.get(keyId); + + const login = String(state.session.login || '').trim(); + const storagePwd = String(state.session.storagePwdInMemory || '').trim(); + if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.'); + + let wallet; + if (keyId === 'root-key') { + try { + wallet = await getWalletFromStoredRootKey({ login, storagePwd }); + } catch { + const password = window.prompt( + 'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:', + '', + ); + if (password == null) throw new Error('Операция отменена.'); + const keyBundle = await authService.derivePasswordKeyBundle(login, password); + const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim(); + if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.'); + wallet = await getSolanaWalletFromStoredSecret(rootPrivate); + } + } else { + wallet = await getWalletFromStoredClientKey({ login, storagePwd }); + } + + walletCache.set(keyId, wallet); + return wallet; + }; + + const refreshBalance = async () => { + if (busy || disposed) return; + setBusy(true); + setStatus('Загрузка баланса…'); + if (balanceEl) balanceEl.textContent = '—'; + try { + const wallet = await resolveSenderWallet(); + if (disposed) return; + if (senderAddressEl) senderAddressEl.textContent = wallet.address; + const balance = await getBalanceSol({ + endpoint: state.entrySettings.solanaServer, + address: wallet.address, + }); + if (disposed) return; + if (balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`; + setStatus(''); + } catch (error) { + if (disposed) return; + if (senderAddressEl) senderAddressEl.textContent = '—'; + setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error'); + } finally { + if (!disposed) setBusy(false); + } + }; + + recipientSelect?.addEventListener('change', updateRecipientAddress); + senderSelect?.addEventListener('change', () => { + if (senderAddressEl) senderAddressEl.textContent = '—'; + if (balanceEl) balanceEl.textContent = '—'; + setStatus(''); + void refreshBalance(); + }); + refreshEl?.addEventListener('click', () => void refreshBalance()); + + submitEl?.addEventListener('click', async () => { + if (busy || disposed) return; + const recipient = selectedRecipient(); + if (!recipient?.address) { + setStatus('Не удалось определить счёт получателя.', 'error'); + return; + } + + const amount = Number(String(amountEl?.value || '').replace(',', '.')); + if (!Number.isFinite(amount) || amount <= 0) { + setStatus('Введите сумму перевода больше 0.', 'error'); + return; + } + + setBusy(true); + setStatus('Отправляем перевод…'); + if (resultEl) { + resultEl.hidden = true; + resultEl.innerHTML = ''; + } + + try { + const wallet = await resolveSenderWallet(); + const result = await transferSol({ + endpoint: state.entrySettings.solanaServer, + fromKeypair: wallet.keypair, + toAddress: recipient.address, + amountSol: amount, + }); + if (disposed) return; + + setStatus(''); + if (resultEl) { + resultEl.hidden = false; + resultEl.innerHTML = ` + Перевод выполнен ✓ + ${escapeHtml(formatSol(amount))} SOL → ${escapeHtml(recipient.label)} + ${escapeHtml(String(result.signature || ''))} + `; + } + showToast('Перевод отправлен'); + + const balance = await getBalanceSol({ + endpoint: state.entrySettings.solanaServer, + address: wallet.address, + }); + if (!disposed && balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`; + } catch (error) { + if (!disposed) setStatus(toUserMessage(error, 'Не удалось выполнить перевод.'), 'error'); + } finally { + if (!disposed) setBusy(false); + } + }); + + updateRecipientAddress(); + if (!recipientChoices.length) { + setStatus('У владельца канала нет доступных публичных ключей для перевода.', 'error'); + } else { + void refreshBalance(); + } + }; + + const refresh = async () => { + const seq = ++refreshSeq; + cleanupWallets(); + content.innerHTML = '
Загрузка данных канала…
'; + try { + const selector = await resolveChannelSelector(route); + const payload = await authService.getChannelMessages(selector, 1, 'asc', String(state.session.login || '').trim()); + if (disposed || seq !== refreshSeq) return; + + const channel = payload?.channel || {}; + const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector.ownerBlockchainName) || '').trim(); + const ownerUser = await authService.getUser(ownerLogin); + if (disposed || seq !== refreshSeq) return; + if (!ownerUser?.exists) throw new Error('Владелец канала не найден.'); + + renderDonationForm({ channel, selector, ownerUser }); + } catch (error) { + if (disposed || seq !== refreshSeq) return; + content.innerHTML = ` +
Не удалось открыть донат автору.
+
${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}
+ + `; + content.querySelector('#channel-donate-retry')?.addEventListener('click', () => void refresh()); + } + }; + + screen.refresh = refresh; + screen.cleanup = () => { + if (disposed) return; + disposed = true; + refreshSeq += 1; + cleanupWallets(); + }; + + void refresh(); + return screen; +} diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 0201d3b0..31eb013d 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -31,6 +31,7 @@ import { makeProfileRoute, makeShineMessageRoute, makeShineChannelAboutRoute, + makeShineChannelDonateRoute, } from '../services/shine-routes.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; @@ -669,7 +670,7 @@ function openChannelMetaDetailsModal({ function openAboutChannelModal(channel, options = {}) { openChannelMetaDetailsModal({ - title: 'О канале', + title: 'Описание канала', channel, canEdit: options.canEdit === true, onEdit: options.onEdit, @@ -1595,9 +1596,13 @@ async function loadFromApi(route, channelId) { try { const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 500); const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; + const ownerLoginNormalized = ownerLoginForLookup.toLowerCase(); channel = ownerRows.find((item) => ( - String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized - && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() + String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() + && ( + String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized + || String(item?.channel?.ownerLogin || '').trim().toLowerCase() === ownerLoginNormalized + ) )); } catch { // ignore owner feed lookup failures @@ -2535,7 +2540,7 @@ export function render({ navigate, route, chrome }) { actions: [ { label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} }, { - label: '⋯', + label: '⋮', title: 'Действия канала', ariaLabel: 'Открыть меню канала', className: 'channel-header-more-btn', @@ -2544,14 +2549,38 @@ export function render({ navigate, route, chrome }) { items: () => { const apiData = activeChannelData; if (!apiData) return []; - const aboutRoute = makeShineChannelAboutRoute({ + const routeArgs = { ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '', channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '', channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '', - }); - const items = [ - { label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } }, - ]; + }; + const aboutRoute = makeShineChannelAboutRoute(routeArgs); + const donateRoute = makeShineChannelDonateRoute(routeArgs); + const items = []; + if (apiData?.isOwnChannel && !apiData?.isDiary) { + items.push({ + label: 'Добавить сообщение', + action: () => { + openAddMessageModal({ + channelName: apiData?.channel?.name || '', + navigate, + isActive: () => !disposed, + onSubmit: async ({ text: bodyText, msgSubType }) => { + try { + await onAddPost(bodyText, msgSubType); + showStatus(''); + } catch (error) { + throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.')); + } + }, + }); + }, + }); + } + items.push({ label: 'Описание канала', action: () => { if (aboutRoute) navigate(aboutRoute); } }); + if (!apiData?.isOwnChannel) { + items.push({ label: 'Поддержать автора', action: () => { if (donateRoute) navigate(donateRoute); } }); + } if (!apiData?.isOwnChannel && !isStoriesChannel(apiData?.channel)) { if (apiData?.isSubscribed) { items.push({ diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 35c7d02f..1baff6e5 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -28,6 +28,7 @@ const PRETTY_PATHS = new Map([ ['add-personal-public-chat-view', 'channels/new-public-chat'], ['channel-view', 'channel'], ['channel-about-view', 'channel/about'], + ['channel-donate-view', 'channel/donate'], ['channel-thread-view', 'thread'], ['network-view', 'network'], ['notifications-view', 'notifications'], @@ -180,9 +181,9 @@ export function parseRouteFromPath(pathname = '') { const channelName = decodePart(segments[1] || ''); const sub = decodePart(segments[2] || '').toLowerCase(); if (ownerBlockchainName && channelName) { - if (sub === 'about') { + if (sub === 'about' || sub === 'donate') { return { - pageId: 'channel-about-view', + pageId: sub === 'donate' ? 'channel-donate-view' : 'channel-about-view', params: { ownerBlockchainName, channelRootBlockNumber: '', @@ -270,16 +271,19 @@ export function parseRouteFromPath(pathname = '') { } if (pageId === 'channel') { - if (segments.length >= 5 && decodePart(segments[4] || '').toLowerCase() === 'about') { - return { - pageId: 'channel-about-view', - params: { - ownerBlockchainName: decodePart(segments[1]), - channelRootBlockNumber: segments[2] || '', - channelRootBlockHash: segments[3] || '', - channelId: '', - }, - }; + if (segments.length >= 5) { + const sub = decodePart(segments[4] || '').toLowerCase(); + if (sub === 'about' || sub === 'donate') { + return { + pageId: sub === 'donate' ? 'channel-donate-view' : 'channel-about-view', + params: { + ownerBlockchainName: decodePart(segments[1]), + channelRootBlockNumber: segments[2] || '', + channelRootBlockHash: segments[3] || '', + channelId: '', + }, + }; + } } if (segments.length >= 4) { return { @@ -358,6 +362,18 @@ export function parseRouteFromPath(pathname = '') { return { pageId: 'remote-addblock-session-view', params: {} }; } + if (pageId === 'channel-donate-view' || pageId === 'channel-about-view') { + return { + pageId, + params: { + ownerBlockchainName: decodePart(segments[1]), + channelRootBlockNumber: segments[2] || '', + channelRootBlockHash: segments[3] || '', + channelId: '', + }, + }; + } + if (pageId === 'channel-view') { if (segments.length >= 4) { return { @@ -399,6 +415,32 @@ export function parseRouteFromPath(pathname = '') { return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } }; } + // Публичная короткая ссылка канала не содержит внутренний номер блокчейна: + // //. Старый /-001/ обрабатывается выше. + if (segments.length === 2 || (segments.length === 3 && ['about', 'donate'].includes(decodePart(segments[2] || '').toLowerCase()))) { + const ownerBlockchainName = decodePart(segments[0] || ''); + const channelName = decodePart(segments[1] || ''); + if (ownerBlockchainName && channelName) { + if (segments.length === 3) { + const sub = decodePart(segments[2] || '').toLowerCase(); + return { + pageId: sub === 'donate' ? 'channel-donate-view' : 'channel-about-view', + params: { + ownerBlockchainName, + channelRootBlockNumber: '', + channelRootBlockHash: '', + channelId: '', + channelName, + }, + }; + } + return { + pageId: 'channel-view', + params: { ownerBlockchainName, channelName, channelId: '' }, + }; + } + } + return { pageId, params: {} }; } @@ -467,7 +509,7 @@ export function resolveToolbarActive(pageId) { pageId === 'solana-users-init-view' ) return 'profile-view'; if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list'; - if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list'; + if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-donate-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list'; if (pageId === 'user') return 'messages-list'; return 'profile-view'; } diff --git a/shine-UI/js/services/shine-routes.js b/shine-UI/js/services/shine-routes.js index 27df45d2..fe5d107f 100644 --- a/shine-UI/js/services/shine-routes.js +++ b/shine-UI/js/services/shine-routes.js @@ -64,11 +64,16 @@ export function makeShineChannelAboutRoute({ ownerBlockchainName = '', channelRo return base ? `${base}/about` : ''; } -export function makeShineChannelShortRoute({ ownerBlockchainName = '', channelName = '' }) { - const ownerBch = String(ownerBlockchainName || '').trim(); +export function makeShineChannelDonateRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) { + const base = makeShineChannelRootRoute({ ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash }); + return base ? `${base}/donate` : ''; +} + +export function makeShineChannelShortRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '' }) { + const cleanOwnerLogin = normalizeLogin(ownerLogin) || extractLoginFromBlockchainName(ownerBlockchainName); const chName = String(channelName || '').trim(); - if (!ownerBch || !chName) return ''; - return `${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`; + if (!cleanOwnerLogin || !chName) return ''; + return `${encodeRoutePart(cleanOwnerLogin)}/${encodeRoutePart(chName)}`; } export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) { diff --git a/shine-UI/js/services/solana-wallet-service.js b/shine-UI/js/services/solana-wallet-service.js index ba6ac7ae..6842f10d 100644 --- a/shine-UI/js/services/solana-wallet-service.js +++ b/shine-UI/js/services/solana-wallet-service.js @@ -1,4 +1,5 @@ import { extractClientKey32FromStoredValue } from './client-key-utils.js'; +import { base64ToBytes } from './crypto-utils.js'; import { loadEncryptedUserSecrets } from './key-vault.js'; import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js'; import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; @@ -45,6 +46,17 @@ function encodeBase58(bytesLike) { return out; } + +export function solanaAddressFromPublicKeyBase64(publicKeyB64) { + const clean = String(publicKeyB64 || '').trim(); + if (!clean) return ''; + const bytes = base64ToBytes(clean); + if (bytes.length !== 32) { + throw new Error('Публичный ключ Solana должен содержать 32 байта'); + } + return encodeBase58(bytes); +} + function normalizeEndpoint(url) { const raw = String(url || '').trim(); if (!raw) return DEFAULT_SOLANA_ENDPOINT; @@ -110,6 +122,17 @@ async function keypairFromStoredSecret(storedSecret) { } } + +export async function getSolanaWalletFromStoredSecret(storedSecret) { + const clean = String(storedSecret || '').trim(); + if (!clean) throw new Error('Не передан приватный ключ'); + const keypair = await keypairFromStoredSecret(clean); + return { + address: keypair.publicKey.toBase58(), + keypair, + }; +} + export async function createRandomSolanaWallet() { const solana = await loadSolanaLib(); const keypair = solana.Keypair.generate(); diff --git a/shine-UI/styles/features/channel.css b/shine-UI/styles/features/channel.css index 8ddf33a2..9d9287aa 100644 --- a/shine-UI/styles/features/channel.css +++ b/shine-UI/styles/features/channel.css @@ -826,3 +826,524 @@ margin: 4px 0; padding: 10px; } + +/* ===== Channel description ===== */ +.channels-screen--channel-about { + padding-bottom: calc(22px + env(safe-area-inset-bottom)); +} + +.channel-about-card { + padding: 0; + overflow: hidden; +} + +.channel-about-content { + gap: 0; +} + +.channel-about-hero { + display: grid; + justify-items: center; + gap: 6px; + padding: 26px 20px 22px; + text-align: center; +} + +.channel-about-avatar-slot { + margin-bottom: 8px; +} + +.channel-about-avatar.channel-profile-avatar { + box-shadow: 0 12px 32px rgba(3, 8, 18, 0.34); +} + +.channel-about-title { + margin: 0; + color: rgba(255, 255, 255, 0.97); + font-size: clamp(24px, 7vw, 32px); + line-height: 1.12; + font-weight: 760; + overflow-wrap: anywhere; +} + +.channel-about-technical { + color: rgba(196, 210, 238, 0.72); + font-size: 14px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.channel-about-subscribers { + color: rgba(170, 190, 226, 0.68); + font-size: 14px; + line-height: 1.35; +} + +.channel-about-section { + display: grid; + gap: 10px; + padding: 20px; + border-top: 1px solid rgba(255, 255, 255, 0.065); +} + +.channel-about-section h3 { + margin: 0; + color: rgba(255, 218, 135, 0.92); + font-size: 14px; + font-weight: 700; +} + +.channel-about-description { + margin: 0; + color: rgba(235, 241, 255, 0.92); + font-size: 16px; + line-height: 1.58; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.channel-about-owner-link { + display: grid; + gap: 3px; + justify-items: start; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.channel-about-owner-link strong { + color: rgba(255, 255, 255, 0.96); + font-size: 16px; + line-height: 1.3; +} + +.channel-about-owner-link span { + color: rgba(176, 198, 234, 0.72); + font-size: 13px; + line-height: 1.3; +} + +.channel-about-owner-link:hover strong, +.channel-about-owner-link:focus-visible strong { + color: #f4dca6; +} + +.channel-about-owner-link:focus-visible { + outline: 2px solid rgba(244, 220, 166, 0.52); + outline-offset: 5px; + border-radius: 8px; +} + +.channel-about-support-btn { + width: 100%; + min-height: 44px; + margin-top: 2px; +} + +.channel-about-link-box { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 48px; + padding: 10px 10px 10px 12px; + border: 1px solid rgba(137, 168, 220, 0.14); + border-radius: 12px; + background: rgba(7, 15, 29, 0.38); +} + + +.channel-about-link-box a, +.channel-about-link-box span { + color: rgba(169, 215, 255, 0.95); + font-size: 13px; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-word; +} + + +.channel-about-copy-btn { + width: 38px; + min-width: 38px; + height: 38px; + min-height: 38px; + padding: 0; + display: inline-grid; + place-items: center; + font-size: 19px; +} + +.channel-about-open-btn, +.channel-about-subscription-btn { + margin: 18px 20px 0; + min-height: 48px; +} + +.channel-about-subscription-btn { + margin-top: 12px; + margin-bottom: 20px; +} + +.channel-about-subscription-btn.is-unsubscribe { + border: 1px solid rgba(255, 91, 105, 0.68); + background: rgba(181, 43, 56, 0.92); + color: #fff; +} + +.channel-about-subscription-btn.is-unsubscribe:hover, +.channel-about-subscription-btn.is-unsubscribe:focus-visible { + background: rgba(205, 49, 63, 0.98); +} + +.channel-about-open-btn:last-child { + margin-bottom: 20px; +} + +/* ===== Channel unsubscribe confirmation ===== */ +#channel-unsubscribe-confirm-modal { + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +#channel-unsubscribe-confirm-modal .channel-unsubscribe-confirm-card { + width: min(100%, 390px); + gap: 14px; + padding: 20px; +} + +.channel-unsubscribe-confirm-card .modal-title { + margin: 0; +} + +.channel-unsubscribe-confirm-text { + margin: 0; + line-height: 1.5; +} + +.channel-unsubscribe-confirm-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.channel-unsubscribe-confirm-actions button { + min-height: 44px; +} + +.channel-unsubscribe-confirm-yes { + border: 1px solid rgba(255, 91, 105, 0.68); + background: rgba(181, 43, 56, 0.92); + color: #fff; +} + +.channel-unsubscribe-confirm-yes:hover, +.channel-unsubscribe-confirm-yes:focus-visible { + background: rgba(205, 49, 63, 0.98); +} + +/* ===== Channel support transfer ===== */ +#channel-support-modal { + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +#channel-support-modal .channel-support-modal-card { + width: min(100%, 430px); + max-height: min(88vh, 720px); + overflow-y: auto; + gap: 9px; + padding: 20px; +} + +.channel-support-modal-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + margin-bottom: 4px; +} + +.channel-support-modal-head .modal-title { + margin: 0 0 3px; +} + +.channel-support-subtitle { + margin: 0; + overflow-wrap: anywhere; +} + +.channel-support-close { + width: 38px; + min-width: 38px; + height: 38px; + min-height: 38px; + padding: 0; + display: inline-grid; + place-items: center; + font-size: 24px; + line-height: 1; +} + +.channel-support-address { + min-height: 36px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(5, 11, 23, 0.44); + color: rgba(186, 215, 255, 0.8); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-all; +} + +.channel-support-balance-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 44px; +} + +.channel-support-balance-row strong { + color: rgba(255, 255, 255, 0.94); + text-align: right; +} + +.channel-support-refresh { + min-height: 36px; + padding-block: 6px; +} + +.channel-support-amount-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.channel-support-amount-row span { + color: rgba(232, 239, 255, 0.82); + font-weight: 700; +} + +.channel-support-status { + min-height: 18px; + margin: 2px 0 0; + color: rgba(192, 207, 236, 0.76); +} + +.channel-support-status.is-error { + color: #ff9fa8; +} + +.channel-support-result { + display: grid; + gap: 5px; + padding: 10px 12px; + border: 1px solid rgba(130, 227, 164, 0.18); + border-radius: 12px; + background: rgba(44, 105, 67, 0.15); +} + +.channel-support-result[hidden] { + display: none; +} + +.channel-support-result strong { + color: #c9f2d8; +} + +.channel-support-result span, +.channel-support-result code { + color: rgba(220, 236, 255, 0.82); + font-size: 12px; + overflow-wrap: anywhere; + word-break: break-all; +} + +.channel-support-submit { + min-height: 48px; + margin-top: 4px; +} + +@media (max-width: 420px) { + .channel-about-section { + padding-inline: 16px; + } + + .channel-about-open-btn, + .channel-about-subscription-btn { + margin-inline: 16px; + } + + .channel-support-balance-row { + grid-template-columns: 1fr auto; + } + + .channel-support-balance-row .meta-muted { + grid-column: 1 / -1; + } +} + +/* ===== Channel donation view ===== */ +.channels-screen--channel-donate { + padding-bottom: calc(24px + env(safe-area-inset-bottom)); +} + +.channel-donate-card { + width: min(100%, 680px); + margin-inline: auto; + padding: 0; + overflow: hidden; +} + +.channel-donate-content { + gap: 0; +} + +.channel-donate-hero { + display: grid; + justify-items: center; + gap: 5px; + padding: 28px 20px 24px; + text-align: center; +} + +.channel-donate-hero h2 { + margin: 0 0 4px; + color: rgba(255, 255, 255, 0.97); + font-size: clamp(24px, 7vw, 31px); + line-height: 1.12; + font-weight: 760; +} + +.channel-donate-hero strong { + color: rgba(255, 230, 169, 0.94); + font-size: 17px; + overflow-wrap: anywhere; +} + +.channel-donate-hero span { + color: rgba(176, 198, 234, 0.72); + font-size: 13px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.channel-donate-section { + display: grid; + gap: 9px; + padding: 18px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.065); +} + +.channel-donate-address { + min-height: 38px; + padding: 9px 10px; + border-radius: 10px; + background: rgba(5, 11, 23, 0.44); + color: rgba(186, 215, 255, 0.82); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-all; +} + +.channel-donate-balance-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 44px; + margin-top: 3px; +} + +.channel-donate-balance-row strong { + color: rgba(255, 255, 255, 0.94); + text-align: right; +} + +.channel-donate-refresh { + min-height: 36px; + padding-block: 6px; +} + +.channel-donate-amount-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.channel-donate-amount-row span { + color: rgba(232, 239, 255, 0.82); + font-weight: 700; +} + +.channel-donate-status { + min-height: 18px; + margin: 14px 20px 0; + color: rgba(192, 207, 236, 0.76); +} + +.channel-donate-status.is-error { + color: #ff9fa8; +} + +.channel-donate-result { + display: grid; + gap: 6px; + margin: 12px 20px 0; + padding: 12px 14px; + border: 1px solid rgba(130, 227, 164, 0.18); + border-radius: 12px; + background: rgba(44, 105, 67, 0.15); +} + +.channel-donate-result[hidden] { + display: none; +} + +.channel-donate-result strong { + color: #c9f2d8; +} + +.channel-donate-result span, +.channel-donate-result code { + color: rgba(220, 236, 255, 0.82); + font-size: 12px; + overflow-wrap: anywhere; + word-break: break-all; +} + +.channel-donate-submit { + min-height: 50px; + margin: 16px 20px 22px; +} + +@media (max-width: 420px) { + .channel-donate-section { + padding-inline: 16px; + } + + .channel-donate-status, + .channel-donate-result, + .channel-donate-submit { + margin-inline: 16px; + } + + .channel-donate-balance-row { + grid-template-columns: 1fr auto; + } + + .channel-donate-balance-row .meta-muted { + grid-column: 1 / -1; + } +} From f44ab75c6485517646c716a5785f4710f1d9bbd944803648dbfa74f908b0caba Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 17 Sep 2026 23:35:53 +0300 Subject: [PATCH 22/30] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D0=B3=D1=80=D0=B0=D1=84=20=D1=81=D0=B2=D1=8F=D0=B7?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/components/avatar-image.js | 12 +++++ shine-UI/js/pages/network-view.js | 58 ++++++++++++++++++------ shine-UI/js/pages/network/force-graph.js | 55 +++++++++++++++++++--- shine-UI/js/pages/profile-view.js | 1 + shine-UI/js/pages/user-profile-view.js | 1 + shine-UI/styles/components/avatar.css | 20 ++++++++ shine-UI/styles/features/profile.css | 5 +- shine-UI/styles/network-graph.css | 8 ++-- 9 files changed, 135 insertions(+), 27 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 93e1fe2e..12e6b19f 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.17 +client.version=1.12.18 server.version=1.10.7 diff --git a/shine-UI/js/components/avatar-image.js b/shine-UI/js/components/avatar-image.js index f4e677bc..9857f066 100644 --- a/shine-UI/js/components/avatar-image.js +++ b/shine-UI/js/components/avatar-image.js @@ -36,6 +36,7 @@ export function renderAvatar({ title = '', alt = 'Аватар', glow = false, + official = false, } = {}) { const wrap = document.createElement('div'); const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']); @@ -52,6 +53,15 @@ export function renderAvatar({ fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?'; wrap.append(fallback); + if (official) { + const badge = document.createElement('img'); + badge.className = 'avatar-official-badge'; + badge.src = '/assets/shine-official-badge.svg?v=2'; + badge.alt = ''; + badge.setAttribute('aria-hidden', 'true'); + wrap.append(badge); + } + const txId = String(avatar?.ar || '').trim(); if (!validateArweaveTxId(txId)) { return wrap; @@ -146,6 +156,7 @@ export function renderUserAvatar({ className = '', title = '', glow = false, + official = false, } = {}) { return renderAvatar({ initials: buildAvatarInitials({ login, firstName, lastName }), @@ -155,5 +166,6 @@ export function renderUserAvatar({ title, alt: 'Аватар', glow, + official, }); } diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 44018ee2..0f9f3ad3 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -709,14 +709,23 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI const centerKey = normKey(snap?.centerLogin); const snapNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : []; const ownCenter = snapNodes.find((node) => normKey(node?.id) === centerKey); - if (ownCenter) centerNodeByKey.set(centerKey, { ...ownCenter, isHistoryCenter: true, keepVisible: true, tier: 1 }); + if (ownCenter) centerNodeByKey.set(centerKey, { + ...ownCenter, + isHistoryCenter: true, + clusterOwnerId: centerKey, + keepVisible: true, + tier: 1, + }); snapNodes.forEach((rawNode) => { const key = normKey(rawNode?.id); if (!key) return; // Исторические центры сохраняют позицию собственного кластера. Все остальные общие узлы // принадлежат самому свежему кластеру, где встретились, и поэтому «переезжают» туда без дубля. - if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) nodeMap.set(key, { ...rawNode, id: key }); + if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) { + // Общий узел принадлежит последнему кластеру, возле которого он был перерисован. + nodeMap.set(key, { ...rawNode, id: key, clusterOwnerId: centerKey }); + } if (key === centerKey) return; let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : []; @@ -755,9 +764,11 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI id: key, login: node?.login || node?.id || key, tier: centerKeys.has(key) ? 1 : Math.max(1, Number(node?.tier) || 1), + isHistoryCenter: centerKeys.has(key), fixedLayout: true, keepVisible: centerKeys.has(key) || Boolean(node?.keepVisible), alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), + clusterOwnerId: normKey(node?.clusterOwnerId || key), edgeParents: edgeParentsByChild.get(key) || [], })); @@ -1098,11 +1109,15 @@ export function render({ navigate, route, chrome } = {}) { window.setTimeout(() => inputEl.focus(), 0); } - function persistManualNodePosition(nodeId, point) { - const key = normKey(nodeId); - const x = Number(point?.x); - const y = Number(point?.y); - if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return; + function persistManualNodePositions(rows) { + const positions = new Map(); + (Array.isArray(rows) ? rows : []).forEach((row) => { + const key = normKey(row?.id || row?.login); + const x = Number(row?.x); + const y = Number(row?.y); + if (key && Number.isFinite(x) && Number.isFinite(y)) positions.set(key, { x, y }); + }); + if (!positions.size) return; // Координаты исторических snapshot'ов уже находятся в общей world-системе. Обновляем все // упоминания пользователя, чтобы следующий setModel/filter/history render не откатил ручной drag. @@ -1110,12 +1125,13 @@ export function render({ navigate, route, chrome } = {}) { const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] }); let changed = false; model.nodes = model.nodes.map((node) => { - if (normKey(node?.id) !== key) return node; + const point = positions.get(normKey(node?.id)); + if (!point) return node; changed = true; return { ...node, - layoutX: x, - layoutY: y, + layoutX: point.x, + layoutY: point.y, fixedLayout: true, }; }); @@ -1149,10 +1165,19 @@ export function render({ navigate, route, chrome } = {}) { } void load(node.login, { pushHistory: true, transitionAngle, transitionX, transitionY }); }, - // тап по центру — полноценный профиль + // Короткий тап по текущему центру сразу открывает его меню. onCenterTap: (node) => { - const routeTo = profileInfoRoute(node.login); - if (routeTo) navigate(routeTo); + const rect = node?.el?.getBoundingClientRect?.(); + openNodeMenu({ + login: normalizeLogin(node?.login), + displayName: String(node?.name || '').trim(), + relationType: node?.relationType, + point: rect ? { x: rect.left + rect.width / 2, y: rect.top, rect } : undefined, + actions: [ + { label: 'Профиль', onClick: () => { const r = profileInfoRoute(node?.login); if (r) navigate(r); } }, + { label: 'Написать', onClick: () => navigate(`chat/${encodeURIComponent(normalizeLogin(node?.login))}`) }, + ], + }); }, // долгое нажатие — контекстное меню (вне масштабируемого холста) onNodeLongPress: (node, point) => { @@ -1171,7 +1196,12 @@ export function render({ navigate, route, chrome } = {}) { // Drag периферийного аватара — ручная правка текущей карты. Движок уже двигает DOM/рёбра // в реальном времени; здесь только сохраняем итоговую world-позицию в историю/X2 snapshot. onNodeMoveEnd: (node, point) => { - persistManualNodePosition(node?.id || node?.login, point); + persistManualNodePositions([{ id: node?.id || node?.login, ...point }]); + }, + // Центр и узлы, которые в последний раз были нарисованы возле него, + // сохраняются одной пачкой, чтобы общий узел не вернулся в старый кластер при rebuild. + onGroupMoveEnd: (movedNodes) => { + persistManualNodePositions(movedNodes); }, }); } diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index 6be6d03d..d2b02ee0 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -219,13 +219,14 @@ function hash01(str) { * @param {Object} opts * @param {HTMLElement} opts.stage - контейнер сцены (position: relative/absolute, overflow hidden) * @param {Object} opts.model - нормализованная модель { focusId, nodes[] } - * @param {Function} [opts.onCenterTap] - тап по центральному узлу (node) => void + * @param {Function} [opts.onCenterTap] - тап по текущему центральному узлу (node) => void * @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования) * @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void * @param {Function} [opts.onNodeMoveEnd] - ручное перемещение периферийного узла (node, {x,y}) => void + * @param {Function} [opts.onGroupMoveEnd] - перемещение исторического кластера (nodes[]) => void * @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }} */ -export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onNodeHover, onDiveChange } = {}) { +export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onGroupMoveEnd, onNodeHover, onDiveChange } = {}) { // Слои DOM const edgesSvg = document.createElementNS(SVGNS, 'svg'); edgesSvg.setAttribute('class', 'fg-edges'); @@ -658,6 +659,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL node.relationType = src.relationType; node.shining = Boolean(src.shining); node.official = Boolean(src.official); + node.isHistoryCenter = Boolean(src.isHistoryCenter); + node.clusterOwnerId = String(src.clusterOwnerId || src.id || ''); node.keepVisible = Boolean(src.keepVisible); node.alwaysVisible = Boolean(src.alwaysVisible); node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : []; @@ -1472,6 +1475,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let nodeDragActive = false; let nodeDragStartX = 0; let nodeDragStartY = 0; + let groupDragActive = false; + let groupDragNodes = []; + let groupDragStarts = new Map(); let longTimer = 0; let longFired = false; const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами @@ -1602,8 +1608,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания downNode = nodeFromEvent(ev); nodeDragActive = false; + groupDragActive = false; + groupDragNodes = []; + groupDragStarts = new Map(); nodeDragStartX = Number(downNode?.x) || 0; nodeDragStartY = Number(downNode?.y) || 0; + if (downNode?.isHistoryCenter) { + const ownerId = String(downNode.clusterOwnerId || downNode.id); + groupDragNodes = nodes.filter((node) => String(node.clusterOwnerId || node.id) === ownerId); + groupDragStarts = new Map(groupDragNodes.map((node) => [String(node.id), { x: Number(node.x) || 0, y: Number(node.y) || 0 }])); + } // касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true); if (downNode && typeof onNodeLongPress === 'function') { @@ -1655,15 +1669,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL moved = true; if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; } if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это drag/pan, а не нажатие - // Не центральный аватар перетаскивается сам. Пустой фон или центральный узел продолжают панорамировать карту. - nodeDragActive = Boolean(downNode && !downNode.isFocus); - if (nodeDragActive && cssBloom) endCssBloom(); + // Исторический центр тащит свой кластер; обычный узел — только себя; пустой фон — всю карту. + groupDragActive = Boolean(downNode?.isHistoryCenter && groupDragNodes.length); + nodeDragActive = Boolean(downNode && !groupDragActive); + if ((nodeDragActive || groupDragActive) && cssBloom) endCssBloom(); // палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false); camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста) cancelTween(); // жест прерывает анимацию центрирования dragging = true; } + if (moved && groupDragActive) { + const zx = dx / Math.max(0.001, zoom); + const zy = dy / Math.max(0.001, zoom); + groupDragNodes.forEach((node) => { + const start = groupDragStarts.get(String(node.id)); + if (!start) return; + const nx = start.x + zx; + const ny = start.y + zy; + node.x = nx; node.y = ny; + node.tx = nx; node.ty = ny; + node.bfx = nx; node.bfy = ny; + node.vx = 0; node.vy = 0; + node.fixedLayout = true; + }); + renderNodes(); + renderEdges(); + return; + } if (moved && nodeDragActive && downNode) { // dx/dy приходят в экранных пикселях, координаты узла живут в world-space — делим на текущий zoom. const nx = nodeDragStartX + dx / Math.max(0.001, zoom); @@ -1708,9 +1741,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const wasMoved = moved; const wasLong = longFired; const movedNode = nodeDragActive ? downNode : null; + const movedGroup = groupDragActive ? [...groupDragNodes] : []; pointerId = null; dragging = false; nodeDragActive = false; + groupDragActive = false; // касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap) if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false); @@ -1719,6 +1754,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // Передаём окончательную world-позицию наружу, чтобы ручная правка пережила следующий setModel/history render. if (typeof onNodeMoveEnd === 'function') onNodeMoveEnd(movedNode, { x: movedNode.x, y: movedNode.y }); renderEdges(); + } else if (wasMoved && movedGroup.length) { + if (typeof onGroupMoveEnd === 'function') { + onGroupMoveEnd(movedGroup.map((node) => ({ id: node.id, login: node.login, x: node.x, y: node.y }))); + } + renderEdges(); } else if (wasMoved) { // после pan даём физике чуть устаканиться и уснуть wake(); @@ -1866,8 +1906,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } // финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty) - const finalX = node.isFocus ? 0 : node.tx; - const finalY = node.isFocus ? 0 : node.ty; + // Ручно сдвинутый текущий центр не должен прыгать обратно в (0,0) при setModel. + const finalX = node.tx; + const finalY = node.ty; const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка) const finalOp = node.targetOpacity; // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1) diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index c6f71c00..bb7d426e 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -176,6 +176,7 @@ export function render({ navigate, chrome }) { size: 'xl', className: 'user-profile-hero-avatar', glow: shining, + official, })); status.textContent = ''; diff --git a/shine-UI/js/pages/user-profile-view.js b/shine-UI/js/pages/user-profile-view.js index b2f0f48b..4d74cb2b 100644 --- a/shine-UI/js/pages/user-profile-view.js +++ b/shine-UI/js/pages/user-profile-view.js @@ -250,6 +250,7 @@ export function render({ navigate, route, chrome }) { size: 'xl', className: 'user-profile-hero-avatar', glow: shining, + official, })); addMenu = body.querySelector('.user-profile-add-menu'); diff --git a/shine-UI/styles/components/avatar.css b/shine-UI/styles/components/avatar.css index 98c03094..9f693cad 100644 --- a/shine-UI/styles/components/avatar.css +++ b/shine-UI/styles/components/avatar.css @@ -269,3 +269,23 @@ pointer-events: none; z-index: -1; } + +/* Единый знак официального пользователя. Левый и нижний края зафиксированы, + а сам знак растёт вправо и вверх вместе с аватаром. */ +.avatar.avatar-image.avatar-framed > .avatar-official-badge { + position: absolute; + left: -2%; + bottom: -1%; + width: 32%; + height: 32%; + min-width: 18px; + min-height: 18px; + object-fit: contain; + object-position: center; + display: block; + opacity: 1; + border-radius: 0; + pointer-events: none; + z-index: 4; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42)); +} diff --git a/shine-UI/styles/features/profile.css b/shine-UI/styles/features/profile.css index cb09eda5..6dd785ae 100644 --- a/shine-UI/styles/features/profile.css +++ b/shine-UI/styles/features/profile.css @@ -800,7 +800,10 @@ .user-profile-screen .user-profile-hero-avatar.avatar-glow::before { inset: -12%; - opacity: 0.5; + background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%); + filter: blur(3.4px); + transform: scale(1.07); + opacity: 0.91; } diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 3a6c95da..6ca65f7d 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -210,10 +210,10 @@ position: absolute; left: -2%; bottom: -1%; - width: 16%; - height: 16%; - min-width: 9px; - min-height: 9px; + width: 32%; + height: 32%; + min-width: 18px; + min-height: 18px; object-fit: contain; object-position: center; display: block; From 2be5ad0b7d1b22f8b7f0985321b2c16e30cc77f1a034cebb166218676de6e526 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 18 Sep 2026 00:00:42 +0300 Subject: [PATCH 23/30] =?UTF-8?q?=D0=92=D1=8B=D1=80=D0=BE=D0=B2=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B2=D0=B5=D1=80=D1=85=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/app.js | 1 - shine-UI/js/pages/chat-view.js | 2 +- shine-UI/js/pages/messages-list.js | 2 +- shine-UI/js/pages/notifications-view.js | 8 +++- shine-UI/js/pages/profile-view.js | 2 +- shine-UI/styles/components/overflow-dots.css | 9 +++++ shine-UI/styles/components/topbar.css | 8 ++++ shine-UI/styles/features/notifications.css | 41 +++++++++++++------- shine-UI/styles/features/profile.css | 26 ++++++++++++- 10 files changed, 79 insertions(+), 22 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 12e6b19f..08f9664b 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.18 +client.version=1.12.19 server.version=1.10.7 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index f8dad777..dafd5c52 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -218,7 +218,6 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([ 'chat-view', 'channel-view', 'channel-thread-view', - 'notifications-view', ]); const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([ diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index fa1429b2..5875d940 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -1426,7 +1426,7 @@ export function render({ navigate, route, chrome }) { iconNode: createOverflowDots(), title: 'Действия чата', ariaLabel: 'Открыть меню действий чата', - className: 'chat-header-icon-btn chat-header-menu-btn', + className: 'chat-header-icon-btn chat-header-menu-btn topbar-overflow-action--raised', menu: { minWidth: 230, items: () => [ diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index 34f9382c..4ba73056 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -251,7 +251,7 @@ export function render({ navigate, chrome }) { iconNode: createOverflowDots(), title: 'Меню чатов', ariaLabel: 'Меню чатов', - className: 'messages-topbar-menu-btn', + className: 'messages-topbar-menu-btn topbar-overflow-action--raised', menu: { minWidth: 210, items: [ diff --git a/shine-UI/js/pages/notifications-view.js b/shine-UI/js/pages/notifications-view.js index 7e4f0cf6..d1f90bee 100644 --- a/shine-UI/js/pages/notifications-view.js +++ b/shine-UI/js/pages/notifications-view.js @@ -320,11 +320,15 @@ function renderItem(item, activeTab, navigate) { export function render({ navigate, chrome } = {}) { const screen = document.createElement('section'); screen.className = 'stack notifications-screen'; - chrome?.setTopbar(createTopBar({ title: 'Уведомления' })); const tabs = document.createElement('div'); tabs.className = 'notification-feed-tabs app-top-tabs'; const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']]; + chrome?.setTopbar(createTopBar({ + center: tabs, + className: 'notifications-topbar', + })); + const list = document.createElement('div'); list.className = 'stack notifications-list'; let payloadCache = null; @@ -415,7 +419,7 @@ export function render({ navigate, chrome } = {}) { observer?.disconnect(); Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer)); }; - screen.append(tabs,list); + screen.append(list); void load(); return screen; } diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index bb7d426e..e47c647b 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -84,7 +84,7 @@ export function render({ navigate, chrome }) { iconNode: createOverflowDots(), title: 'Меню профиля', ariaLabel: 'Меню профиля', - className: 'profile-head-menu-btn', + className: 'profile-head-menu-btn topbar-overflow-action--raised', menu: { className: 'profile-head-menu', minWidth: 250, diff --git a/shine-UI/styles/components/overflow-dots.css b/shine-UI/styles/components/overflow-dots.css index f65ccd7d..1a595873 100644 --- a/shine-UI/styles/components/overflow-dots.css +++ b/shine-UI/styles/components/overflow-dots.css @@ -26,3 +26,12 @@ 0 0 5px rgba(92, 190, 255, 0.72), 0 0 10px rgba(72, 145, 255, 0.34); } + + +/* Явный вариант для шапок, где overflow-кнопку нужно выровнять по эталонной + * позиции экрана «Связи». Поднимаем кнопку вместе с зоной нажатия и якорем + * выпадающего меню, не меняя базовый TopBar для остальных экранов. */ +.topbar-overflow-action--raised { + position: relative; + top: -2px; +} diff --git a/shine-UI/styles/components/topbar.css b/shine-UI/styles/components/topbar.css index 8cf2d510..a3d464e4 100644 --- a/shine-UI/styles/components/topbar.css +++ b/shine-UI/styles/components/topbar.css @@ -97,6 +97,14 @@ text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 12px var(--app-topbar-blue-glow-soft); } +.topbar-slot .topbar__action { + min-width: 40px; + padding-block: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + .topbar-slot .topbar__action svg, .topbar-slot .topbar__back svg { filter: drop-shadow(0 0 3px var(--app-topbar-blue-glow)) drop-shadow(0 0 7px var(--app-topbar-blue-glow-soft)); diff --git a/shine-UI/styles/features/notifications.css b/shine-UI/styles/features/notifications.css index 460a54df..62a8d841 100644 --- a/shine-UI/styles/features/notifications.css +++ b/shine-UI/styles/features/notifications.css @@ -186,14 +186,32 @@ -/* Уведомления: переключатели лент повторяют стеклянные чипы экрана «Связи». */ -.notifications-screen .notification-feed-tabs { +/* Уведомления: переключатели лент занимают место заголовка в закреплённом + * TopBar и повторяют стеклянные чипы экрана «Связи». */ +.notifications-topbar { + grid-template-columns: minmax(0, 1fr); +} + + +.notifications-topbar .topbar__left, +.notifications-topbar .topbar__right { + display: none; +} + + +.notifications-topbar .topbar__center { + width: 100%; +} + + +.notifications-topbar .notification-feed-tabs { + width: 100%; display: flex; align-items: center; justify-content: center; - flex-wrap: wrap; + flex-wrap: nowrap; gap: 8px; - padding: 8px 12px 4px; + padding: 0; margin: 0; background: transparent; border: 0; @@ -201,25 +219,22 @@ } -.notifications-screen .notification-feed-tabs .notification-tab-btn { - min-width: 92px; +.notifications-topbar .notification-feed-tabs .notification-tab-btn { + flex: 1 1 0; + min-width: 0; + padding-inline: 8px; text-align: center; } -/* На обычных экранах отступ создаёт стандартный padding screen-content (14px). */ -.notifications-screen .notification-feed-tabs.app-top-tabs { +/* Высота панели остаётся стандартной: перенос фильтров не увеличивает TopBar. */ +.notifications-topbar .notification-feed-tabs.app-top-tabs { min-height: var(--app-primary-tab-min-height); padding: 0; margin: 0; } -.notifications-screen .notification-feed-tabs .notification-tab-btn { - min-width: 92px; -} - - /* Уведомления: информационные блоки без рамок. Hover/focus не возвращает * обводку — различение сохраняется фоном и лёгкой реакцией на нажатие. */ .notifications-screen .notifications-list > .notification-card, diff --git a/shine-UI/styles/features/profile.css b/shine-UI/styles/features/profile.css index 6dd785ae..471df775 100644 --- a/shine-UI/styles/features/profile.css +++ b/shine-UI/styles/features/profile.css @@ -798,15 +798,37 @@ } -.user-profile-screen .user-profile-hero-avatar.avatar-glow::before { - inset: -12%; +.user-profile-screen .user-profile-hero-avatar.avatar-glow { + box-shadow: + 0 0 8.3px rgba(146, 243, 255, 0.57), + 0 0 18.3px rgba(121, 235, 255, 0.39), + 0 0 33px rgba(100, 220, 255, 0.24); +} + + +/* Повторяем стационарное сияние узлов экрана «Связи»: тот же градиент, + * интенсивность, масштаб и радиус размытия. CSS blur здесь эквивалентен + * SVG-фильтру fg-shine-glow со stdDeviation 3.4, который создаёт граф. */ +.user-profile-screen .user-profile-hero-avatar.avatar.avatar-image.avatar-framed.avatar-glow::before { + inset: -12px; + border: 0; background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%); + box-shadow: none; filter: blur(3.4px); transform: scale(1.07); opacity: 0.91; } +/* На крупном аватаре профиля официальный знак на 20% компактнее базового. + * Левый и нижний края остаются на месте, поэтому сокращение приходится на + * верхнюю и правую стороны знака. */ +.user-profile-screen .user-profile-hero-avatar.avatar-framed > .avatar-official-badge { + width: 25.6%; + height: 25.6%; +} + + .user-profile-screen .user-profile-hero-avatar .avatar-fallback { font-size: clamp(34px, 9vw, 58px); font-weight: 800; From 4da2d7129a26ee4f579f9d51c9050d38c04aad60f05caf7615adc46bce3336a7 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 19 Sep 2026 10:18:45 +0300 Subject: [PATCH 24/30] 1 --- shine-UI/styles/features/profile.css | 29 +++++++++++----------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/shine-UI/styles/features/profile.css b/shine-UI/styles/features/profile.css index 471df775..96f3e323 100644 --- a/shine-UI/styles/features/profile.css +++ b/shine-UI/styles/features/profile.css @@ -798,25 +798,18 @@ } -.user-profile-screen .user-profile-hero-avatar.avatar-glow { +.user-profile-screen .user-profile-hero-avatar.avatar-glow::before { + inset: -7%; + border: 2px solid rgba(145, 239, 255, 0.94); + background: transparent; box-shadow: - 0 0 8.3px rgba(146, 243, 255, 0.57), - 0 0 18.3px rgba(121, 235, 255, 0.39), - 0 0 33px rgba(100, 220, 255, 0.24); -} - - -/* Повторяем стационарное сияние узлов экрана «Связи»: тот же градиент, - * интенсивность, масштаб и радиус размытия. CSS blur здесь эквивалентен - * SVG-фильтру fg-shine-glow со stdDeviation 3.4, который создаёт граф. */ -.user-profile-screen .user-profile-hero-avatar.avatar.avatar-image.avatar-framed.avatar-glow::before { - inset: -12px; - border: 0; - background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%); - box-shadow: none; - filter: blur(3.4px); - transform: scale(1.07); - opacity: 0.91; + 0 0 3px rgba(155, 244, 255, 0.96), + 0 0 9px rgba(112, 222, 255, 0.74), + 0 0 17px rgba(86, 190, 255, 0.38), + inset 0 0 4px rgba(176, 248, 255, 0.72); + filter: none; + transform: none; + opacity: 1; } From 23969121e551de2616870857a33542d04a0b242dd623340be8161fdf72e60fed Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 20 Sep 2026 11:20:32 +0300 Subject: [PATCH 25/30] cripto utils --- shine-UI/js/services/crypto-utils.js | 4 ++-- shine-UI/js/services/device-pairing-service.js | 2 +- .../vendor/noble/curves-ed25519-1.5.0.bundle.mjs | 15 +++++++++++++++ .../vendor/noble/curves-ed25519-1.8.1.bundle.mjs | 15 +++++++++++++++ .../vendor/noble/hashes-argon2-1.8.0.bundle.mjs | 7 +++++++ 5 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 shine-UI/js/vendor/noble/curves-ed25519-1.5.0.bundle.mjs create mode 100644 shine-UI/js/vendor/noble/curves-ed25519-1.8.1.bundle.mjs create mode 100644 shine-UI/js/vendor/noble/hashes-argon2-1.8.0.bundle.mjs diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index 00355976..a9a84e25 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -1,7 +1,7 @@ const encoder = new TextEncoder(); const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.'; -import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js'; -import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from 'https://esm.sh/@noble/curves@1.8.1/ed25519'; +import { argon2idAsync } from '../vendor/noble/hashes-argon2-1.8.0.bundle.mjs'; +import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from '../vendor/noble/curves-ed25519-1.8.1.bundle.mjs'; const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key'; function getCryptoApi() { diff --git a/shine-UI/js/services/device-pairing-service.js b/shine-UI/js/services/device-pairing-service.js index fa307ab9..c167d96f 100644 --- a/shine-UI/js/services/device-pairing-service.js +++ b/shine-UI/js/services/device-pairing-service.js @@ -12,7 +12,7 @@ import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519, -} from 'https://esm.sh/@noble/curves@1.5.0/ed25519'; +} from '../vendor/noble/curves-ed25519-1.5.0.bundle.mjs'; const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:'; const PAIRING_HASH_PREFIX = 'sha256$'; diff --git a/shine-UI/js/vendor/noble/curves-ed25519-1.5.0.bundle.mjs b/shine-UI/js/vendor/noble/curves-ed25519-1.5.0.bundle.mjs new file mode 100644 index 00000000..7a7912bd --- /dev/null +++ b/shine-UI/js/vendor/noble/curves-ed25519-1.5.0.bundle.mjs @@ -0,0 +1,15 @@ +/* esm.sh - @noble/curves@1.5.0/ed25519 */ +function tn(t){return t instanceof Uint8Array||t!=null&&typeof t=="object"&&t.constructor.name==="Uint8Array"}function St(t,...e){if(!tn(t))throw new Error("Uint8Array expected");if(e.length>0&&!e.includes(t.length))throw new Error(`Uint8Array expected of length ${e}, not of length=${t.length}`)}function Mt(t,e=!0){if(t.destroyed)throw new Error("Hash instance has been destroyed");if(e&&t.finished)throw new Error("Hash#digest() has already been called")}function be(t,e){St(t);let n=e.outputLen;if(t.lengthnew DataView(t.buffer,t.byteOffset,t.byteLength);var po=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;function vt(t){if(typeof t!="string")throw new Error(`utf8ToBytes expected string, got ${typeof t}`);return new Uint8Array(new TextEncoder().encode(t))}function $t(t){return typeof t=="string"&&(t=vt(t)),St(t),t}function ge(...t){let e=0;for(let o=0;ot().update($t(o)).digest(),n=t();return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=()=>t(),e}function Vt(t=32){if(At&&typeof At.getRandomValues=="function")return At.getRandomValues(new Uint8Array(t));throw new Error("crypto.getRandomValues must be defined")}function en(t,e,n,o){if(typeof t.setBigUint64=="function")return t.setBigUint64(e,n,o);let s=BigInt(32),r=BigInt(4294967295),i=Number(n>>s&r),f=Number(n&r),c=o?4:0,u=o?0:4;t.setUint32(e+c,i,o),t.setUint32(e+u,f,o)}var Tt=class extends It{constructor(e,n,o,s){super(),this.blockLen=e,this.outputLen=n,this.padOffset=o,this.isLE=s,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(e),this.view=Lt(this.buffer)}update(e){Mt(this);let{view:n,buffer:o,blockLen:s}=this;e=$t(e);let r=e.length;for(let i=0;is-i&&(this.process(o,0),i=0);for(let d=i;da.length)throw new Error("_sha2: outputLen bigger than state");for(let d=0;d>Zt&Ct)}:{h:Number(t>>Zt&Ct)|0,l:Number(t&Ct)|0}}function nn(t,e=!1){let n=new Uint32Array(t.length),o=new Uint32Array(t.length);for(let s=0;sBigInt(t>>>0)<>>0),rn=(t,e,n)=>t>>>n,sn=(t,e,n)=>t<<32-n|e>>>n,cn=(t,e,n)=>t>>>n|e<<32-n,fn=(t,e,n)=>t<<32-n|e>>>n,an=(t,e,n)=>t<<64-n|e>>>n-32,un=(t,e,n)=>t>>>n-32|e<<64-n,ln=(t,e)=>e,dn=(t,e)=>t,hn=(t,e,n)=>t<>>32-n,pn=(t,e,n)=>e<>>32-n,xn=(t,e,n)=>e<>>64-n,bn=(t,e,n)=>t<>>64-n;function gn(t,e,n,o){let s=(e>>>0)+(o>>>0);return{h:t+n+(s/2**32|0)|0,l:s|0}}var wn=(t,e,n)=>(t>>>0)+(e>>>0)+(n>>>0),yn=(t,e,n,o)=>e+n+o+(t/2**32|0)|0,mn=(t,e,n,o)=>(t>>>0)+(e>>>0)+(n>>>0)+(o>>>0),Bn=(t,e,n,o,s)=>e+n+o+s+(t/2**32|0)|0,En=(t,e,n,o,s)=>(t>>>0)+(e>>>0)+(n>>>0)+(o>>>0)+(s>>>0),_n=(t,e,n,o,s,r)=>e+n+o+s+r+(t/2**32|0)|0;var Sn={fromBig:ye,split:nn,toBig:on,shrSH:rn,shrSL:sn,rotrSH:cn,rotrSL:fn,rotrBH:an,rotrBL:un,rotr32H:ln,rotr32L:dn,rotlSH:hn,rotlSL:pn,rotlBH:xn,rotlBL:bn,add:gn,add3L:wn,add3H:yn,add4L:mn,add4H:Bn,add5H:_n,add5L:En},_=Sn;var[An,In]=_.split(["0x428a2f98d728ae22","0x7137449123ef65cd","0xb5c0fbcfec4d3b2f","0xe9b5dba58189dbbc","0x3956c25bf348b538","0x59f111f1b605d019","0x923f82a4af194f9b","0xab1c5ed5da6d8118","0xd807aa98a3030242","0x12835b0145706fbe","0x243185be4ee4b28c","0x550c7dc3d5ffb4e2","0x72be5d74f27b896f","0x80deb1fe3b1696b1","0x9bdc06a725c71235","0xc19bf174cf692694","0xe49b69c19ef14ad2","0xefbe4786384f25e3","0x0fc19dc68b8cd5b5","0x240ca1cc77ac9c65","0x2de92c6f592b0275","0x4a7484aa6ea6e483","0x5cb0a9dcbd41fbd4","0x76f988da831153b5","0x983e5152ee66dfab","0xa831c66d2db43210","0xb00327c898fb213f","0xbf597fc7beef0ee4","0xc6e00bf33da88fc2","0xd5a79147930aa725","0x06ca6351e003826f","0x142929670a0e6e70","0x27b70a8546d22ffc","0x2e1b21385c26c926","0x4d2c6dfc5ac42aed","0x53380d139d95b3df","0x650a73548baf63de","0x766a0abb3c77b2a8","0x81c2c92e47edaee6","0x92722c851482353b","0xa2bfe8a14cf10364","0xa81a664bbc423001","0xc24b8b70d0f89791","0xc76c51a30654be30","0xd192e819d6ef5218","0xd69906245565a910","0xf40e35855771202a","0x106aa07032bbd1b8","0x19a4c116b8d2d0c8","0x1e376c085141ab53","0x2748774cdf8eeb99","0x34b0bcb5e19b48a8","0x391c0cb3c5c95a63","0x4ed8aa4ae3418acb","0x5b9cca4f7763e373","0x682e6ff3d6b2b8a3","0x748f82ee5defb2fc","0x78a5636f43172f60","0x84c87814a1f0ab72","0x8cc702081a6439ec","0x90befffa23631e28","0xa4506cebde82bde9","0xbef9a3f7b2c67915","0xc67178f2e372532b","0xca273eceea26619c","0xd186b8c721c0c207","0xeada7dd6cde0eb1e","0xf57d4f7fee6ed178","0x06f067aa72176fba","0x0a637dc5a2c898a6","0x113f9804bef90dae","0x1b710b35131c471b","0x28db77f523047d84","0x32caab7b40c72493","0x3c9ebe0a15c9bebc","0x431d67c49c100d4c","0x4cc5d4becb3e42b6","0x597f299cfc657e2a","0x5fcb6fab3ad6faec","0x6c44198c4a475817"].map(t=>BigInt(t))),dt=new Uint32Array(80),ht=new Uint32Array(80),zt=class extends Tt{constructor(){super(128,64,16,!1),this.Ah=1779033703,this.Al=-205731576,this.Bh=-1150833019,this.Bl=-2067093701,this.Ch=1013904242,this.Cl=-23791573,this.Dh=-1521486534,this.Dl=1595750129,this.Eh=1359893119,this.El=-1377402159,this.Fh=-1694144372,this.Fl=725511199,this.Gh=528734635,this.Gl=-79577749,this.Hh=1541459225,this.Hl=327033209}get(){let{Ah:e,Al:n,Bh:o,Bl:s,Ch:r,Cl:i,Dh:f,Dl:c,Eh:u,El:a,Fh:d,Fl:b,Gh:w,Gl:B,Hh:y,Hl:A}=this;return[e,n,o,s,r,i,f,c,u,a,d,b,w,B,y,A]}set(e,n,o,s,r,i,f,c,u,a,d,b,w,B,y,A){this.Ah=e|0,this.Al=n|0,this.Bh=o|0,this.Bl=s|0,this.Ch=r|0,this.Cl=i|0,this.Dh=f|0,this.Dl=c|0,this.Eh=u|0,this.El=a|0,this.Fh=d|0,this.Fl=b|0,this.Gh=w|0,this.Gl=B|0,this.Hh=y|0,this.Hl=A|0}process(e,n){for(let x=0;x<16;x++,n+=4)dt[x]=e.getUint32(n),ht[x]=e.getUint32(n+=4);for(let x=16;x<80;x++){let I=dt[x-15]|0,R=ht[x-15]|0,H=_.rotrSH(I,R,1)^_.rotrSH(I,R,8)^_.shrSH(I,R,7),z=_.rotrSL(I,R,1)^_.rotrSL(I,R,8)^_.shrSL(I,R,7),q=dt[x-2]|0,$=ht[x-2]|0,G=_.rotrSH(q,$,19)^_.rotrBH(q,$,61)^_.shrSH(q,$,6),Z=_.rotrSL(q,$,19)^_.rotrBL(q,$,61)^_.shrSL(q,$,6),ut=_.add4L(z,Z,ht[x-7],ht[x-16]),st=_.add4H(ut,H,G,dt[x-7],dt[x-16]);dt[x]=st|0,ht[x]=ut|0}let{Ah:o,Al:s,Bh:r,Bl:i,Ch:f,Cl:c,Dh:u,Dl:a,Eh:d,El:b,Fh:w,Fl:B,Gh:y,Gl:A,Hh:T,Hl:p}=this;for(let x=0;x<80;x++){let I=_.rotrSH(d,b,14)^_.rotrSH(d,b,18)^_.rotrBH(d,b,41),R=_.rotrSL(d,b,14)^_.rotrSL(d,b,18)^_.rotrBL(d,b,41),H=d&w^~d&y,z=b&B^~b&A,q=_.add5L(p,R,z,In[x],ht[x]),$=_.add5H(q,T,I,H,An[x],dt[x]),G=q|0,Z=_.rotrSH(o,s,28)^_.rotrBH(o,s,34)^_.rotrBH(o,s,39),ut=_.rotrSL(o,s,28)^_.rotrBL(o,s,34)^_.rotrBL(o,s,39),st=o&r^o&f^r&f,yt=s&i^s&c^i&c;T=y|0,p=A|0,y=w|0,A=B|0,w=d|0,B=b|0,{h:d,l:b}=_.add(u|0,a|0,$|0,G|0),u=f|0,a=c|0,f=r|0,c=i|0,r=o|0,i=s|0;let m=_.add3L(G,ut,yt);o=_.add3H(m,$,Z,st),s=m|0}({h:o,l:s}=_.add(this.Ah|0,this.Al|0,o|0,s|0)),{h:r,l:i}=_.add(this.Bh|0,this.Bl|0,r|0,i|0),{h:f,l:c}=_.add(this.Ch|0,this.Cl|0,f|0,c|0),{h:u,l:a}=_.add(this.Dh|0,this.Dl|0,u|0,a|0),{h:d,l:b}=_.add(this.Eh|0,this.El|0,d|0,b|0),{h:w,l:B}=_.add(this.Fh|0,this.Fl|0,w|0,B|0),{h:y,l:A}=_.add(this.Gh|0,this.Gl|0,y|0,A|0),{h:T,l:p}=_.add(this.Hh|0,this.Hl|0,T|0,p|0),this.set(o,s,r,i,f,c,u,a,d,b,w,B,y,A,T,p)}roundClean(){dt.fill(0),ht.fill(0)}destroy(){this.buffer.fill(0),this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)}};var mt=we(()=>new zt);var Ln=BigInt(0),vn=BigInt(1),Tn=BigInt(2);function kt(t){return t instanceof Uint8Array||t!=null&&typeof t=="object"&&t.constructor.name==="Uint8Array"}function ct(t){if(!kt(t))throw new Error("Uint8Array expected")}function Ut(t,e){if(typeof e!="boolean")throw new Error(`${t} must be valid boolean, got "${e}".`)}var Cn=Array.from({length:256},(t,e)=>e.toString(16).padStart(2,"0"));function gt(t){ct(t);let e="";for(let n=0;n=it._0&&t<=it._9)return t-it._0;if(t>=it._A&&t<=it._F)return t-(it._A-10);if(t>=it._a&&t<=it._f)return t-(it._a-10)}function Ee(t){if(typeof t!="string")throw new Error("hex string expected, got "+typeof t);let e=t.length,n=e/2;if(e%2)throw new Error("padded hex string expected, got unpadded hex of length "+e);let o=new Uint8Array(n);for(let s=0,r=0;stypeof t=="bigint"&&Ln<=t;function Un(t,e,n){return Ft(t)&&Ft(e)&&Ft(n)&&e<=t&&t(Tn<typeof t=="bigint",function:t=>typeof t=="function",boolean:t=>typeof t=="boolean",string:t=>typeof t=="string",stringOrUint8Array:t=>typeof t=="string"||kt(t),isSafeInteger:t=>Number.isSafeInteger(t),array:t=>Array.isArray(t),field:(t,e)=>e.Fp.isValid(t),hash:t=>typeof t=="function"&&Number.isSafeInteger(t.outputLen)};function ot(t,e,n={}){let o=(s,r,i)=>{let f=On[r];if(typeof f!="function")throw new Error(`Invalid validator "${r}", expected function`);let c=t[s];if(!(i&&c===void 0)&&!f(c,t))throw new Error(`Invalid param ${String(s)}=${c} (${typeof c}), expected ${r}`)};for(let[s,r]of Object.entries(e))o(s,r,!1);for(let[s,r]of Object.entries(n))o(s,r,!0);return t}function jt(t){let e=new WeakMap;return(n,...o)=>{let s=e.get(n);if(s!==void 0)return s;let r=t(n,...o);return e.set(n,r),r}}var V=BigInt(0),D=BigInt(1),bt=BigInt(2),Rn=BigInt(3),Pt=BigInt(4),Ae=BigInt(5),Ie=BigInt(8),Hn=BigInt(9),qn=BigInt(16);function U(t,e){let n=t%e;return n>=V?n:e+n}function Yt(t,e,n){if(n<=V||e 0");if(n===D)return V;let o=D;for(;e>V;)e&D&&(o=o*t%n),t=t*t%n,e>>=D;return o}function X(t,e,n){let o=t;for(;e-- >V;)o*=o,o%=n;return o}function Le(t,e){if(t===V||e<=V)throw new Error(`invert: expected positive integers, got n=${t} mod=${e}`);let n=U(t,e),o=e,s=V,r=D,i=D,f=V;for(;n!==V;){let u=o/n,a=o%n,d=s-i*u,b=r-f*u;o=n,n=a,s=i,r=f,i=d,f=b}if(o!==D)throw new Error("invert: does not exist");return U(s,e)}function Dn(t){let e=(t-D)/bt,n,o,s;for(n=t-D,o=0;n%bt===V;n/=bt,o++);for(s=bt;s(U(t,e)&D)===D,$n=["create","isValid","is0","neg","inv","sqrt","sqr","eql","add","sub","mul","pow","div","addN","subN","mulN","sqrN"];function ve(t){let e={ORDER:"bigint",MASK:"bigint",BYTES:"isSafeInteger",BITS:"isSafeInteger"},n=$n.reduce((o,s)=>(o[s]="function",o),e);return ot(t,n)}function Vn(t,e,n){if(n 0");if(n===V)return t.ONE;if(n===D)return e;let o=t.ONE,s=e;for(;n>V;)n&D&&(o=t.mul(o,s)),s=t.sqr(s),n>>=D;return o}function Zn(t,e){let n=new Array(e.length),o=e.reduce((r,i,f)=>t.is0(i)?r:(n[f]=r,t.mul(r,i)),t.ONE),s=t.inv(o);return e.reduceRight((r,i,f)=>t.is0(i)?r:(n[f]=t.mul(r,n[f]),t.mul(r,i)),s),n}function Xt(t,e){let n=e!==void 0?e:t.toString(2).length,o=Math.ceil(n/8);return{nBitLength:n,nByteLength:o}}function Te(t,e,n=!1,o={}){if(t<=V)throw new Error(`Expected Field ORDER > 0, got ${t}`);let{nBitLength:s,nByteLength:r}=Xt(t,e);if(r>2048)throw new Error("Field lengths over 2048 bytes are not supported");let i=Mn(t),f=Object.freeze({ORDER:t,BITS:s,BYTES:r,MASK:Se(s),ZERO:V,ONE:D,create:c=>U(c,t),isValid:c=>{if(typeof c!="bigint")throw new Error(`Invalid field element: expected bigint, got ${typeof c}`);return V<=c&&cc===V,isOdd:c=>(c&D)===D,neg:c=>U(-c,t),eql:(c,u)=>c===u,sqr:c=>U(c*c,t),add:(c,u)=>U(c+u,t),sub:(c,u)=>U(c-u,t),mul:(c,u)=>U(c*u,t),pow:(c,u)=>Vn(f,c,u),div:(c,u)=>U(c*Le(u,t),t),sqrN:c=>c*c,addN:(c,u)=>c+u,subN:(c,u)=>c-u,mulN:(c,u)=>c*u,inv:c=>Le(c,t),sqrt:o.sqrt||(c=>i(f,c)),invertBatch:c=>Zn(f,c),cmov:(c,u,a)=>a?u:c,toBytes:c=>n?et(c,r):Gt(c,r),fromBytes:c=>{if(c.length!==r)throw new Error(`Fp.fromBytes: expected ${r}, got ${c.length}`);return n?W(c):Ot(c)}});return Object.freeze(f)}function Ce(t,e){if(!t.isOdd)throw new Error("Field doesn't have isOdd");let n=t.sqrt(e);return t.isOdd(n)?t.neg(n):n}var zn=BigInt(0),Wt=BigInt(1),Kt=new WeakMap,Ue=new WeakMap;function Oe(t,e){let n=(r,i)=>{let f=i.negate();return r?f:i},o=r=>{if(!Number.isSafeInteger(r)||r<=0||r>e)throw new Error(`Wrong window size=${r}, should be [1..${e}]`)},s=r=>{o(r);let i=Math.ceil(e/r)+1,f=2**(r-1);return{windows:i,windowSize:f}};return{constTimeNegate:n,unsafeLadder(r,i){let f=t.ZERO,c=r;for(;i>zn;)i&Wt&&(f=f.add(c)),c=c.double(),i>>=Wt;return f},precomputeWindow(r,i){let{windows:f,windowSize:c}=s(i),u=[],a=r,d=a;for(let b=0;b>=B,T>u&&(T-=w,f+=Wt);let p=A,x=A+Math.abs(T)-1,I=y%2!==0,R=T<0;T===0?d=d.add(n(I,i[p])):a=a.add(n(R,i[x]))}return{p:a,f:d}},wNAFCached(r,i,f){let c=Ue.get(r)||1,u=Kt.get(r);return u||(u=this.precomputeWindow(r,c),c!==1&&Kt.set(r,f(u))),this.wNAF(c,u,i)},setWindowSize(r,i){o(i),Ue.set(r,i),Kt.delete(r)}}}function Ne(t){return ve(t.Fp),ot(t,{n:"bigint",h:"bigint",Gx:"field",Gy:"field"},{nBitLength:"isSafeInteger",nByteLength:"isSafeInteger"}),Object.freeze({...Xt(t.n,t.nBitLength),...t,p:t.Fp.ORDER})}var K=BigInt(0),P=BigInt(1),Rt=BigInt(2),Fn=BigInt(8),kn={zip215:!0};function Gn(t){let e=Ne(t);return ot(t,{hash:"function",a:"bigint",d:"bigint",randomBytes:"function"},{adjustScalarBytes:"function",domain:"function",uvRatio:"function",mapToCurve:"function"}),Object.freeze({...e})}function Ht(t){let e=Gn(t),{Fp:n,n:o,prehash:s,hash:r,randomBytes:i,nByteLength:f,h:c}=e,u=Rt<{try{return{isValid:!0,value:n.sqrt(m*n.inv(l))}}catch{return{isValid:!1,value:K}}}),b=e.adjustScalarBytes||(m=>m),w=e.domain||((m,l,g)=>{if(Ut("phflag",g),l.length||g)throw new Error("Contexts/pre-hash are not supported");return m});function B(m,l){ft("coordinate "+m,l,K,u)}function y(m){if(!(m instanceof p))throw new Error("ExtendedPoint expected")}let A=jt((m,l)=>{let{ex:g,ey:E,ez:S}=m,L=m.is0();l==null&&(l=L?Fn:n.inv(S));let C=a(g*l),O=a(E*l),v=a(S*l);if(L)return{x:K,y:P};if(v!==P)throw new Error("invZ was invalid");return{x:C,y:O}}),T=jt(m=>{let{a:l,d:g}=e;if(m.is0())throw new Error("bad point: ZERO");let{ex:E,ey:S,ez:L,et:C}=m,O=a(E*E),v=a(S*S),N=a(L*L),M=a(N*N),j=a(O*l),J=a(N*a(j+v)),tt=a(M+a(g*a(O*v)));if(J!==tt)throw new Error("bad point: equation left != right (1)");let Y=a(E*S),lt=a(L*C);if(Y!==lt)throw new Error("bad point: equation left != right (2)");return!0});class p{constructor(l,g,E,S){this.ex=l,this.ey=g,this.ez=E,this.et=S,B("x",l),B("y",g),B("z",E),B("t",S),Object.freeze(this)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}static fromAffine(l){if(l instanceof p)throw new Error("extended point not allowed");let{x:g,y:E}=l||{};return B("x",g),B("y",E),new p(g,E,P,a(g*E))}static normalizeZ(l){let g=n.invertBatch(l.map(E=>E.ez));return l.map((E,S)=>E.toAffine(g[S])).map(p.fromAffine)}_setWindowSize(l){R.setWindowSize(this,l)}assertValidity(){T(this)}equals(l){y(l);let{ex:g,ey:E,ez:S}=this,{ex:L,ey:C,ez:O}=l,v=a(g*O),N=a(L*S),M=a(E*O),j=a(C*S);return v===N&&M===j}is0(){return this.equals(p.ZERO)}negate(){return new p(a(-this.ex),this.ey,this.ez,a(-this.et))}double(){let{a:l}=e,{ex:g,ey:E,ez:S}=this,L=a(g*g),C=a(E*E),O=a(Rt*a(S*S)),v=a(l*L),N=g+E,M=a(a(N*N)-L-C),j=v+C,J=j-O,tt=v-C,Y=a(M*J),lt=a(j*tt),xt=a(M*tt),_t=a(J*j);return new p(Y,lt,_t,xt)}add(l){y(l);let{a:g,d:E}=e,{ex:S,ey:L,ez:C,et:O}=this,{ex:v,ey:N,ez:M,et:j}=l;if(g===BigInt(-1)){let ae=a((L-S)*(N+v)),ue=a((L+S)*(N-v)),Dt=a(ue-ae);if(Dt===K)return this.double();let le=a(C*Rt*j),de=a(O*Rt*M),he=de+le,pe=ue+ae,xe=de-le,We=a(he*Dt),Ke=a(pe*xe),Qe=a(he*xe),Je=a(Dt*pe);return new p(We,Ke,Je,Qe)}let J=a(S*v),tt=a(L*N),Y=a(O*E*j),lt=a(C*M),xt=a((S+L)*(v+N)-J-tt),_t=lt-Y,ce=lt+Y,fe=a(tt-g*J),je=a(xt*_t),Pe=a(ce*fe),Ye=a(xt*fe),Xe=a(_t*ce);return new p(je,Pe,Xe,Ye)}subtract(l){return this.add(l.negate())}wNAF(l){return R.wNAFCached(this,l,p.normalizeZ)}multiply(l){let g=l;ft("scalar",g,P,o);let{p:E,f:S}=this.wNAF(g);return p.normalizeZ([E,S])[0]}multiplyUnsafe(l){let g=l;return ft("scalar",g,K,o),g===K?I:this.equals(I)||g===P?this:this.equals(x)?this.wNAF(g).p:R.unsafeLadder(this,g)}isSmallOrder(){return this.multiplyUnsafe(c).is0()}isTorsionFree(){return R.unsafeLadder(this,o).is0()}toAffine(l){return A(this,l)}clearCofactor(){let{h:l}=e;return l===P?this:this.multiplyUnsafe(l)}static fromHex(l,g=!1){let{d:E,a:S}=e,L=n.BYTES;l=k("pointHex",l,L),Ut("zip215",g);let C=l.slice(),O=l[L-1];C[L-1]=O&-129;let v=W(C),N=g?u:n.ORDER;ft("pointHex.y",v,K,N);let M=a(v*v),j=a(M-P),J=a(E*M-S),{isValid:tt,value:Y}=d(j,J);if(!tt)throw new Error("Point.fromHex: invalid y coordinate");let lt=(Y&P)===P,xt=(O&128)!==0;if(!g&&Y===K&&xt)throw new Error("Point.fromHex: x=0 and x_0=1");return xt!==lt&&(Y=a(-Y)),p.fromAffine({x:Y,y:v})}static fromPrivateKey(l){return q(l).point}toRawBytes(){let{x:l,y:g}=this.toAffine(),E=et(g,n.BYTES);return E[E.length-1]|=l&P?128:0,E}toHex(){return gt(this.toRawBytes())}}p.BASE=new p(e.Gx,e.Gy,P,a(e.Gx*e.Gy)),p.ZERO=new p(K,P,P,K);let{BASE:x,ZERO:I}=p,R=Oe(p,f*8);function H(m){return U(m,o)}function z(m){return H(W(m))}function q(m){let l=f;m=k("private key",m,l);let g=k("hashed private key",r(m),2*l),E=b(g.slice(0,l)),S=g.slice(l,2*l),L=z(E),C=x.multiply(L),O=C.toRawBytes();return{head:E,prefix:S,scalar:L,point:C,pointBytes:O}}function $(m){return q(m).pointBytes}function G(m=new Uint8Array,...l){let g=nt(...l);return z(r(w(g,k("context",m),!!s)))}function Z(m,l,g={}){m=k("message",m),s&&(m=s(m));let{prefix:E,scalar:S,pointBytes:L}=q(l),C=G(g.context,E,m),O=x.multiply(C).toRawBytes(),v=G(g.context,O,L,m),N=H(C+v*S);ft("signature.s",N,K,o);let M=nt(O,et(N,n.BYTES));return k("result",M,f*2)}let ut=kn;function st(m,l,g,E=ut){let{context:S,zip215:L}=E,C=n.BYTES;m=k("signature",m,2*C),l=k("message",l),L!==void 0&&Ut("zip215",L),s&&(l=s(l));let O=W(m.slice(C,2*C)),v,N,M;try{v=p.fromHex(g,L),N=p.fromHex(m.slice(0,C),L),M=x.multiplyUnsafe(O)}catch{return!1}if(!L&&v.isSmallOrder())return!1;let j=G(S,N.toRawBytes(),v.toRawBytes(),l);return N.add(v.multiplyUnsafe(j)).subtract(M).clearCofactor().equals(p.ZERO)}return x._setWindowSize(8),{CURVE:e,getPublicKey:$,sign:Z,verify:st,ExtendedPoint:p,utils:{getExtendedPublicKey:q,randomPrivateKey:()=>i(n.BYTES),precompute(m=8,l=p.BASE){return l._setWindowSize(m),l.multiply(BigInt(3)),l}}}}var jn=Ot;function pt(t,e){if(t<0||t>=1<<8*e)throw new Error(`bad I2OSP call: value=${t} length=${e}`);let n=Array.from({length:e}).fill(0);for(let o=e-1;o>=0;o--)n[o]=t&255,t>>>=8;return new Uint8Array(n)}function Pn(t,e){let n=new Uint8Array(t.length);for(let o=0;o255&&(e=o(nt(Nt("H2C-OVERSIZE-DST-"),e)));let{outputLen:s,blockLen:r}=o,i=Math.ceil(n/s);if(i>255)throw new Error("Invalid xmd length");let f=nt(e,pt(e.length,1)),c=pt(0,r),u=pt(n,2),a=new Array(i),d=o(nt(c,t,u,pt(0,1),f));a[0]=o(nt(d,pt(1,1),f));for(let w=1;w<=i;w++){let B=[Pn(d,a[w-1]),pt(w+1,1),f];a[w]=o(nt(...B))}return nt(...a).slice(0,n)}function Yn(t,e,n,o,s){if(ct(t),ct(e),Qt(n),e.length>255){let r=Math.ceil(2*o/8);e=s.create({dkLen:r}).update(Nt("H2C-OVERSIZE-DST-")).update(e).digest()}if(n>65535||e.length>255)throw new Error("expand_message_xof: invalid lenInBytes");return s.create({dkLen:n}).update(t).update(pt(n,2)).update(e).update(pt(e.length,1)).digest()}function Re(t,e,n){ot(n,{DST:"stringOrUint8Array",p:"bigint",m:"isSafeInteger",k:"isSafeInteger",hash:"hash"});let{p:o,k:s,m:r,hash:i,expand:f,DST:c}=n;ct(t),Qt(e);let u=typeof c=="string"?Nt(c):c,a=o.toString(2).length,d=Math.ceil((a+s)/8),b=e*r*d,w;if(f==="xmd")w=Jt(t,u,b,i);else if(f==="xof")w=Yn(t,u,b,s,i);else if(f==="_internal_pass")w=t;else throw new Error('expand must be "xmd" or "xof"');let B=new Array(e);for(let y=0;yU(p,n),s=e.montgomeryBits,r=Math.ceil(s/8),i=e.nByteLength,f=e.adjustScalarBytes||(p=>p),c=e.powPminus2||(p=>Yt(p,n-BigInt(2),n));function u(p,x,I){let R=o(p*(x-I));return x=o(x-R),I=o(I+R),[x,I]}let a=(e.a-BigInt(2))/BigInt(4);function d(p,x){ft("u",p,wt,n),ft("scalar",x,wt,n);let I=x,R=p,H=te,z=wt,q=p,$=te,G=wt,Z;for(let st=BigInt(s-1);st>=wt;st--){let yt=I>>st&te;G^=yt,Z=u(G,H,q),H=Z[0],q=Z[1],Z=u(G,z,$),z=Z[0],$=Z[1],G=yt;let m=H+z,l=o(m*m),g=H-z,E=o(g*g),S=l-E,L=q+$,C=q-$,O=o(C*m),v=o(L*g),N=O+v,M=O-v;q=o(N*N),$=o(R*o(M*M)),H=o(l*E),z=o(S*(l+o(a*S)))}Z=u(G,H,q),H=Z[0],q=Z[1],Z=u(G,z,$),z=Z[0],$=Z[1];let ut=c(z);return o(H*ut)}function b(p){return et(o(p),r)}function w(p){let x=k("u coordinate",p,r);return i===32&&(x[31]&=127),W(x)}function B(p){let x=k("scalar",p),I=x.length;if(I!==r&&I!==i)throw new Error(`Expected ${r} or ${i} bytes, got ${I}`);return W(f(x))}function y(p,x){let I=w(x),R=B(p),H=d(I,R);if(H===wt)throw new Error("Invalid private or public key received");return b(H)}let A=b(e.Gu);function T(p){return y(p,A)}return{scalarMult:y,scalarMultBase:T,getSharedSecret:(p,x)=>y(p,x),getPublicKey:p=>T(p),utils:{randomPrivateKey:()=>e.randomBytes(e.nByteLength)},GuBytes:A}}var Bt=BigInt("57896044618658097711785492504343953926634992332820282019728792003956564819949"),oe=BigInt("19681161376707505956807079304988542015446066515923890162744021073123829784752"),Wn=BigInt(0),rt=BigInt(1),qt=BigInt(2),Ve=BigInt(3),Ze=BigInt(5),se=BigInt(8);function ze(t){let e=BigInt(10),n=BigInt(20),o=BigInt(40),s=BigInt(80),r=Bt,f=t*t%r*t%r,c=X(f,qt,r)*f%r,u=X(c,rt,r)*t%r,a=X(u,Ze,r)*u%r,d=X(a,e,r)*a%r,b=X(d,n,r)*d%r,w=X(b,o,r)*b%r,B=X(w,s,r)*w%r,y=X(B,s,r)*w%r,A=X(y,e,r)*a%r;return{pow_p_5_8:X(A,qt,r)*t%r,b2:f}}function Fe(t){return t[0]&=248,t[31]&=127,t[31]|=64,t}function ie(t,e){let n=Bt,o=U(e*e*e,n),s=U(o*o*e,n),r=ze(t*s).pow_p_5_8,i=U(t*o*r,n),f=U(e*i*i,n),c=i,u=U(i*oe,n),a=f===t,d=f===U(-t,n),b=f===U(-t*oe,n);return a&&(i=c),(d||b)&&(i=u),at(i,n)&&(i=U(-i,n)),{isValid:a||d,value:i}}var Po=["0100000000000000000000000000000000000000000000000000000000000000","c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a","0000000000000000000000000000000000000000000000000000000000000080","26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05","ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f","26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85","0000000000000000000000000000000000000000000000000000000000000000","c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa"],h=Te(Bt,void 0,!0),Et={a:BigInt(-1),d:BigInt("37095705934669439343138083508754565189542113879843219016388785533085940283555"),Fp:h,n:BigInt("7237005577332262213973186563042994240857116359379907606001950938285454250989"),h:se,Gx:BigInt("15112221349535400772501151409588531511454012693041857206046113283949847762202"),Gy:BigInt("46316835694926478169428394003475163141307993866256225615783033603165251855960"),hash:mt,randomBytes:Vt,adjustScalarBytes:Fe,uvRatio:ie},F=Ht(Et);function ke(t,e,n){if(e.length>255)throw new Error("Context is too big");return ge(vt("SigEd25519 no Ed25519 collisions"),new Uint8Array([n?1:0,e.length]),e,t)}var Yo=Ht({...Et,domain:ke}),Xo=Ht(Object.assign({},Et,{domain:ke,prehash:mt})),Wo=qe({P:Bt,a:BigInt(486662),montgomeryBits:255,nByteLength:32,Gu:BigInt(9),powPminus2:t=>{let e=Bt,{pow_p_5_8:n,b2:o}=ze(t);return U(X(n,Ve,e)*o,e)},adjustScalarBytes:Fe,randomBytes:Vt});function Kn(t){let{y:e}=F.ExtendedPoint.fromHex(t),n=BigInt(1);return h.toBytes(h.create((n+e)*h.inv(n-e)))}var Ko=Kn;function Qo(t){let e=Et.hash(t.subarray(0,32));return Et.adjustScalarBytes(e).subarray(0,32)}var Qn=(h.ORDER+Ve)/se,Jn=h.pow(qt,Qn),De=h.sqrt(h.neg(h.ONE));function to(t){let e=(h.ORDER-Ze)/se,n=BigInt(486662),o=h.sqr(t);o=h.mul(o,qt);let s=h.add(o,h.ONE),r=h.neg(n),i=h.sqr(s),f=h.mul(i,s),c=h.mul(o,n);c=h.mul(c,r),c=h.add(c,i),c=h.mul(c,r);let u=h.sqr(f);i=h.sqr(u),u=h.mul(u,f),u=h.mul(u,c),i=h.mul(i,u);let a=h.pow(i,e);a=h.mul(a,u);let d=h.mul(a,De);i=h.sqr(a),i=h.mul(i,f);let b=h.eql(i,c),w=h.cmov(d,a,b),B=h.mul(r,o),y=h.mul(a,t);y=h.mul(y,Jn);let A=h.mul(y,De),T=h.mul(c,o);i=h.sqr(y),i=h.mul(i,f);let p=h.eql(i,T),x=h.cmov(A,y,p);i=h.sqr(w),i=h.mul(i,f);let I=h.eql(i,c),R=h.cmov(B,r,I),H=h.cmov(x,w,I),z=h.isOdd(H);return H=h.cmov(H,h.neg(H),I!==z),{xMn:R,xMd:s,yMn:H,yMd:rt}}var eo=Ce(h,h.neg(BigInt(486664)));function no(t){let{xMn:e,xMd:n,yMn:o,yMd:s}=to(t),r=h.mul(e,s);r=h.mul(r,eo);let i=h.mul(n,o),f=h.sub(e,n),c=h.add(e,n),u=h.mul(i,c),a=h.eql(u,h.ZERO);r=h.cmov(r,h.ZERO,a),i=h.cmov(i,h.ONE,a),f=h.cmov(f,h.ONE,a),c=h.cmov(c,h.ONE,a);let d=h.invertBatch([i,c]);return{x:h.mul(r,d[0]),y:h.mul(f,d[1])}}var Ge=He(F.ExtendedPoint,t=>no(t[0]),{DST:"edwards25519_XMD:SHA-512_ELL2_RO_",encodeDST:"edwards25519_XMD:SHA-512_ELL2_NU_",p:h.ORDER,m:1,k:128,expand:"xmd",hash:mt}),Jo=Ge.hashToCurve,tr=Ge.encodeToCurve;function ee(t){if(!(t instanceof Q))throw new Error("RistrettoPoint expected")}var re=oe,oo=BigInt("25063068953384623474111414158702152701244531502492656460079210482610430750235"),ro=BigInt("54469307008909316920995813868745141605393597292927456921205312896311721017578"),so=BigInt("1159843021668779879193775521855586647937357759715417654439879720876111806838"),io=BigInt("40440834346308536858101042469323190826248399146238708352240133220865137265952"),Me=t=>ie(rt,t),co=BigInt("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),ne=t=>F.CURVE.Fp.create(W(t)&co);function $e(t){let{d:e}=F.CURVE,n=F.CURVE.Fp.ORDER,o=F.CURVE.Fp.create,s=o(re*t*t),r=o((s+rt)*so),i=BigInt(-1),f=o((i-e*s)*o(s+e)),{isValid:c,value:u}=ie(r,f),a=o(u*t);at(a,n)||(a=o(-a)),c||(u=a),c||(i=s);let d=o(i*(s-rt)*io-f),b=u*u,w=o((u+u)*f),B=o(d*oo),y=o(rt-b),A=o(rt+b);return new F.ExtendedPoint(o(w*A),o(y*B),o(B*A),o(w*y))}var Q=class t{constructor(e){this.ep=e}static fromAffine(e){return new t(F.ExtendedPoint.fromAffine(e))}static hashToCurve(e){e=k("ristrettoHash",e,64);let n=ne(e.slice(0,32)),o=$e(n),s=ne(e.slice(32,64)),r=$e(s);return new t(o.add(r))}static fromHex(e){e=k("ristrettoHex",e,32);let{a:n,d:o}=F.CURVE,s=F.CURVE.Fp.ORDER,r=F.CURVE.Fp.create,i="RistrettoPoint.fromHex: the hex is not valid encoding of RistrettoPoint",f=ne(e);if(!_e(et(f,32),e)||at(f,s))throw new Error(i);let c=r(f*f),u=r(rt+n*c),a=r(rt-n*c),d=r(u*u),b=r(a*a),w=r(n*o*d-b),{isValid:B,value:y}=Me(r(w*b)),A=r(y*a),T=r(y*A*w),p=r((f+f)*A);at(p,s)&&(p=r(-p));let x=r(u*T),I=r(p*x);if(!B||at(I,s)||x===Wn)throw new Error(i);return new t(new F.ExtendedPoint(p,x,rt,I))}toRawBytes(){let{ex:e,ey:n,ez:o,et:s}=this.ep,r=F.CURVE.Fp.ORDER,i=F.CURVE.Fp.create,f=i(i(o+n)*i(o-n)),c=i(e*n),u=i(c*c),{value:a}=Me(i(f*u)),d=i(a*f),b=i(a*c),w=i(d*b*s),B;if(at(s*w,r)){let A=i(n*re),T=i(e*re);e=A,n=T,B=i(d*ro)}else B=b;at(e*w,r)&&(n=i(-n));let y=i((o-n)*B);return at(y,r)&&(y=i(-y)),et(y,32)}toHex(){return gt(this.toRawBytes())}toString(){return this.toHex()}equals(e){ee(e);let{ex:n,ey:o}=this.ep,{ex:s,ey:r}=e.ep,i=F.CURVE.Fp.create,f=i(n*r)===i(o*s),c=i(o*r)===i(n*s);return f||c}add(e){return ee(e),new t(this.ep.add(e.ep))}subtract(e){return ee(e),new t(this.ep.subtract(e.ep))}multiply(e){return new t(this.ep.multiply(e))}multiplyUnsafe(e){return new t(this.ep.multiplyUnsafe(e))}double(){return new t(this.ep.double())}negate(){return new t(this.ep.negate())}},er=(Q.BASE||(Q.BASE=new Q(F.ExtendedPoint.BASE)),Q.ZERO||(Q.ZERO=new Q(F.ExtendedPoint.ZERO)),Q),fo=(t,e)=>{let n=e.DST,o=typeof n=="string"?vt(n):n,s=Jt(t,o,64,mt);return Q.hashToCurve(s)},nr=fo;export{Po as ED25519_TORSION_SUBGROUP,er as RistrettoPoint,F as ed25519,Yo as ed25519ctx,Xo as ed25519ph,Ko as edwardsToMontgomery,Qo as edwardsToMontgomeryPriv,Kn as edwardsToMontgomeryPub,tr as encodeToCurve,Jo as hashToCurve,fo as hashToRistretto255,nr as hash_to_ristretto255,Wo as x25519}; +/*! Bundled license information: + +@noble/hashes/esm/utils.js: + (*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *) + +@noble/curves/esm/abstract/utils.js: +@noble/curves/esm/abstract/modular.js: +@noble/curves/esm/abstract/curve.js: +@noble/curves/esm/abstract/edwards.js: +@noble/curves/esm/abstract/montgomery.js: +@noble/curves/esm/ed25519.js: + (*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) *) +*/ diff --git a/shine-UI/js/vendor/noble/curves-ed25519-1.8.1.bundle.mjs b/shine-UI/js/vendor/noble/curves-ed25519-1.8.1.bundle.mjs new file mode 100644 index 00000000..774ce916 --- /dev/null +++ b/shine-UI/js/vendor/noble/curves-ed25519-1.8.1.bundle.mjs @@ -0,0 +1,15 @@ +/* esm.sh - @noble/curves@1.8.1/ed25519 */ +function ln(t){return t instanceof Uint8Array||ArrayBuffer.isView(t)&&t.constructor.name==="Uint8Array"}function Lt(t,...e){if(!ln(t))throw new Error("Uint8Array expected");if(e.length>0&&!e.includes(t.length))throw new Error("Uint8Array expected of length "+e+", got length="+t.length)}function zt(t,e=!0){if(t.destroyed)throw new Error("Hash instance has been destroyed");if(e&&t.finished)throw new Error("Hash#digest() has already been called")}function _e(t,e){Lt(t);let n=e.outputLen;if(t.lengtht().update(Gt(o)).digest(),n=t();return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=()=>t(),e}function jt(t=32){if(bt&&typeof bt.getRandomValues=="function")return bt.getRandomValues(new Uint8Array(t));if(bt&&typeof bt.randomBytes=="function")return bt.randomBytes(t);throw new Error("crypto.getRandomValues must be defined")}function dn(t,e,n,o){if(typeof t.setBigUint64=="function")return t.setBigUint64(e,n,o);let s=BigInt(32),r=BigInt(4294967295),i=Number(n>>s&r),a=Number(n&r),c=o?4:0,u=o?0:4;t.setUint32(e+c,i,o),t.setUint32(e+u,a,o)}var Ct=class extends Tt{constructor(e,n,o,s){super(),this.blockLen=e,this.outputLen=n,this.padOffset=o,this.isLE=s,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(e),this.view=Ut(this.buffer)}update(e){zt(this);let{view:n,buffer:o,blockLen:s}=this;e=Gt(e);let r=e.length;for(let i=0;is-i&&(this.process(o,0),i=0);for(let h=i;hf.length)throw new Error("_sha2: outputLen bigger than state");for(let h=0;h>Pt&Rt)}:{h:Number(t>>Pt&Rt)|0,l:Number(t&Rt)|0}}function hn(t,e=!1){let n=new Uint32Array(t.length),o=new Uint32Array(t.length);for(let s=0;sBigInt(t>>>0)<>>0),xn=(t,e,n)=>t>>>n,gn=(t,e,n)=>t<<32-n|e>>>n,bn=(t,e,n)=>t>>>n|e<<32-n,mn=(t,e,n)=>t<<32-n|e>>>n,wn=(t,e,n)=>t<<64-n|e>>>n-32,yn=(t,e,n)=>t>>>n-32|e<<64-n,Bn=(t,e)=>e,En=(t,e)=>t,_n=(t,e,n)=>t<>>32-n,An=(t,e,n)=>e<>>32-n,Sn=(t,e,n)=>e<>>64-n,In=(t,e,n)=>t<>>64-n;function vn(t,e,n,o){let s=(e>>>0)+(o>>>0);return{h:t+n+(s/2**32|0)|0,l:s|0}}var Ln=(t,e,n)=>(t>>>0)+(e>>>0)+(n>>>0),Tn=(t,e,n,o)=>e+n+o+(t/2**32|0)|0,Un=(t,e,n,o)=>(t>>>0)+(e>>>0)+(n>>>0)+(o>>>0),On=(t,e,n,o,s)=>e+n+o+s+(t/2**32|0)|0,Cn=(t,e,n,o,s)=>(t>>>0)+(e>>>0)+(n>>>0)+(o>>>0)+(s>>>0),Rn=(t,e,n,o,s,r)=>e+n+o+s+r+(t/2**32|0)|0;var Nn={fromBig:Ie,split:hn,toBig:pn,shrSH:xn,shrSL:gn,rotrSH:bn,rotrSL:mn,rotrBH:wn,rotrBL:yn,rotr32H:Bn,rotr32L:En,rotlSH:_n,rotlSL:An,rotlBH:Sn,rotlBL:In,add:vn,add3L:Ln,add3H:Tn,add4L:Un,add4H:On,add5H:Rn,add5L:Cn},_=Nn;var[Hn,qn]=_.split(["0x428a2f98d728ae22","0x7137449123ef65cd","0xb5c0fbcfec4d3b2f","0xe9b5dba58189dbbc","0x3956c25bf348b538","0x59f111f1b605d019","0x923f82a4af194f9b","0xab1c5ed5da6d8118","0xd807aa98a3030242","0x12835b0145706fbe","0x243185be4ee4b28c","0x550c7dc3d5ffb4e2","0x72be5d74f27b896f","0x80deb1fe3b1696b1","0x9bdc06a725c71235","0xc19bf174cf692694","0xe49b69c19ef14ad2","0xefbe4786384f25e3","0x0fc19dc68b8cd5b5","0x240ca1cc77ac9c65","0x2de92c6f592b0275","0x4a7484aa6ea6e483","0x5cb0a9dcbd41fbd4","0x76f988da831153b5","0x983e5152ee66dfab","0xa831c66d2db43210","0xb00327c898fb213f","0xbf597fc7beef0ee4","0xc6e00bf33da88fc2","0xd5a79147930aa725","0x06ca6351e003826f","0x142929670a0e6e70","0x27b70a8546d22ffc","0x2e1b21385c26c926","0x4d2c6dfc5ac42aed","0x53380d139d95b3df","0x650a73548baf63de","0x766a0abb3c77b2a8","0x81c2c92e47edaee6","0x92722c851482353b","0xa2bfe8a14cf10364","0xa81a664bbc423001","0xc24b8b70d0f89791","0xc76c51a30654be30","0xd192e819d6ef5218","0xd69906245565a910","0xf40e35855771202a","0x106aa07032bbd1b8","0x19a4c116b8d2d0c8","0x1e376c085141ab53","0x2748774cdf8eeb99","0x34b0bcb5e19b48a8","0x391c0cb3c5c95a63","0x4ed8aa4ae3418acb","0x5b9cca4f7763e373","0x682e6ff3d6b2b8a3","0x748f82ee5defb2fc","0x78a5636f43172f60","0x84c87814a1f0ab72","0x8cc702081a6439ec","0x90befffa23631e28","0xa4506cebde82bde9","0xbef9a3f7b2c67915","0xc67178f2e372532b","0xca273eceea26619c","0xd186b8c721c0c207","0xeada7dd6cde0eb1e","0xf57d4f7fee6ed178","0x06f067aa72176fba","0x0a637dc5a2c898a6","0x113f9804bef90dae","0x1b710b35131c471b","0x28db77f523047d84","0x32caab7b40c72493","0x3c9ebe0a15c9bebc","0x431d67c49c100d4c","0x4cc5d4becb3e42b6","0x597f299cfc657e2a","0x5fcb6fab3ad6faec","0x6c44198c4a475817"].map(t=>BigInt(t))),dt=new Uint32Array(80),ht=new Uint32Array(80),Yt=class extends Ct{constructor(){super(128,64,16,!1),this.Ah=1779033703,this.Al=-205731576,this.Bh=-1150833019,this.Bl=-2067093701,this.Ch=1013904242,this.Cl=-23791573,this.Dh=-1521486534,this.Dl=1595750129,this.Eh=1359893119,this.El=-1377402159,this.Fh=-1694144372,this.Fl=725511199,this.Gh=528734635,this.Gl=-79577749,this.Hh=1541459225,this.Hl=327033209}get(){let{Ah:e,Al:n,Bh:o,Bl:s,Ch:r,Cl:i,Dh:a,Dl:c,Eh:u,El:f,Fh:h,Fl:g,Gh:x,Gl:w,Hh:b,Hl:S}=this;return[e,n,o,s,r,i,a,c,u,f,h,g,x,w,b,S]}set(e,n,o,s,r,i,a,c,u,f,h,g,x,w,b,S){this.Ah=e|0,this.Al=n|0,this.Bh=o|0,this.Bl=s|0,this.Ch=r|0,this.Cl=i|0,this.Dh=a|0,this.Dl=c|0,this.Eh=u|0,this.El=f|0,this.Fh=h|0,this.Fl=g|0,this.Gh=x|0,this.Gl=w|0,this.Hh=b|0,this.Hl=S|0}process(e,n){for(let d=0;d<16;d++,n+=4)dt[d]=e.getUint32(n),ht[d]=e.getUint32(n+=4);for(let d=16;d<80;d++){let I=dt[d-15]|0,M=ht[d-15]|0,U=_.rotrSH(I,M,1)^_.rotrSH(I,M,8)^_.shrSH(I,M,7),k=_.rotrSL(I,M,1)^_.rotrSL(I,M,8)^_.shrSL(I,M,7),D=dt[d-2]|0,H=ht[d-2]|0,Y=_.rotrSH(D,H,19)^_.rotrBH(D,H,61)^_.shrSH(D,H,6),V=_.rotrSL(D,H,19)^_.rotrBL(D,H,61)^_.shrSL(D,H,6),ut=_.add4L(k,V,ht[d-7],ht[d-16]),st=_.add4H(ut,U,Y,dt[d-7],dt[d-16]);dt[d]=st|0,ht[d]=ut|0}let{Ah:o,Al:s,Bh:r,Bl:i,Ch:a,Cl:c,Dh:u,Dl:f,Eh:h,El:g,Fh:x,Fl:w,Gh:b,Gl:S,Hh:N,Hl:B}=this;for(let d=0;d<80;d++){let I=_.rotrSH(h,g,14)^_.rotrSH(h,g,18)^_.rotrBH(h,g,41),M=_.rotrSL(h,g,14)^_.rotrSL(h,g,18)^_.rotrBL(h,g,41),U=h&x^~h&b,k=g&w^~g&S,D=_.add5L(B,M,k,qn[d],ht[d]),H=_.add5H(D,N,I,U,Hn[d],dt[d]),Y=D|0,V=_.rotrSH(o,s,28)^_.rotrBH(o,s,34)^_.rotrBH(o,s,39),ut=_.rotrSL(o,s,28)^_.rotrBL(o,s,34)^_.rotrBL(o,s,39),st=o&r^o&a^r&a,wt=s&i^s&c^i&c;N=b|0,B=S|0,b=x|0,S=w|0,x=h|0,w=g|0,{h,l:g}=_.add(u|0,f|0,H|0,Y|0),u=a|0,f=c|0,a=r|0,c=i|0,r=o|0,i=s|0;let xt=_.add3L(Y,ut,wt);o=_.add3H(xt,H,V,st),s=xt|0}({h:o,l:s}=_.add(this.Ah|0,this.Al|0,o|0,s|0)),{h:r,l:i}=_.add(this.Bh|0,this.Bl|0,r|0,i|0),{h:a,l:c}=_.add(this.Ch|0,this.Cl|0,a|0,c|0),{h:u,l:f}=_.add(this.Dh|0,this.Dl|0,u|0,f|0),{h,l:g}=_.add(this.Eh|0,this.El|0,h|0,g|0),{h:x,l:w}=_.add(this.Fh|0,this.Fl|0,x|0,w|0),{h:b,l:S}=_.add(this.Gh|0,this.Gl|0,b|0,S|0),{h:N,l:B}=_.add(this.Hh|0,this.Hl|0,N|0,B|0),this.set(o,s,r,i,a,c,u,f,h,g,x,w,b,S,N,B)}roundClean(){dt.fill(0),ht.fill(0)}destroy(){this.buffer.fill(0),this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)}};var Et=Se(()=>new Yt);var Wt=BigInt(0),Le=BigInt(1),Mn=BigInt(2);function Kt(t){return t instanceof Uint8Array||ArrayBuffer.isView(t)&&t.constructor.name==="Uint8Array"}function ct(t){if(!Kt(t))throw new Error("Uint8Array expected")}function Nt(t,e){if(typeof e!="boolean")throw new Error(t+" boolean expected, got "+e)}var Dn=Array.from({length:256},(t,e)=>e.toString(16).padStart(2,"0"));function yt(t){ct(t);let e="";for(let n=0;n=it._0&&t<=it._9)return t-it._0;if(t>=it.A&&t<=it.F)return t-(it.A-10);if(t>=it.a&&t<=it.f)return t-(it.a-10)}function Ue(t){if(typeof t!="string")throw new Error("hex string expected, got "+typeof t);let e=t.length,n=e/2;if(e%2)throw new Error("hex string expected, got unpadded hex of length "+e);let o=new Uint8Array(n);for(let s=0,r=0;stypeof t=="bigint"&&Wt<=t;function Vn(t,e,n){return Xt(t)&&Xt(e)&&Xt(n)&&e<=t&&tWt;t>>=Le,e+=1);return e}var Re=t=>(Mn<typeof t=="bigint",function:t=>typeof t=="function",boolean:t=>typeof t=="boolean",string:t=>typeof t=="string",stringOrUint8Array:t=>typeof t=="string"||Kt(t),isSafeInteger:t=>Number.isSafeInteger(t),array:t=>Array.isArray(t),field:(t,e)=>e.Fp.isValid(t),hash:t=>typeof t=="function"&&Number.isSafeInteger(t.outputLen)};function ot(t,e,n={}){let o=(s,r,i)=>{let a=Zn[r];if(typeof a!="function")throw new Error("invalid validator function");let c=t[s];if(!(i&&c===void 0)&&!a(c,t))throw new Error("param "+String(s)+" is invalid. Expected "+r+", got "+c)};for(let[s,r]of Object.entries(e))o(s,r,!1);for(let[s,r]of Object.entries(n))o(s,r,!0);return t}function Jt(t){let e=new WeakMap;return(n,...o)=>{let s=e.get(n);if(s!==void 0)return s;let r=t(n,...o);return e.set(n,r),r}}var Z=BigInt(0),q=BigInt(1),mt=BigInt(2),kn=BigInt(3),$t=BigInt(4),Ne=BigInt(5),He=BigInt(8),zn=BigInt(9),Gn=BigInt(16);function O(t,e){let n=t%e;return n>=Z?n:e+n}function te(t,e,n){if(eZ;)e&q&&(o=o*t%n),t=t*t%n,e>>=q;return o}function W(t,e,n){let o=t;for(;e-- >Z;)o*=o,o%=n;return o}function qe(t,e){if(t===Z)throw new Error("invert: expected non-zero number");if(e<=Z)throw new Error("invert: expected positive modulus, got "+e);let n=O(t,e),o=e,s=Z,r=q,i=q,a=Z;for(;n!==Z;){let u=o/n,f=o%n,h=s-i*u,g=r-a*u;o=n,n=f,s=i,r=a,i=h,a=g}if(o!==q)throw new Error("invert: does not exist");return O(s,e)}function jn(t){let e=(t-q)/mt,n,o,s;for(n=t-q,o=0;n%mt===Z;n/=mt,o++);for(s=mt;s1e3)throw new Error("Cannot find square root: likely non-prime P");if(o===1){let i=(t+q)/$t;return function(c,u){let f=c.pow(u,i);if(!c.eql(c.sqr(f),u))throw new Error("Cannot find square root");return f}}let r=(n+q)/mt;return function(a,c){if(a.pow(c,e)===a.neg(a.ONE))throw new Error("Cannot find square root");let u=o,f=a.pow(a.mul(a.ONE,s),n),h=a.pow(c,r),g=a.pow(c,n);for(;!a.eql(g,a.ONE);){if(a.eql(g,a.ZERO))return a.ZERO;let x=1;for(let b=a.sqr(g);x(O(t,e)&q)===q,Yn=["create","isValid","is0","neg","inv","sqrt","sqr","eql","add","sub","mul","pow","div","addN","subN","mulN","sqrN"];function Me(t){let e={ORDER:"bigint",MASK:"bigint",BYTES:"isSafeInteger",BITS:"isSafeInteger"},n=Yn.reduce((o,s)=>(o[s]="function",o),e);return ot(t,n)}function Xn(t,e,n){if(nZ;)n&q&&(o=t.mul(o,s)),s=t.sqr(s),n>>=q;return o}function Wn(t,e){let n=new Array(e.length),o=e.reduce((r,i,a)=>t.is0(i)?r:(n[a]=r,t.mul(r,i)),t.ONE),s=t.inv(o);return e.reduceRight((r,i,a)=>t.is0(i)?r:(n[a]=t.mul(r,n[a]),t.mul(r,i)),s),n}function ee(t,e){let n=e!==void 0?e:t.toString(2).length,o=Math.ceil(n/8);return{nBitLength:n,nByteLength:o}}function _t(t,e,n=!1,o={}){if(t<=Z)throw new Error("invalid field: expected ORDER > 0, got "+t);let{nBitLength:s,nByteLength:r}=ee(t,e);if(r>2048)throw new Error("invalid field: expected ORDER of <= 2048 bytes");let i,a=Object.freeze({ORDER:t,isLE:n,BITS:s,BYTES:r,MASK:Re(s),ZERO:Z,ONE:q,create:c=>O(c,t),isValid:c=>{if(typeof c!="bigint")throw new Error("invalid field element: expected bigint, got "+typeof c);return Z<=c&&cc===Z,isOdd:c=>(c&q)===q,neg:c=>O(-c,t),eql:(c,u)=>c===u,sqr:c=>O(c*c,t),add:(c,u)=>O(c+u,t),sub:(c,u)=>O(c-u,t),mul:(c,u)=>O(c*u,t),pow:(c,u)=>Xn(a,c,u),div:(c,u)=>O(c*qe(u,t),t),sqrN:c=>c*c,addN:(c,u)=>c+u,subN:(c,u)=>c-u,mulN:(c,u)=>c*u,inv:c=>qe(c,t),sqrt:o.sqrt||(c=>(i||(i=Pn(t)),i(a,c))),invertBatch:c=>Wn(a,c),cmov:(c,u,f)=>f?u:c,toBytes:c=>n?et(c,r):Qt(c,r),fromBytes:c=>{if(c.length!==r)throw new Error("Field.fromBytes: expected "+r+" bytes, got "+c.length);return n?K(c):Ht(c)}});return Object.freeze(a)}function De(t,e){if(!t.isOdd)throw new Error("Field doesn't have isOdd");let n=t.sqrt(e);return t.isOdd(n)?t.neg(n):n}var Ve=BigInt(0),Mt=BigInt(1);function ne(t,e){let n=e.negate();return t?n:e}function Ze(t,e){if(!Number.isSafeInteger(t)||t<=0||t>e)throw new Error("invalid window size, expected [1.."+e+"], got W="+t)}function oe(t,e){Ze(t,e);let n=Math.ceil(e/t)+1,o=2**(t-1);return{windows:n,windowSize:o}}function Kn(t,e){if(!Array.isArray(t))throw new Error("array expected");t.forEach((n,o)=>{if(!(n instanceof e))throw new Error("invalid point at index "+o)})}function Qn(t,e){if(!Array.isArray(t))throw new Error("array of scalars expected");t.forEach((n,o)=>{if(!e.isValid(n))throw new Error("invalid scalar at index "+o)})}var re=new WeakMap,Fe=new WeakMap;function se(t){return Fe.get(t)||1}function ke(t,e){return{constTimeNegate:ne,hasPrecomputes(n){return se(n)!==1},unsafeLadder(n,o,s=t.ZERO){let r=n;for(;o>Ve;)o&Mt&&(s=s.add(r)),r=r.double(),o>>=Mt;return s},precomputeWindow(n,o){let{windows:s,windowSize:r}=oe(o,e),i=[],a=n,c=a;for(let u=0;u>=h,w>i&&(w-=f,s+=Mt);let b=x,S=x+Math.abs(w)-1,N=g%2!==0,B=w<0;w===0?c=c.add(ne(N,o[b])):a=a.add(ne(B,o[S]))}return{p:a,f:c}},wNAFUnsafe(n,o,s,r=t.ZERO){let{windows:i,windowSize:a}=oe(n,e),c=BigInt(2**n-1),u=2**n,f=BigInt(n);for(let h=0;h>=f,x>a&&(x-=u,s+=Mt),x===0)continue;let w=o[g+Math.abs(x)-1];x<0&&(w=w.negate()),r=r.add(w)}return r},getPrecomputes(n,o,s){let r=re.get(o);return r||(r=this.precomputeWindow(o,n),n!==1&&re.set(o,s(r))),r},wNAFCached(n,o,s){let r=se(n);return this.wNAF(r,this.getPrecomputes(r,n,s),o)},wNAFCachedUnsafe(n,o,s,r){let i=se(n);return i===1?this.unsafeLadder(n,o,r):this.wNAFUnsafe(i,this.getPrecomputes(i,n,s),o,r)},setWindowSize(n,o){Ze(o,e),Fe.set(n,o),re.delete(n)}}}function Dt(t,e,n,o){if(Kn(n,t),Qn(o,e),n.length!==o.length)throw new Error("arrays of points and scalars must have equal length");let s=t.ZERO,r=Ce(BigInt(n.length)),i=r>12?r-3:r>4?r-2:r?2:1,a=(1<=0;h-=i){c.fill(s);for(let x=0;x>BigInt(h)&BigInt(a));c[b]=c[b].add(n[x])}let g=s;for(let x=c.length-1,w=s;x>0;x--)w=w.add(c[x]),g=g.add(w);if(f=f.add(g),h!==0)for(let x=0;x{try{return{isValid:!0,value:n.sqrt(E*n.inv(l))}}catch{return{isValid:!1,value:Q}}}),x=e.adjustScalarBytes||(E=>E),w=e.domain||((E,l,m)=>{if(Nt("phflag",m),l.length||m)throw new Error("Contexts/pre-hash are not supported");return E});function b(E,l){ft("coordinate "+E,l,Q,u)}function S(E){if(!(E instanceof d))throw new Error("ExtendedPoint expected")}let N=Jt((E,l)=>{let{ex:m,ey:y,ez:A}=E,v=E.is0();l==null&&(l=v?Jn:n.inv(A));let L=f(m*l),C=f(y*l),T=f(A*l);if(v)return{x:Q,y:P};if(T!==P)throw new Error("invZ was invalid");return{x:L,y:C}}),B=Jt(E=>{let{a:l,d:m}=e;if(E.is0())throw new Error("bad point: ZERO");let{ex:y,ey:A,ez:v,et:L}=E,C=f(y*y),T=f(A*A),R=f(v*v),z=f(R*R),j=f(C*l),$=f(R*f(j+T)),tt=f(z+f(m*f(C*T)));if($!==tt)throw new Error("bad point: equation left != right (1)");let X=f(y*A),lt=f(v*L);if(X!==lt)throw new Error("bad point: equation left != right (2)");return!0});class d{constructor(l,m,y,A){this.ex=l,this.ey=m,this.ez=y,this.et=A,b("x",l),b("y",m),b("z",y),b("t",A),Object.freeze(this)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}static fromAffine(l){if(l instanceof d)throw new Error("extended point not allowed");let{x:m,y}=l||{};return b("x",m),b("y",y),new d(m,y,P,f(m*y))}static normalizeZ(l){let m=n.invertBatch(l.map(y=>y.ez));return l.map((y,A)=>y.toAffine(m[A])).map(d.fromAffine)}static msm(l,m){return Dt(d,h,l,m)}_setWindowSize(l){U.setWindowSize(this,l)}assertValidity(){B(this)}equals(l){S(l);let{ex:m,ey:y,ez:A}=this,{ex:v,ey:L,ez:C}=l,T=f(m*C),R=f(v*A),z=f(y*C),j=f(L*A);return T===R&&z===j}is0(){return this.equals(d.ZERO)}negate(){return new d(f(-this.ex),this.ey,this.ez,f(-this.et))}double(){let{a:l}=e,{ex:m,ey:y,ez:A}=this,v=f(m*m),L=f(y*y),C=f(Vt*f(A*A)),T=f(l*v),R=m+y,z=f(f(R*R)-v-L),j=T+L,$=j-C,tt=T-L,X=f(z*$),lt=f(j*tt),gt=f(z*tt),vt=f($*j);return new d(X,lt,vt,gt)}add(l){S(l);let{a:m,d:y}=e,{ex:A,ey:v,ez:L,et:C}=this,{ex:T,ey:R,ez:z,et:j}=l;if(m===BigInt(-1)){let ge=f((v-A)*(R+T)),be=f((v+A)*(R-T)),kt=f(be-ge);if(kt===Q)return this.double();let me=f(L*Vt*j),we=f(C*Vt*z),ye=we+me,Be=be+ge,Ee=we-me,cn=f(ye*kt),fn=f(Be*Ee),an=f(ye*Ee),un=f(kt*Be);return new d(cn,fn,un,an)}let $=f(A*T),tt=f(v*R),X=f(C*y*j),lt=f(L*z),gt=f((A+v)*(T+R)-$-tt),vt=lt-X,pe=lt+X,xe=f(tt-m*$),nn=f(gt*vt),on=f(pe*xe),rn=f(gt*xe),sn=f(vt*pe);return new d(nn,on,sn,rn)}subtract(l){return this.add(l.negate())}wNAF(l){return U.wNAFCached(this,l,d.normalizeZ)}multiply(l){let m=l;ft("scalar",m,P,o);let{p:y,f:A}=this.wNAF(m);return d.normalizeZ([y,A])[0]}multiplyUnsafe(l,m=d.ZERO){let y=l;return ft("scalar",y,Q,o),y===Q?M:this.is0()||y===P?this:U.wNAFCachedUnsafe(this,y,d.normalizeZ,m)}isSmallOrder(){return this.multiplyUnsafe(c).is0()}isTorsionFree(){return U.unsafeLadder(this,o).is0()}toAffine(l){return N(this,l)}clearCofactor(){let{h:l}=e;return l===P?this:this.multiplyUnsafe(l)}static fromHex(l,m=!1){let{d:y,a:A}=e,v=n.BYTES;l=G("pointHex",l,v),Nt("zip215",m);let L=l.slice(),C=l[v-1];L[v-1]=C&-129;let T=K(L),R=m?u:n.ORDER;ft("pointHex.y",T,Q,R);let z=f(T*T),j=f(z-P),$=f(y*z-A),{isValid:tt,value:X}=g(j,$);if(!tt)throw new Error("Point.fromHex: invalid y coordinate");let lt=(X&P)===P,gt=(C&128)!==0;if(!m&&X===Q&>)throw new Error("Point.fromHex: x=0 and x_0=1");return gt!==lt&&(X=f(-X)),d.fromAffine({x:X,y:T})}static fromPrivateKey(l){return H(l).point}toRawBytes(){let{x:l,y:m}=this.toAffine(),y=et(m,n.BYTES);return y[y.length-1]|=l&P?128:0,y}toHex(){return yt(this.toRawBytes())}}d.BASE=new d(e.Gx,e.Gy,P,f(e.Gx*e.Gy)),d.ZERO=new d(Q,P,P,Q);let{BASE:I,ZERO:M}=d,U=ke(d,a*8);function k(E){return O(E,o)}function D(E){return k(K(E))}function H(E){let l=n.BYTES;E=G("private key",E,l);let m=G("hashed private key",r(E),2*l),y=x(m.slice(0,l)),A=m.slice(l,2*l),v=D(y),L=I.multiply(v),C=L.toRawBytes();return{head:y,prefix:A,scalar:v,point:L,pointBytes:C}}function Y(E){return H(E).pointBytes}function V(E=new Uint8Array,...l){let m=nt(...l);return D(r(w(m,G("context",E),!!s)))}function ut(E,l,m={}){E=G("message",E),s&&(E=s(E));let{prefix:y,scalar:A,pointBytes:v}=H(l),L=V(m.context,y,E),C=I.multiply(L).toRawBytes(),T=V(m.context,C,v,E),R=k(L+T*A);ft("signature.s",R,Q,o);let z=nt(C,et(R,n.BYTES));return G("result",z,n.BYTES*2)}let st=$n;function wt(E,l,m,y=st){let{context:A,zip215:v}=y,L=n.BYTES;E=G("signature",E,2*L),l=G("message",l),m=G("publicKey",m,L),v!==void 0&&Nt("zip215",v),s&&(l=s(l));let C=K(E.slice(L,2*L)),T,R,z;try{T=d.fromHex(m,v),R=d.fromHex(E.slice(0,L),v),z=I.multiplyUnsafe(C)}catch{return!1}if(!v&&T.isSmallOrder())return!1;let j=V(A,R.toRawBytes(),T.toRawBytes(),l);return R.add(T.multiplyUnsafe(j)).subtract(z).clearCofactor().equals(d.ZERO)}return I._setWindowSize(8),{CURVE:e,getPublicKey:Y,sign:ut,verify:wt,ExtendedPoint:d,utils:{getExtendedPublicKey:H,randomPrivateKey:()=>i(n.BYTES),precompute(E=8,l=d.BASE){return l._setWindowSize(E),l.multiply(BigInt(3)),l}}}}var eo=Ht;function pt(t,e){if(At(t),At(e),t<0||t>=1<<8*e)throw new Error("invalid I2OSP input: "+t);let n=Array.from({length:e}).fill(0);for(let o=e-1;o>=0;o--)n[o]=t&255,t>>>=8;return new Uint8Array(n)}function no(t,e){let n=new Uint8Array(t.length);for(let o=0;o255&&(e=o(nt(qt("H2C-OVERSIZE-DST-"),e)));let{outputLen:s,blockLen:r}=o,i=Math.ceil(n/s);if(n>65535||i>255)throw new Error("expand_message_xmd: invalid lenInBytes");let a=nt(e,pt(e.length,1)),c=pt(0,r),u=pt(n,2),f=new Array(i),h=o(nt(c,t,u,pt(0,1),a));f[0]=o(nt(h,pt(1,1),a));for(let x=1;x<=i;x++){let w=[no(h,f[x-1]),pt(x+1,1),a];f[x]=o(nt(...w))}return nt(...f).slice(0,n)}function oo(t,e,n,o,s){if(ct(t),ct(e),At(n),e.length>255){let r=Math.ceil(2*o/8);e=s.create({dkLen:r}).update(qt("H2C-OVERSIZE-DST-")).update(e).digest()}if(n>65535||e.length>255)throw new Error("expand_message_xof: invalid lenInBytes");return s.create({dkLen:n}).update(t).update(pt(n,2)).update(e).update(pt(e.length,1)).digest()}function Ge(t,e,n){ot(n,{DST:"stringOrUint8Array",p:"bigint",m:"isSafeInteger",k:"isSafeInteger",hash:"hash"});let{p:o,k:s,m:r,hash:i,expand:a,DST:c}=n;ct(t),At(e);let u=typeof c=="string"?qt(c):c,f=o.toString(2).length,h=Math.ceil((f+s)/8),g=e*r*h,x;if(a==="xmd")x=ie(t,u,g,i);else if(a==="xof")x=oo(t,u,g,s,i);else if(a==="_internal_pass")x=t;else throw new Error('expand must be "xmd" or "xof"');let w=new Array(e);for(let b=0;bO(B,n),s=e.montgomeryBits,r=Math.ceil(s/8),i=e.nByteLength,a=e.adjustScalarBytes||(B=>B),c=e.powPminus2||(B=>te(B,n-BigInt(2),n));function u(B,d,I){let M=o(B*(d-I));return d=o(d-M),I=o(I+M),[d,I]}let f=(e.a-BigInt(2))/BigInt(4);function h(B,d){ft("u",B,Bt,n),ft("scalar",d,Bt,n);let I=d,M=B,U=ce,k=Bt,D=B,H=ce,Y=Bt,V;for(let st=BigInt(s-1);st>=Bt;st--){let wt=I>>st&ce;Y^=wt,V=u(Y,U,D),U=V[0],D=V[1],V=u(Y,k,H),k=V[0],H=V[1],Y=wt;let xt=U+k,E=o(xt*xt),l=U-k,m=o(l*l),y=E-m,A=D+H,v=D-H,L=o(v*xt),C=o(A*l),T=L+C,R=L-C;D=o(T*T),H=o(M*o(R*R)),U=o(E*m),k=o(y*(E+o(f*y)))}V=u(Y,U,D),U=V[0],D=V[1],V=u(Y,k,H),k=V[0],H=V[1];let ut=c(k);return o(U*ut)}function g(B){return et(o(B),r)}function x(B){let d=G("u coordinate",B,r);return i===32&&(d[31]&=127),K(d)}function w(B){let d=G("scalar",B),I=d.length;if(I!==r&&I!==i){let M=""+r+" or "+i;throw new Error("invalid scalar, expected "+M+" bytes, got "+I)}return K(a(d))}function b(B,d){let I=x(d),M=w(B),U=h(I,M);if(U===Bt)throw new Error("invalid private or public key received");return g(U)}let S=g(e.Gu);function N(B){return b(B,S)}return{scalarMult:b,scalarMultBase:N,getSharedSecret:(B,d)=>b(B,d),getPublicKey:B=>N(B),utils:{randomPrivateKey:()=>e.randomBytes(e.nByteLength)},GuBytes:S}}var St=BigInt("57896044618658097711785492504343953926634992332820282019728792003956564819949"),ue=BigInt("19681161376707505956807079304988542015446066515923890162744021073123829784752"),so=BigInt(0),rt=BigInt(1),Ft=BigInt(2),Ke=BigInt(3),Qe=BigInt(5),de=BigInt(8);function Je(t){let e=BigInt(10),n=BigInt(20),o=BigInt(40),s=BigInt(80),r=St,a=t*t%r*t%r,c=W(a,Ft,r)*a%r,u=W(c,rt,r)*t%r,f=W(u,Qe,r)*u%r,h=W(f,e,r)*f%r,g=W(h,n,r)*h%r,x=W(g,o,r)*g%r,w=W(x,s,r)*x%r,b=W(w,s,r)*x%r,S=W(b,e,r)*f%r;return{pow_p_5_8:W(S,Ft,r)*t%r,b2:a}}function $e(t){return t[0]&=248,t[31]&=127,t[31]|=64,t}function he(t,e){let n=St,o=O(e*e*e,n),s=O(o*o*e,n),r=Je(t*s).pow_p_5_8,i=O(t*o*r,n),a=O(e*i*i,n),c=i,u=O(i*ue,n),f=a===t,h=a===O(-t,n),g=a===O(-t*ue,n);return f&&(i=c),(h||g)&&(i=u),at(i,n)&&(i=O(-i,n)),{isValid:f||h,value:i}}var er=["0100000000000000000000000000000000000000000000000000000000000000","c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a","0000000000000000000000000000000000000000000000000000000000000080","26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05","ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f","26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85","0000000000000000000000000000000000000000000000000000000000000000","c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa"],p=_t(St,void 0,!0),It={a:BigInt(-1),d:BigInt("37095705934669439343138083508754565189542113879843219016388785533085940283555"),Fp:p,n:BigInt("7237005577332262213973186563042994240857116359379907606001950938285454250989"),h:de,Gx:BigInt("15112221349535400772501151409588531511454012693041857206046113283949847762202"),Gy:BigInt("46316835694926478169428394003475163141307993866256225615783033603165251855960"),hash:Et,randomBytes:jt,adjustScalarBytes:$e,uvRatio:he},F=Zt(It);function tn(t,e,n){if(e.length>255)throw new Error("Context is too big");return Ae(Ot("SigEd25519 no Ed25519 collisions"),new Uint8Array([n?1:0,e.length]),e,t)}var nr=Zt({...It,domain:tn}),or=Zt(Object.assign({},It,{domain:tn,prehash:Et})),rr=Pe({P:St,a:BigInt(486662),montgomeryBits:255,nByteLength:32,Gu:BigInt(9),powPminus2:t=>{let e=St,{pow_p_5_8:n,b2:o}=Je(t);return O(W(n,Ke,e)*o,e)},adjustScalarBytes:$e,randomBytes:jt});function io(t){let{y:e}=F.ExtendedPoint.fromHex(t),n=BigInt(1);return p.toBytes(p.create((n+e)*p.inv(n-e)))}var sr=io;function ir(t){let e=It.hash(t.subarray(0,32));return It.adjustScalarBytes(e).subarray(0,32)}var co=(p.ORDER+Ke)/de,fo=p.pow(Ft,co),Ye=p.sqrt(p.neg(p.ONE));function ao(t){let e=(p.ORDER-Qe)/de,n=BigInt(486662),o=p.sqr(t);o=p.mul(o,Ft);let s=p.add(o,p.ONE),r=p.neg(n),i=p.sqr(s),a=p.mul(i,s),c=p.mul(o,n);c=p.mul(c,r),c=p.add(c,i),c=p.mul(c,r);let u=p.sqr(a);i=p.sqr(u),u=p.mul(u,a),u=p.mul(u,c),i=p.mul(i,u);let f=p.pow(i,e);f=p.mul(f,u);let h=p.mul(f,Ye);i=p.sqr(f),i=p.mul(i,a);let g=p.eql(i,c),x=p.cmov(h,f,g),w=p.mul(r,o),b=p.mul(f,t);b=p.mul(b,fo);let S=p.mul(b,Ye),N=p.mul(c,o);i=p.sqr(b),i=p.mul(i,a);let B=p.eql(i,N),d=p.cmov(S,b,B);i=p.sqr(x),i=p.mul(i,a);let I=p.eql(i,c),M=p.cmov(w,r,I),U=p.cmov(d,x,I),k=p.isOdd(U);return U=p.cmov(U,p.neg(U),I!==k),{xMn:M,xMd:s,yMn:U,yMd:rt}}var uo=De(p,p.neg(BigInt(486664)));function lo(t){let{xMn:e,xMd:n,yMn:o,yMd:s}=ao(t),r=p.mul(e,s);r=p.mul(r,uo);let i=p.mul(n,o),a=p.sub(e,n),c=p.add(e,n),u=p.mul(i,c),f=p.eql(u,p.ZERO);r=p.cmov(r,p.ZERO,f),i=p.cmov(i,p.ONE,f),a=p.cmov(a,p.ONE,f),c=p.cmov(c,p.ONE,f);let h=p.invertBatch([i,c]);return{x:p.mul(r,h[0]),y:p.mul(a,h[1])}}var en=je(F.ExtendedPoint,t=>lo(t[0]),{DST:"edwards25519_XMD:SHA-512_ELL2_RO_",encodeDST:"edwards25519_XMD:SHA-512_ELL2_NU_",p:p.ORDER,m:1,k:128,expand:"xmd",hash:Et}),cr=en.hashToCurve,fr=en.encodeToCurve;function fe(t){if(!(t instanceof J))throw new Error("RistrettoPoint expected")}var le=ue,ho=BigInt("25063068953384623474111414158702152701244531502492656460079210482610430750235"),po=BigInt("54469307008909316920995813868745141605393597292927456921205312896311721017578"),xo=BigInt("1159843021668779879193775521855586647937357759715417654439879720876111806838"),go=BigInt("40440834346308536858101042469323190826248399146238708352240133220865137265952"),Xe=t=>he(rt,t),bo=BigInt("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),ae=t=>F.CURVE.Fp.create(K(t)&bo);function We(t){let{d:e}=F.CURVE,n=F.CURVE.Fp.ORDER,o=F.CURVE.Fp.create,s=o(le*t*t),r=o((s+rt)*xo),i=BigInt(-1),a=o((i-e*s)*o(s+e)),{isValid:c,value:u}=he(r,a),f=o(u*t);at(f,n)||(f=o(-f)),c||(u=f),c||(i=s);let h=o(i*(s-rt)*go-a),g=u*u,x=o((u+u)*a),w=o(h*ho),b=o(rt-g),S=o(rt+g);return new F.ExtendedPoint(o(x*S),o(b*w),o(w*S),o(x*b))}var J=class t{constructor(e){this.ep=e}static fromAffine(e){return new t(F.ExtendedPoint.fromAffine(e))}static hashToCurve(e){e=G("ristrettoHash",e,64);let n=ae(e.slice(0,32)),o=We(n),s=ae(e.slice(32,64)),r=We(s);return new t(o.add(r))}static fromHex(e){e=G("ristrettoHex",e,32);let{a:n,d:o}=F.CURVE,s=F.CURVE.Fp.ORDER,r=F.CURVE.Fp.create,i="RistrettoPoint.fromHex: the hex is not valid encoding of RistrettoPoint",a=ae(e);if(!Oe(et(a,32),e)||at(a,s))throw new Error(i);let c=r(a*a),u=r(rt+n*c),f=r(rt-n*c),h=r(u*u),g=r(f*f),x=r(n*o*h-g),{isValid:w,value:b}=Xe(r(x*g)),S=r(b*f),N=r(b*S*x),B=r((a+a)*S);at(B,s)&&(B=r(-B));let d=r(u*N),I=r(B*d);if(!w||at(I,s)||d===so)throw new Error(i);return new t(new F.ExtendedPoint(B,d,rt,I))}static msm(e,n){let o=_t(F.CURVE.n,F.CURVE.nBitLength);return Dt(t,o,e,n)}toRawBytes(){let{ex:e,ey:n,ez:o,et:s}=this.ep,r=F.CURVE.Fp.ORDER,i=F.CURVE.Fp.create,a=i(i(o+n)*i(o-n)),c=i(e*n),u=i(c*c),{value:f}=Xe(i(a*u)),h=i(f*a),g=i(f*c),x=i(h*g*s),w;if(at(s*x,r)){let S=i(n*le),N=i(e*le);e=S,n=N,w=i(h*po)}else w=g;at(e*x,r)&&(n=i(-n));let b=i((o-n)*w);return at(b,r)&&(b=i(-b)),et(b,32)}toHex(){return yt(this.toRawBytes())}toString(){return this.toHex()}equals(e){fe(e);let{ex:n,ey:o}=this.ep,{ex:s,ey:r}=e.ep,i=F.CURVE.Fp.create,a=i(n*r)===i(o*s),c=i(o*r)===i(n*s);return a||c}add(e){return fe(e),new t(this.ep.add(e.ep))}subtract(e){return fe(e),new t(this.ep.subtract(e.ep))}multiply(e){return new t(this.ep.multiply(e))}multiplyUnsafe(e){return new t(this.ep.multiplyUnsafe(e))}double(){return new t(this.ep.double())}negate(){return new t(this.ep.negate())}},ar=(J.BASE||(J.BASE=new J(F.ExtendedPoint.BASE)),J.ZERO||(J.ZERO=new J(F.ExtendedPoint.ZERO)),J),mo=(t,e)=>{let n=e.DST,o=typeof n=="string"?Ot(n):n,s=ie(t,o,64,Et);return J.hashToCurve(s)},ur=mo;export{er as ED25519_TORSION_SUBGROUP,ar as RistrettoPoint,F as ed25519,nr as ed25519ctx,or as ed25519ph,sr as edwardsToMontgomery,ir as edwardsToMontgomeryPriv,io as edwardsToMontgomeryPub,fr as encodeToCurve,cr as hashToCurve,mo as hashToRistretto255,ur as hash_to_ristretto255,rr as x25519}; +/*! Bundled license information: + +@noble/hashes/esm/utils.js: + (*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *) + +@noble/curves/esm/abstract/utils.js: +@noble/curves/esm/abstract/modular.js: +@noble/curves/esm/abstract/curve.js: +@noble/curves/esm/abstract/edwards.js: +@noble/curves/esm/abstract/montgomery.js: +@noble/curves/esm/ed25519.js: + (*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) *) +*/ diff --git a/shine-UI/js/vendor/noble/hashes-argon2-1.8.0.bundle.mjs b/shine-UI/js/vendor/noble/hashes-argon2-1.8.0.bundle.mjs new file mode 100644 index 00000000..aec42929 --- /dev/null +++ b/shine-UI/js/vendor/noble/hashes-argon2-1.8.0.bundle.mjs @@ -0,0 +1,7 @@ +/* esm.sh - @noble/hashes@1.8.0/argon2 */ +var Q=BigInt(4294967295),pt=BigInt(32);function xt(t,e=!1){return e?{h:Number(t&Q),l:Number(t>>pt&Q)}:{h:Number(t>>pt&Q)|0,l:Number(t&Q)|0}}var X=(t,e,n)=>t>>>n|e<<32-n,P=(t,e,n)=>t<<32-n|e>>>n,$=(t,e,n)=>t<<64-n|e>>>n-32,K=(t,e,n)=>t>>>n-32|e<<64-n,tt=(t,e)=>e,et=(t,e)=>t;function st(t,e,n,s){let h=(e>>>0)+(s>>>0);return{h:t+n+(h/2**32|0)|0,l:h|0}}var Z=(t,e,n)=>(t>>>0)+(e>>>0)+(n>>>0),q=(t,e,n,s)=>e+n+s+(t/2**32|0)|0;function St(t){return t instanceof Uint8Array||ArrayBuffer.isView(t)&&t.constructor.name==="Uint8Array"}function rt(t){if(!Number.isSafeInteger(t)||t<0)throw new Error("positive integer expected, got "+t)}function O(t,...e){if(!St(t))throw new Error("Uint8Array expected");if(e.length>0&&!e.includes(t.length))throw new Error("Uint8Array expected of length "+e+", got length="+t.length)}function ht(t,e=!0){if(t.destroyed)throw new Error("Hash instance has been destroyed");if(e&&t.finished)throw new Error("Hash#digest() has already been called")}function bt(t,e){O(t);let n=e.outputLen;if(t.length>>8&65280|t>>>24&255}var D=wt?t=>t:t=>yt(t);function Ot(t){for(let e=0;et:Ot;var gt=async()=>{};function Bt(t){if(typeof t!="string")throw new Error("string expected");return new Uint8Array(new TextEncoder().encode(t))}function R(t){return typeof t=="string"&&(t=Bt(t)),O(t),t}function ot(t){return typeof t=="string"&&(t=Bt(t)),O(t),t}var nt=class{};function At(t){let e=(s,h)=>t(h).update(R(s)).digest(),n=t({});return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=s=>t(s),e}var mt=Uint8Array.from([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3,11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4,7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8,9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13,2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9,12,5,1,15,14,13,4,10,0,7,6,3,9,2,8,11,13,11,7,14,12,1,3,9,5,0,15,4,8,6,2,10,6,15,14,9,11,3,0,8,12,2,13,7,1,4,10,5,10,2,8,4,7,6,1,5,15,11,9,14,3,12,13,0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3,11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4,7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8,9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13,2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9]);var B=Uint32Array.from([4089235720,1779033703,2227873595,3144134277,4271175723,1013904242,1595750129,2773480762,2917565137,1359893119,725511199,2600822924,4215389547,528734635,327033209,1541459225]),l=new Uint32Array(32);function _(t,e,n,s,h,r){let i=h[r],o=h[r+1],c=l[2*t],f=l[2*t+1],a=l[2*e],u=l[2*e+1],x=l[2*n],b=l[2*n+1],p=l[2*s],d=l[2*s+1],g=Z(c,a,i);f=q(g,f,u,o),c=g|0,{Dh:d,Dl:p}={Dh:d^f,Dl:p^c},{Dh:d,Dl:p}={Dh:tt(d,p),Dl:et(d,p)},{h:b,l:x}=st(b,x,d,p),{Bh:u,Bl:a}={Bh:u^b,Bl:a^x},{Bh:u,Bl:a}={Bh:X(u,a,24),Bl:P(u,a,24)},l[2*t]=c,l[2*t+1]=f,l[2*e]=a,l[2*e+1]=u,l[2*n]=x,l[2*n+1]=b,l[2*s]=p,l[2*s+1]=d}function T(t,e,n,s,h,r){let i=h[r],o=h[r+1],c=l[2*t],f=l[2*t+1],a=l[2*e],u=l[2*e+1],x=l[2*n],b=l[2*n+1],p=l[2*s],d=l[2*s+1],g=Z(c,a,i);f=q(g,f,u,o),c=g|0,{Dh:d,Dl:p}={Dh:d^f,Dl:p^c},{Dh:d,Dl:p}={Dh:X(d,p,16),Dl:P(d,p,16)},{h:b,l:x}=st(b,x,d,p),{Bh:u,Bl:a}={Bh:u^b,Bl:a^x},{Bh:u,Bl:a}={Bh:$(u,a,63),Bl:K(u,a,63)},l[2*t]=c,l[2*t+1]=f,l[2*e]=a,l[2*e+1]=u,l[2*n]=x,l[2*n+1]=b,l[2*s]=p,l[2*s+1]=d}function _t(t,e={},n,s,h){if(rt(n),t<0||t>n)throw new Error("outputLen bigger than keyLen");let{key:r,salt:i,personalization:o}=e;if(r!==void 0&&(r.length<1||r.length>n))throw new Error("key length must be undefined or 1.."+n);if(i!==void 0&&i.length!==s)throw new Error("salt must be undefined or "+s);if(o!==void 0&&o.length!==h)throw new Error("personalization must be undefined or "+h)}var lt=class extends nt{constructor(e,n){super(),this.finished=!1,this.destroyed=!1,this.length=0,this.pos=0,rt(e),rt(n),this.blockLen=e,this.outputLen=n,this.buffer=new Uint8Array(e),this.buffer32=G(this.buffer)}update(e){ht(this),e=R(e),O(e);let{blockLen:n,buffer:s,buffer32:h}=this,r=e.length,i=e.byteOffset,o=e.buffer;for(let c=0;ch[i]=D(r))}digest(){let{buffer:e,outputLen:n}=this;this.digestInto(e);let s=e.slice(0,n);return this.destroy(),s}_cloneInto(e){let{buffer:n,length:s,finished:h,destroyed:r,outputLen:i,pos:o}=this;return e||(e=new this.constructor({dkLen:i})),e.set(...this.get()),e.buffer.set(n),e.destroyed=r,e.finished=h,e.length=s,e.pos=o,e.outputLen=i,e}clone(){return this._cloneInto()}},ct=class extends lt{constructor(e={}){let n=e.dkLen===void 0?64:e.dkLen;super(128,n),this.v0l=B[0]|0,this.v0h=B[1]|0,this.v1l=B[2]|0,this.v1h=B[3]|0,this.v2l=B[4]|0,this.v2h=B[5]|0,this.v3l=B[6]|0,this.v3h=B[7]|0,this.v4l=B[8]|0,this.v4h=B[9]|0,this.v5l=B[10]|0,this.v5h=B[11]|0,this.v6l=B[12]|0,this.v6h=B[13]|0,this.v7l=B[14]|0,this.v7h=B[15]|0,_t(n,e,64,16,16);let{key:s,personalization:h,salt:r}=e,i=0;if(s!==void 0&&(s=R(s),i=s.length),this.v0l^=this.outputLen|i<<8|65536|1<<24,r!==void 0){r=R(r);let o=G(r);this.v4l^=D(o[0]),this.v4h^=D(o[1]),this.v5l^=D(o[2]),this.v5h^=D(o[3])}if(h!==void 0){h=R(h);let o=G(h);this.v6l^=D(o[0]),this.v6h^=D(o[1]),this.v7l^=D(o[2]),this.v7h^=D(o[3])}if(s!==void 0){let o=new Uint8Array(this.blockLen);o.set(s),this.update(o)}}get(){let{v0l:e,v0h:n,v1l:s,v1h:h,v2l:r,v2h:i,v3l:o,v3h:c,v4l:f,v4h:a,v5l:u,v5h:x,v6l:b,v6h:p,v7l:d,v7h:g}=this;return[e,n,s,h,r,i,o,c,f,a,u,x,b,p,d,g]}set(e,n,s,h,r,i,o,c,f,a,u,x,b,p,d,g){this.v0l=e|0,this.v0h=n|0,this.v1l=s|0,this.v1h=h|0,this.v2l=r|0,this.v2h=i|0,this.v3l=o|0,this.v3h=c|0,this.v4l=f|0,this.v4h=a|0,this.v5l=u|0,this.v5h=x|0,this.v6l=b|0,this.v6h=p|0,this.v7l=d|0,this.v7h=g|0}compress(e,n,s){this.get().forEach((c,f)=>l[f]=c),l.set(B,16);let{h,l:r}=xt(BigInt(this.length));l[24]=B[8]^r,l[25]=B[9]^h,s&&(l[28]=~l[28],l[29]=~l[29]);let i=0,o=mt;for(let c=0;c<12;c++)_(0,4,8,12,e,n+2*o[i++]),T(0,4,8,12,e,n+2*o[i++]),_(1,5,9,13,e,n+2*o[i++]),T(1,5,9,13,e,n+2*o[i++]),_(2,6,10,14,e,n+2*o[i++]),T(2,6,10,14,e,n+2*o[i++]),_(3,7,11,15,e,n+2*o[i++]),T(3,7,11,15,e,n+2*o[i++]),_(0,5,10,15,e,n+2*o[i++]),T(0,5,10,15,e,n+2*o[i++]),_(1,6,11,12,e,n+2*o[i++]),T(1,6,11,12,e,n+2*o[i++]),_(2,7,8,13,e,n+2*o[i++]),T(2,7,8,13,e,n+2*o[i++]),_(3,4,9,14,e,n+2*o[i++]),T(3,4,9,14,e,n+2*o[i++]);this.v0l^=l[0]^l[16],this.v0h^=l[1]^l[17],this.v1l^=l[2]^l[18],this.v1h^=l[3]^l[19],this.v2l^=l[4]^l[20],this.v2h^=l[5]^l[21],this.v3l^=l[6]^l[22],this.v3h^=l[7]^l[23],this.v4l^=l[8]^l[24],this.v4h^=l[9]^l[25],this.v5l^=l[10]^l[26],this.v5h^=l[11]^l[27],this.v6l^=l[12]^l[28],this.v6h^=l[13]^l[29],this.v7l^=l[14]^l[30],this.v7h^=l[15]^l[31],H(l)}destroy(){this.destroyed=!0,H(this.buffer32),this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)}},W=At(t=>new ct(t));var E={Argond2d:0,Argon2i:1,Argon2id:2},Y=4,kt=t=>t===void 0?Uint8Array.of():ot(t);function ft(t,e){let n=t&65535,s=t>>>16,h=e&65535,r=e>>>16,i=Math.imul(n,h),o=Math.imul(s,h),c=Math.imul(n,r),f=Math.imul(s,r),a=(i>>>16)+(o&65535)+c,u=f+(o>>>16)+(a>>>16)|0,x=a<<16|i&65535;return{h:u,l:x}}function Tt(t,e){let{h:n,l:s}=ft(t,e);return{h:(n<<1|s>>>31)&4294967295,l:s<<1&4294967295}}function it(t,e,n,s){let{h,l:r}=Tt(e,s),i=Z(e,s,r);return{h:q(i,t,n,h),l:i|0}}var y=new Uint32Array(256);function M(t,e,n,s){let h=y[2*t],r=y[2*t+1],i=y[2*e],o=y[2*e+1],c=y[2*n],f=y[2*n+1],a=y[2*s],u=y[2*s+1];({h:r,l:h}=it(r,h,o,i)),{Dh:u,Dl:a}={Dh:u^r,Dl:a^h},{Dh:u,Dl:a}={Dh:tt(u,a),Dl:et(u,a)},{h:f,l:c}=it(f,c,u,a),{Bh:o,Bl:i}={Bh:o^f,Bl:i^c},{Bh:o,Bl:i}={Bh:X(o,i,24),Bl:P(o,i,24)},{h:r,l:h}=it(r,h,o,i),{Dh:u,Dl:a}={Dh:u^r,Dl:a^h},{Dh:u,Dl:a}={Dh:X(u,a,16),Dl:P(u,a,16)},{h:f,l:c}=it(f,c,u,a),{Bh:o,Bl:i}={Bh:o^f,Bl:i^c},{Bh:o,Bl:i}={Bh:$(o,i,63),Bl:K(o,i,63)},y[2*t]=h,y[2*t+1]=r,y[2*e]=i,y[2*e+1]=o,y[2*n]=c,y[2*n+1]=f,y[2*s]=a,y[2*s+1]=u}function Lt(t,e,n,s,h,r,i,o,c,f,a,u,x,b,p,d){M(t,h,c,x),M(e,r,f,b),M(n,i,a,p),M(s,o,u,d),M(t,r,a,d),M(e,i,u,x),M(n,o,c,b),M(s,h,f,p)}function j(t,e,n,s,h){for(let r=0;r<256;r++)y[r]=t[e+r]^t[n+r];for(let r=0;r<128;r+=16)Lt(r,r+1,r+2,r+3,r+4,r+5,r+6,r+7,r+8,r+9,r+10,r+11,r+12,r+13,r+14,r+15);for(let r=0;r<16;r+=2)Lt(r,r+1,r+16,r+17,r+32,r+33,r+48,r+49,r+64,r+65,r+80,r+81,r+96,r+97,r+112,r+113);if(h)for(let r=0;r<256;r++)t[s+r]^=y[r]^t[e+r]^t[n+r];else for(let r=0;r<256;r++)t[s+r]=y[r]^t[e+r]^t[n+r];H(y)}function ut(t,e){let n=z(t),s=new Uint32Array(1),h=z(s);if(s[0]=e,e<=64)return W.create({dkLen:e}).update(h).update(n).digest();let r=new Uint8Array(e),i=W.create({}).update(h).update(n).digest(),o=0;for(r.set(i.subarray(0,32)),o+=32;e-o>64;o+=32){let c=W.create({}).update(i);c.digestInto(i),c.destroy(),r.set(i.subarray(0,32),o)}return r.set(W(i,{dkLen:e-o}),o),H(i,s),G(r)}function Mt(t,e,n,s,h,r,i=!1){let o;t===0?e===0?o=h-1:i?o=e*s+h-1:o=e*s+(h==0?-1:0):i?o=n-s+h-1:o=n-s+(h==0?-1:0);let c=t!==0&&e!==Y-1?(e+1)*s:0,f=o-1-ft(o,ft(r,r).h).h;return(c+f)%n}var Ut=Math.pow(2,32);function C(t){return Number.isSafeInteger(t)&&t>=0&&t=Math.pow(2,24))throw new Error("p should be 1 <= p < 2^24");if(!C(h))throw new Error("m should be 0 <= m < 2^32");if(!C(r)||r<1)throw new Error("t (iterations) should be 1 <= t < 2^32");if(o!==void 0&&typeof o!="function")throw new Error("progressCb should be function");if(!C(h)||h<8*s)throw new Error("memory should be at least 8*p bytes");if(i!==16&&i!==19)throw new Error("unknown version="+i);return e}function Ht(t,e,n,s){if(t=ot(t),e=ot(e),O(t),O(e),!C(t.length))throw new Error("password should be less than 4 GB");if(!C(e.length)||e.length<8)throw new Error("salt should be at least 8 bytes and less than 4 GB");if(!Object.values(E).includes(n))throw new Error("invalid type");let{p:h,dkLen:r,m:i,t:o,version:c,key:f,personalization:a,maxmem:u,onProgress:x,asyncTick:b}=Ct(s);f=kt(f),a=kt(a);let p=W.create({}),d=new Uint32Array(1),g=z(d);for(let A of[h,r,i,o,c,n])d[0]=A,p.update(g);for(let A of[t,e,f,a])d[0]=A.length,p.update(g).update(A);let w=new Uint32Array(18),L=z(w);p.digestInto(L);let m=h,I=4*h*Math.floor(i/(Y*h)),k=Math.floor(I/h),S=Math.floor(k/Y),U=I*256;if(!C(u)||U>u)throw new Error("mem should be less than 2**32, got: maxmem="+u+", memused="+U);let N=new Uint32Array(U);for(let A=0;A{};if(x){let A=o*Y*h*S,v=Math.max(Math.floor(A/1e4),1),J=0;F=()=>{J++,x&&(!(J%v)||J===A)&&x(J/A)}}return H(d,w),{type:n,mP:I,p:h,t:o,version:c,B:N,laneLen:k,lanes:m,segmentLen:S,dkLen:r,perBlock:F,asyncTick:b}}function Et(t,e,n,s){let h=new Uint32Array(256);for(let i=0;iat(E.Argond2d,t,e,n),Zt=(t,e,n)=>at(E.Argon2i,t,e,n),qt=(t,e,n)=>at(E.Argon2id,t,e,n);async function dt(t,e,n,s){let{mP:h,p:r,t:i,version:o,B:c,laneLen:f,lanes:a,segmentLen:u,dkLen:x,perBlock:b,asyncTick:p}=Ht(e,n,t,s),d=new Uint32Array(3*256);d[262]=h,d[264]=i,d[266]=t;let g=Date.now();for(let w=0;w=0&&Adt(E.Argond2d,t,e,n),Jt=(t,e,n)=>dt(E.Argon2i,t,e,n),Qt=(t,e,n)=>dt(E.Argon2id,t,e,n);export{Yt as argon2d,vt as argon2dAsync,Zt as argon2i,Jt as argon2iAsync,qt as argon2id,Qt as argon2idAsync}; +/*! Bundled license information: + +@noble/hashes/esm/utils.js: + (*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *) +*/ From be696d116b8bcfe5e57688abdde9e9ba7a47f4673d0554aef0b7f7eff360f0b1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 21 Sep 2026 12:49:33 +0300 Subject: [PATCH 26/30] =?UTF-8?q?=D0=90=D0=B2=D0=B2=D0=B8=D0=B2=202.0=20-?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82,=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B2=D0=BA=D0=B0!!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PATCH_MANIFEST.md | 29 + .../java/server/archive/Ans104Bundle.java | 103 + .../java/server/archive/ArchiveFileNames.java | 56 - .../server/archive/ArchiveImportConfig.java | 37 - .../archive/ArchiveImportScheduler.java | 54 - .../server/archive/ArchiveImportService.java | 229 -- .../java/server/archive/ArchiveKeyLoader.java | 75 - .../archive/ArchivePublisherConfig.java | 98 - .../archive/ArchivePublisherScheduler.java | 90 - .../archive/ArchivePublisherService.java | 341 -- .../server/archive/ArweaveArchiveService.java | 259 -- .../ArweaveBlockPublisherScheduler.java | 37 + .../archive/ArweaveBlockPublisherService.java | 59 + .../archive/ArweaveBlockSyncScheduler.java | 32 + .../archive/ArweaveBlockSyncService.java | 185 + .../server/archive/ArweaveBlocksConfig.java | 66 + .../server/archive/ArweaveL1Uploader.java | 141 + .../java/server/archive/ArweaveMerkle.java | 49 + .../server/archive/ShineArchiveReader.java | 101 - .../server/archive/ShineArchiveWriter.java | 310 -- .../archive/SolanaArchiveHeadWriter.java | 373 -- .../java/server/archive/Ans104BundleTest.java | 36 + .../server/archive/ArchiveFileNamesTest.java | 34 - .../archive/ShineArchiveWriterTest.java | 83 - .../main/java/blockchain/Ans104DataItem.java | 363 ++ .../main/java/blockchain/BchBlockEntry.java | 388 +- .../java/blockchain/BchCryptoVerifier.java | 35 +- .../main/java/utils/files/FileStoreUtil.java | 297 -- .../java/blockchain/Ans104DataItemTest.java | 74 + .../src/main/java/utils/crypto/README.md | 2 +- .../java/shine/db/DatabaseInitializer.java | 6 + .../shine/db/archive/ArchiveBigBlockRef.java | 8 - .../db/archive/ArchiveBlockchainLocation.java | 14 - .../shine/db/archive/ArchiveChainCursor.java | 15 - .../shine/db/archive/ArchivePublishJob.java | 16 - .../db/archive/ArchivePublishJobChain.java | 17 - .../db/archive/ArchivePublisherHead.java | 14 - .../java/shine/db/dao/ArchiveImportDAO.java | 150 - .../shine/db/dao/ArchivePublicationDAO.java | 472 --- .../shine/db/dao/ArweaveBlockImportDAO.java | 138 + .../db/dao/BlockchainResyncCleanupDAO.java | 4 +- .../src/main/java/shine/db/dao/BlocksDAO.java | 327 +- .../shine/db/dao/ChannelNameStateDAO.java | 11 + .../shine/db/dao/SolanaUserPdaCurrentDAO.java | 18 + .../java/shine/db/entities/BlockEntry.java | 13 + .../main/resources/postgres/migration_v24.sql | 56 + .../src/main/resources/postgres/schema_v1.sql | 103 +- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 - .../blockchain/Net_AddBlock_Handler.java | 72 +- .../BlockchainWriter.java | 137 +- ..._GetArchiveBlockchainLocation_Handler.java | 52 - ..._GetArchiveBlockchainLocation_Request.java | 9 - ...GetArchiveBlockchainLocation_Response.java | 16 - .../server/sync/BlockchainResyncGuard.java | 6 +- .../BlockchainResyncRecoveryOnStartup.java | 150 - .../sync/PeriodicBlockchainSyncService.java | 26 - .../sync/SolanaUsersSyncStartupService.java | 10 +- .../ws/BlockchainTmpRecoveryOnStartup.java | 348 -- .../src/main/java/server/ws/WsServer.java | 41 +- .../src/main/resources/application.properties | 55 +- .../test/it/blockchain/AddBlockSender.java | 97 +- VERSION.properties | 4 +- docs/API/04_Add_Block_to_Blockchain_API.md | 321 +- docs/Archive/01_PROTOCOL_v1.0.md | 1647 -------- docs/Archive/02_IMPLEMENTATION_MAP.md | 137 - docs/Archive/03_DEPLOY_TEST_SERVER.md | 466 --- docs/Archive/04_TEST_AND_OPERATIONS.md | 113 - .../Archive/05_PATCH_CONTENTS_AND_REMOVALS.md | 38 - docs/Archive/06_FILE_MANIFEST.md | 50 - docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md | 303 -- docs/Archive/README.md | 42 - .../archive-publisher.example.properties | 33 - docs/Blockchain/01_Common_Block_Format.md | 137 +- .../Blockchain/17_ANS104_Arweave_Transport.md | 97 + docs/Blockchain/CHANGELOG.md | 13 + .../CODEX_APPLY_ANS104_TEST5590_PATCH.md | 108 + docs/Blockchain/README.md | 75 +- docs/Blockchain/sync-between-servers.md | 288 +- docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md | 1619 -------- docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md | 1619 -------- docs/Solana/user_pda/README.md | 4 +- .../details/shine_users.md | 4 +- docs/libs/shine-server-blockchain/Doc.md | 3 +- javac.20260920_155256.args | 30 + shine-UI/Blockchain-Viewer.html | 3416 ----------------- shine-UI/js/app.js | 2 - shine-UI/js/pages/blockchain-archive-view.js | 144 - shine-UI/js/pages/settings-view.js | 7 - shine-UI/js/services/ans104-data-item.js | 125 + shine-UI/js/services/auth-service.js | 63 +- 90 files changed, 2411 insertions(+), 14938 deletions(-) create mode 100644 PATCH_MANIFEST.md create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java delete mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java create mode 100644 SHiNE-server/shine-server-archive/src/test/java/server/archive/Ans104BundleTest.java delete mode 100644 SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java delete mode 100644 SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java create mode 100644 SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java delete mode 100644 SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java create mode 100644 SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v24.sql delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java delete mode 100644 SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java delete mode 100644 SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java delete mode 100644 docs/Archive/01_PROTOCOL_v1.0.md delete mode 100644 docs/Archive/02_IMPLEMENTATION_MAP.md delete mode 100644 docs/Archive/03_DEPLOY_TEST_SERVER.md delete mode 100644 docs/Archive/04_TEST_AND_OPERATIONS.md delete mode 100644 docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md delete mode 100644 docs/Archive/06_FILE_MANIFEST.md delete mode 100644 docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md delete mode 100644 docs/Archive/README.md delete mode 100644 docs/Archive/archive-publisher.example.properties create mode 100644 docs/Blockchain/17_ANS104_Arweave_Transport.md create mode 100644 docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md delete mode 100644 docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md delete mode 100644 docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md create mode 100644 javac.20260920_155256.args delete mode 100644 shine-UI/Blockchain-Viewer.html delete mode 100644 shine-UI/js/pages/blockchain-archive-view.js create mode 100644 shine-UI/js/services/ans104-data-item.js diff --git a/PATCH_MANIFEST.md b/PATCH_MANIFEST.md new file mode 100644 index 00000000..14d6eeb6 --- /dev/null +++ b/PATCH_MANIFEST.md @@ -0,0 +1,29 @@ +# SHiNE ANS-104 `test5590` patch + +Этот архив содержит **только новые/изменённые файлы** плюс `DELETE_FILES.txt`. + +## Что меняется + +- Frame v1 без поддержки старого формата. +- Один пользовательский block = готовый Ed25519 ANS-104 DataItem. +- Test tag: `App=test5590`; channel tag: `c=`. +- SHiNE `prevHash` остаётся `SHA-256(FrameV1Bytes)` и не зависит от Arweave ID. +- PostgreSQL — единственное локальное хранилище пользовательских blocks. +- Старый custom SHINE-ARCHIVE и filesystem blockchain storage удаляются. +- Каждые ~15 минут локальные pending DataItems собираются в standard ANS-104 bundle и идут через существующий native Arweave L1 transport. +- Другие серверы находят `App=test5590`, импортируют и не публикуют повторно. +- `ESP32/**` **не изменён**. + +## Состав + +- изменено/добавлено файлов: 46 +- удалить файлов: 41 + +Применение и проверки: `docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md`. + +## Проверки при сборке patch + +- `node --check` для изменённых production JS: OK. +- `diff -qr` каталога `ESP32` относительно исходного архива: без изменений. +- Active Java refs на удалённые SHINE-ARCHIVE/file-recovery classes: отсутствуют. +- `./gradlew testClasses --offline --no-daemon`: не запущен до компиляции, потому что wrapper требует скачать Gradle 8.14 с `services.gradle.org`, а рабочая среда не имеет внешнего сетевого доступа. Полный Gradle test обязателен после применения patch в dev-среде. diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java new file mode 100644 index 00000000..20977bb7 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java @@ -0,0 +1,103 @@ +package server.archive; + +import blockchain.Ans104DataItem; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** Minimal ANS-104 binary bundle writer/reader. */ +public final class Ans104Bundle { + public record Entry(byte[] id32, byte[] rawDataItem) {} + + private Ans104Bundle() {} + + public static byte[] write(List rawItems) { + if (rawItems == null || rawItems.isEmpty()) throw new IllegalArgumentException("ANS-104 bundle is empty"); + if (rawItems.size() > 1_000_000) throw new IllegalArgumentException("Too many ANS-104 items"); + + List parsed = new ArrayList<>(rawItems.size()); + long total = 32L + 64L * rawItems.size(); + for (byte[] raw : rawItems) { + Ans104DataItem item = new Ans104DataItem(raw); + if (!item.verifySignature()) throw new IllegalArgumentException("Invalid ANS-104 DataItem signature"); + parsed.add(item); + total = Math.addExact(total, raw.length); + } + if (total > Integer.MAX_VALUE) throw new IllegalArgumentException("Bundle too large for in-memory publisher: " + total); + + ByteArrayOutputStream out = new ByteArrayOutputStream((int) total); + out.writeBytes(uint256LE(rawItems.size())); + for (int i = 0; i < rawItems.size(); i++) { + out.writeBytes(uint256LE(rawItems.get(i).length)); + out.writeBytes(parsed.get(i).id32()); + } + for (byte[] raw : rawItems) out.writeBytes(raw); + return out.toByteArray(); + } + + public static List read(byte[] bundle, int maxItems) { + if (bundle == null || bundle.length < 32) throw new IllegalArgumentException("ANS-104 bundle too short"); + long countLong = readUint256AsLong(bundle, 0); + if (countLong <= 0 || countLong > Math.max(1, maxItems)) { + throw new IllegalArgumentException("Bad ANS-104 bundle item count: " + countLong); + } + int count = (int) countLong; + long headerLong = 32L + 64L * count; + if (headerLong > bundle.length) throw new IllegalArgumentException("ANS-104 bundle header truncated"); + int header = (int) headerLong; + long[] sizes = new long[count]; + byte[][] ids = new byte[count][]; + int hp = 32; + long payloadBytes = 0; + for (int i = 0; i < count; i++) { + sizes[i] = readUint256AsLong(bundle, hp); hp += 32; + ids[i] = Arrays.copyOfRange(bundle, hp, hp + 32); hp += 32; + if (sizes[i] <= 0 || sizes[i] > Integer.MAX_VALUE) throw new IllegalArgumentException("Bad DataItem size: " + sizes[i]); + payloadBytes = Math.addExact(payloadBytes, sizes[i]); + } + if (headerLong + payloadBytes != bundle.length) { + throw new IllegalArgumentException("ANS-104 bundle size mismatch: header=" + headerLong + " payload=" + payloadBytes + " actual=" + bundle.length); + } + List out = new ArrayList<>(count); + int p = header; + for (int i = 0; i < count; i++) { + int len = (int) sizes[i]; + byte[] raw = Arrays.copyOfRange(bundle, p, p + len); + p += len; + Ans104DataItem item = new Ans104DataItem(raw); + if (!Arrays.equals(ids[i], item.id32())) throw new IllegalArgumentException("Bundle entry ID mismatch at index " + i); + out.add(new Entry(ids[i], raw)); + } + return out; + } + + public static byte[] find(byte[] bundle, byte[] wantedId32, int maxItems) { + if (wantedId32 == null || wantedId32.length != 32) throw new IllegalArgumentException("wanted ID must be 32 bytes"); + for (Entry e : read(bundle, maxItems)) { + if (Arrays.equals(e.id32(), wantedId32)) return e.rawDataItem(); + } + return null; + } + + /** ANS-104 bundle integers are 32-byte unsigned little-endian values. */ + static byte[] uint256LE(long value) { + if (value < 0) throw new IllegalArgumentException("negative uint256"); + byte[] out = new byte[32]; + long v = value; + for (int i = 0; i < 8; i++) { out[i] = (byte) (v & 0xff); v >>>= 8; } + return out; + } + + static long readUint256AsLong(byte[] data, int offset) { + if (offset < 0 || offset + 32 > data.length) throw new IllegalArgumentException("uint256 truncated"); + for (int i = offset + 8; i < offset + 32; i++) { + if (data[i] != 0) throw new IllegalArgumentException("uint256 exceeds signed long range"); + } + long v = 0; + for (int i = 7; i >= 0; i--) v = (v << 8) | (data[offset + i] & 0xffL); + if (v < 0) throw new IllegalArgumentException("uint256 exceeds signed long range"); + return v; + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java deleted file mode 100644 index 55eff901..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java +++ /dev/null @@ -1,56 +0,0 @@ -package server.archive; - -import java.nio.file.Path; -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.Base64; - -/** Единые локальные имена больших архивных файлов. */ -public final class ArchiveFileNames { - private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yy"); - - private ArchiveFileNames() {} - - /** - * Временное имя полностью собранного файла до появления Arweave TX ID. - * Пример: archive01.00001.11.09.26.tmp.SHiNE-archive - */ - public static String pendingFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) { - return baseName(login, bigBlockNumber, createdAtMs, zone) + ".tmp.SHiNE-archive"; - } - - /** - * Постоянное имя после успешной загрузки в Arweave. - * Пример: archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive - */ - public static String uploadedFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) { - if (txId == null || txId.length != 32) throw new IllegalArgumentException("Arweave TX ID должен быть 32 bytes"); - String tx = Base64.getUrlEncoder().withoutPadding().encodeToString(txId); - return baseName(login, bigBlockNumber, createdAtMs, zone) + "." + tx + ".SHiNE-archive"; - } - - public static Path pendingFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone) { - return archiveDir.resolve(pendingFileName(login, bigBlockNumber, createdAtMs, zone)); - } - - public static Path uploadedFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) { - return archiveDir.resolve(uploadedFileName(login, bigBlockNumber, createdAtMs, zone, txId)); - } - - private static String baseName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) { - if (bigBlockNumber < 0 || bigBlockNumber > 0xffff_ffffL) { - throw new IllegalArgumentException("Номер большого блока вне u32: " + bigBlockNumber); - } - String safeLogin = sanitize(login); - String number = String.format("%05d", bigBlockNumber); - String date = DATE.format(Instant.ofEpochMilli(createdAtMs).atZone(zone == null ? ZoneId.systemDefault() : zone)); - return safeLogin + "." + number + "." + date; - } - - static String sanitize(String login) { - String s = login == null ? "server" : login.trim(); - if (s.isEmpty()) s = "server"; - return s.replaceAll("[^A-Za-z0-9._-]", "_"); - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java deleted file mode 100644 index f4c58918..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java +++ /dev/null @@ -1,37 +0,0 @@ -package server.archive; - -import utils.config.AppConfig; - -import java.nio.file.Path; -import java.util.*; - -/** Настройки чтения архивов других доверенных SHiNE-серверов. Пустой whitelist = импорт выключен. */ -public record ArchiveImportConfig( - Set allowedPublishers, - int intervalMinutes, - String arweaveGateway, - Path workDir -) { - public static ArchiveImportConfig load() { - AppConfig c = AppConfig.getInstance(); - Set approved = new LinkedHashSet<>(); - String raw = c.getParam("archive.import.allowedPublishers"); - if (raw != null) { - for (String item : raw.split(",")) { - String login = item.trim().toLowerCase(Locale.ROOT); - if (!login.isEmpty()) approved.add(login); - } - } - String gateway = c.getParam("archive.arweave.gateway"); - if (gateway == null || gateway.isBlank()) gateway = "https://arweave.net"; - String dir = c.getParam("archive.import.workDir"); - if (dir == null || dir.isBlank()) dir = "data/archive-import"; - return new ArchiveImportConfig( - Set.copyOf(approved), - Math.max(1, c.getInt("archive.import.intervalMinutes", 60)), - gateway.trim(), - Path.of(dir)); - } - - public boolean enabled() { return !allowedPublishers.isEmpty(); } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java deleted file mode 100644 index 1fad1e6c..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java +++ /dev/null @@ -1,54 +0,0 @@ -package server.archive; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.*; - -/** - * Фоновая проверка уже синхронизированной локальной таблицы User PDA. - * Solana отдельно здесь не опрашивается: первый запуск через 10 секунд после старта, - * затем по умолчанию раз в час. - */ -public final class ArchiveImportScheduler { - private static final Logger log = LoggerFactory.getLogger(ArchiveImportScheduler.class); - private static ScheduledExecutorService executor; - - private ArchiveImportScheduler() {} - - public static synchronized void startOrLog() { - if (executor != null) return; - ArchiveImportConfig cfg = ArchiveImportConfig.load(); - if (!cfg.enabled()) { - log.info("Archive importer выключен: archive.import.allowedPublishers пуст"); - return; - } - try { - ArchiveImportService service = new ArchiveImportService(cfg); - executor = Executors.newSingleThreadScheduledExecutor(r -> { - Thread t = new Thread(r, "archive-importer"); - t.setDaemon(true); - return t; - }); - executor.scheduleWithFixedDelay( - () -> { - try { service.pollOnce(); } - catch (Exception e) { log.error("Archive importer cycle failed", e); } - }, - 10, - Math.multiplyExact((long) cfg.intervalMinutes(), 60L), - TimeUnit.SECONDS); - log.info("Archive importer включён. approvedPublishers={} interval={}min", - cfg.allowedPublishers(), cfg.intervalMinutes()); - } catch (Exception e) { - log.error("Archive importer не запущен", e); - } - } - - public static synchronized void close() { - if (executor != null) { - executor.shutdownNow(); - executor = null; - } - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java deleted file mode 100644 index 06b6cf80..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java +++ /dev/null @@ -1,229 +0,0 @@ -package server.archive; - -import blockchain.BchBlockEntry; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; -import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; -import shine.db.archive.*; -import shine.db.dao.*; -import shine.db.entities.BlockchainStateEntry; - -import java.io.IOException; -import java.net.URI; -import java.net.http.*; -import java.nio.file.*; -import java.time.Duration; -import java.util.*; -import java.util.concurrent.locks.ReentrantLock; - -/** Импорт archive head только от серверов из server-side whitelist. */ -public final class ArchiveImportService { - private static final Logger log = LoggerFactory.getLogger(ArchiveImportService.class); - private final ArchiveImportConfig cfg; - private final ArchiveImportDAO dao = ArchiveImportDAO.getInstance(); - private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance(); - private final BlocksDAO blocksDao = BlocksDAO.getInstance(); - private final ShineArchiveReader reader = new ShineArchiveReader(); - private final Net_AddBlock_Handler addBlockHandler = new Net_AddBlock_Handler(); - private final HttpClient http = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(20)) - .followRedirects(HttpClient.Redirect.NORMAL) - .build(); - - public ArchiveImportService(ArchiveImportConfig cfg) throws IOException { - this.cfg = Objects.requireNonNull(cfg); - Files.createDirectories(cfg.workDir()); - } - - /** - * Никаких отдельных Solana RPC запросов здесь нет. - * Читаем только solana_user_pda_current, которую уже обновляет обычный Solana users sync. - */ - public void pollOnce() throws Exception { - if (!cfg.enabled()) return; - for (ArchivePublisherHead head : dao.listPendingPublisherHeads(cfg.allowedPublishers())) { - try { - importHead(head); - } catch (Exception e) { - log.error("Archive import publisher={} tx={} failed", head.login(), head.archiveHeadTxId(), e); - } - } - } - - private void importHead(ArchivePublisherHead publisher) throws Exception { - String headTxId = publisher.archiveHeadTxId(); - byte[] headHash = hex(publisher.archiveHeadHash()); - - // Если процесс успел импортировать head, но упал до установки archive_imported=true, - // следующий цикл просто завершает отметку без повторного скачивания всей истории. - if (headTxId.equals(publisher.lastImportedArchiveTxId())) { - if (!dao.markHeadImported(publisher.login(), headTxId)) { - log.info("Archive head changed while finalizing import marker publisher={} oldHead={}", - publisher.login(), headTxId); - } - return; - } - - Path headFile = download(headTxId); - ShineArchiveReader.Document head; - try { - head = reader.readAndVerify(headFile, publisher.login(), headHash, publisher.rootKey()); - } finally { - Files.deleteIfExists(headFile); - } - - // FULL reference table позволяет догнать все archive blocks, пропущенные пока сервер был выключен. - List previous = new ArrayList<>(head.references()); - previous.sort(Comparator.comparingLong(ShineArchiveReader.Reference::bigBlockNumber)); - - int startIndex = findResumeIndex(previous, publisher.lastImportedArchiveTxId(), headTxId); - for (int i = startIndex; i < previous.size(); i++) { - ShineArchiveReader.Reference ref = previous.get(i); - importOne(publisher, ref.arweaveTxId(), ref.archiveHash()); - if (!dao.advanceImportCursor(publisher.login(), headTxId, ref.arweaveTxId())) { - throw new IllegalStateException("Archive head changed during import for publisher " + publisher.login()); - } - } - - // Head уже скачан и проверен выше — второй раз его из Arweave не качаем. - importDocument(publisher, headTxId, head); - if (!dao.advanceImportCursor(publisher.login(), headTxId, headTxId)) { - throw new IllegalStateException("Archive head changed during final import for publisher " + publisher.login()); - } - if (!dao.markHeadImported(publisher.login(), headTxId)) { - throw new IllegalStateException("Archive head changed before imported marker for publisher " + publisher.login()); - } - - log.info("Archive head fully imported publisher={} bigBlock={} tx={}", - publisher.login(), head.bigBlockNumber(), headTxId); - } - - /** - * Возвращает индекс первого ещё не импортированного previous big block. - * Пустой cursor = новый сервер, импортируем всю FULL историю. - */ - private static int findResumeIndex(List previous, - String lastImportedTxId, - String currentHeadTxId) { - String last = lastImportedTxId == null ? "" : lastImportedTxId.trim(); - if (last.isEmpty()) return 0; - if (last.equals(currentHeadTxId)) return previous.size(); - for (int i = 0; i < previous.size(); i++) { - if (last.equals(previous.get(i).arweaveTxId())) return i + 1; - } - throw new IllegalStateException( - "Последний импортированный archive TX не найден в FULL history текущего head: " + last); - } - - private void importOne(ArchivePublisherHead publisher, String txId, byte[] expectedHash) throws Exception { - Path file = download(txId); - try { - ShineArchiveReader.Document doc = reader.readAndVerify( - file, publisher.login(), expectedHash, publisher.rootKey()); - importDocument(publisher, txId, doc); - } finally { - Files.deleteIfExists(file); - } - } - - private void importDocument(ArchivePublisherHead publisher, String txId, - ShineArchiveReader.Document doc) throws Exception { - for (ShineArchiveReader.Chunk chunk : doc.chunks()) { - processChunk(publisher.login(), txId, doc, chunk); - } - log.info("Archive imported publisher={} bigBlock={} tx={} chunks={}", - publisher.login(), doc.bigBlockNumber(), txId, doc.chunks().size()); - } - - private void processChunk(String publisherLogin, String txId, - ShineArchiveReader.Document doc, - ShineArchiveReader.Chunk chunk) throws Exception { - ReentrantLock lock = BlockchainLocks.lockFor(chunk.blockchainName()); - lock.lock(); - try { - ensureState(chunk.blockchainName()); - BlockchainStateEntry state = stateDao.getByBlockchainName(chunk.blockchainName()); - int localLast = state == null ? -1 : state.getLastBlockNumber(); - long archiveLast = -1; - - for (byte[] raw : chunk.rawRecords()) { - BchBlockEntry block = new BchBlockEntry(raw); - archiveLast = Integer.toUnsignedLong(block.blockNumber); - - if (block.blockNumber <= localLast) { - byte[] localHash = blocksDao.getHashByNumber(chunk.blockchainName(), block.blockNumber); - if (!Arrays.equals(localHash, block.getHash32())) { - throw new IllegalStateException( - "Archive conflict " + chunk.blockchainName() + "#" + block.blockNumber); - } - continue; - } - - if (block.blockNumber != localLast + 1) { - throw new IllegalStateException( - "Archive gap " + chunk.blockchainName() + ": local=" + localLast + - " next=" + block.blockNumber); - } - - var result = addBlockHandler.addBlockFromArchive(chunk.blockchainName(), raw); - if (!result.ok()) { - throw new IllegalStateException( - "Archive AddBlock rejected " + chunk.blockchainName() + "#" + - block.blockNumber + ": " + result.reasonCode()); - } - localLast = block.blockNumber; - } - - if (archiveLast >= 0) { - dao.upsertLocation(new ArchiveBlockchainLocation( - chunk.blockchainName(), publisherLogin, txId, doc.archiveHash(), doc.bigBlockNumber(), - chunk.offset(), chunk.size(), archiveLast, System.currentTimeMillis())); - } - } finally { - lock.unlock(); - } - } - - private void ensureState(String blockchainName) throws Exception { - if (stateDao.getByBlockchainName(blockchainName) != null) return; - ArchiveImportDAO.BlockchainIdentity id = dao.getBlockchainIdentity(blockchainName); - if (id == null) throw new IllegalStateException("Нет User PDA для blockchain " + blockchainName); - - BlockchainStateEntry s = new BlockchainStateEntry(); - s.setBlockchainName(id.blockchainName()); - s.setLogin(id.login()); - s.setBlockchainKey(id.blockchainKey()); - s.setSizeLimit(id.sizeLimit()); - s.setFileSizeBytes(0); - s.setLastBlockNumber(-1); - s.setLastBlockHash(null); - s.setUpdatedAtMs(System.currentTimeMillis()); - stateDao.insertIfMissing(s); - } - - private Path download(String txId) throws Exception { - String gateway = cfg.arweaveGateway().replaceAll("/+$", ""); - Path target = Files.createTempFile(cfg.workDir(), "archive-", ".download"); - HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/" + txId)) - .timeout(Duration.ofMinutes(10)) - .GET() - .build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofFile(target)); - if (resp.statusCode() < 200 || resp.statusCode() >= 300) { - Files.deleteIfExists(target); - throw new IOException("Arweave HTTP " + resp.statusCode() + " tx=" + txId); - } - return target; - } - - private static byte[] hex(String value) { - String s = String.valueOf(value == null ? "" : value).trim(); - if (s.length() != 64) throw new IllegalArgumentException("archive_head_hash должен быть HEX64"); - byte[] out = new byte[32]; - for (int i = 0; i < 32; i++) { - out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16); - } - return out; - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java deleted file mode 100644 index 0e6d4b37..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java +++ /dev/null @@ -1,75 +0,0 @@ -package server.archive; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import sync.util.Base58Util; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Base64; - -/** Читает Ed25519 seed32 из распространённых SHiNE/Solana форматов ключей. */ -final class ArchiveKeyLoader { - private static final ObjectMapper JSON = new ObjectMapper(); - - private ArchiveKeyLoader() {} - - static byte[] loadSeed32(Path path) throws IOException { - if (path == null) throw new IllegalArgumentException("Путь ключа не задан"); - byte[] raw = Files.readAllBytes(path); - if (raw.length == 32) return raw.clone(); - if (raw.length == 64) return first32(raw); - - String text = new String(raw, StandardCharsets.UTF_8).trim(); - if (text.isEmpty()) throw new IOException("Пустой key file: " + path); - - // Solana keypair JSON: [64 чисел]. - if (text.startsWith("[")) { - JsonNode arr = JSON.readTree(text); - if (!arr.isArray() || (arr.size() != 32 && arr.size() != 64)) { - throw new IOException("Ожидался JSON keypair на 32/64 байта: " + path); - } - byte[] bytes = new byte[arr.size()]; - for (int i = 0; i < arr.size(); i++) { - int v = arr.get(i).asInt(-1); - if (v < 0 || v > 255) throw new IOException("Некорректный байт keypair: " + path); - bytes[i] = (byte) v; - } - return bytes.length == 32 ? bytes : first32(bytes); - } - - // Base58 seed32 или Solana secret key 64 bytes. - try { - byte[] decoded = Base58Util.decode(text); - if (decoded.length == 32) return decoded; - if (decoded.length == 64) return first32(decoded); - } catch (IllegalArgumentException ignored) { - // Пробуем остальные текстовые форматы ниже. - } - - // Base64 raw seed / keypair / Ed25519 PKCS8. В SHiNE PKCS8 seed находится в последних 32 байтах. - try { - byte[] decoded = Base64.getDecoder().decode(text.replaceAll("\\s+", "")); - if (decoded.length == 32) return decoded; - if (decoded.length == 64) return first32(decoded); - if (decoded.length > 32) { - byte[] seed = new byte[32]; - System.arraycopy(decoded, decoded.length - 32, seed, 0, 32); - return seed; - } - } catch (IllegalArgumentException ignored) { - // Ниже выдадим понятную общую ошибку. - } - - throw new IOException("Не удалось распознать Ed25519 key file: " + path - + ". Поддерживаются raw seed32, raw/keypair64, Solana JSON, Base58 seed/keypair и Base64 PKCS8."); - } - - private static byte[] first32(byte[] bytes) { - byte[] out = new byte[32]; - System.arraycopy(bytes, 0, out, 0, 32); - return out; - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java deleted file mode 100644 index d4ed7e00..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java +++ /dev/null @@ -1,98 +0,0 @@ -package server.archive; - -import utils.config.AppConfig; -import utils.config.SolanaProgramsConfig; - -import java.nio.file.Path; -import java.time.LocalTime; -import java.time.ZoneId; - -/** Настройки опционального архивного publisher-а. По умолчанию функция выключена. */ -public record ArchivePublisherConfig( - boolean enabled, - LocalTime publishTime, - ZoneId publishZone, - long maxFileBytes, - Path workDir, - String serverLogin, - String arweaveGateway, - Path arweaveWalletJwkPath, - int arweaveMinConfirmations, - int arweaveConfirmPollSeconds, - int arweaveConfirmTimeoutMinutes, - Path solanaRootKeyPath, - Path solanaClientKeyPath, - String solanaRpcUrl, - String solanaUsersProgramId, - String solanaPaymentsProgramId, - int solanaConfirmPollSeconds, - int solanaConfirmTimeoutMinutes, - String solanaCommitment -) { - public static ArchivePublisherConfig load() { - AppConfig c = AppConfig.getInstance(); - return new ArchivePublisherConfig( - c.getBoolean("archive.publish.enabled", false), - parseTime(orDefault(c.getParam("archive.publish.time"), "00:00")), - parseZone(c.getParam("archive.publish.zoneId")), - positiveLong(parseLong(c.getParam("archive.maxFileBytes"), 4_000_000_000L), "archive.maxFileBytes"), - Path.of(orDefault(c.getParam("archive.workDir"), "data/archive")), - requiredIfEnabled(c, "server.SHiNE.login"), - orDefault(c.getParam("archive.arweave.gateway"), "https://arweave.net"), - optionalPath(c.getParam("archive.arweave.walletJwkPath")), - nonNegative(c.getInt("archive.arweave.minConfirmations", 1), "archive.arweave.minConfirmations"), - positive(c.getInt("archive.arweave.confirmPollSeconds", 30), "archive.arweave.confirmPollSeconds"), - positive(c.getInt("archive.arweave.confirmTimeoutMinutes", 180), "archive.arweave.confirmTimeoutMinutes"), - optionalPath(c.getParam("archive.solana.rootKeyPath")), - optionalPath(c.getParam("archive.solana.clientKeyPath")), - firstNonBlank(c.getParam("solana.users.sync.rpcUrl"), c.getParam("solana.rpcUrl"), SolanaProgramsConfig.SOLANA_RPC_URL), - firstNonBlank(c.getParam("solana.users.sync.programId"), SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID), - SolanaProgramsConfig.SHINE_PAYMENTS_PROGRAM_ID, - positive(c.getInt("archive.solana.confirmPollSeconds", 5), "archive.solana.confirmPollSeconds"), - positive(c.getInt("archive.solana.confirmTimeoutMinutes", 30), "archive.solana.confirmTimeoutMinutes"), - orDefault(c.getParam("archive.solana.commitment"), "finalized") - ); - } - - public void validateForStart() { - if (!enabled) return; - if (serverLogin == null || serverLogin.isBlank()) throw new IllegalArgumentException("server.SHiNE.login обязателен для archive publisher"); - if (arweaveWalletJwkPath == null) throw new IllegalArgumentException("archive.arweave.walletJwkPath обязателен"); - if (solanaRootKeyPath == null) throw new IllegalArgumentException("archive.solana.rootKeyPath обязателен"); - if (solanaClientKeyPath == null) throw new IllegalArgumentException("archive.solana.clientKeyPath обязателен"); - if (solanaRpcUrl == null || solanaRpcUrl.isBlank()) throw new IllegalArgumentException("solana.users.sync.rpcUrl или solana.rpcUrl обязателен"); - if (maxFileBytes >= 0x1_0000_0000L) { - throw new IllegalArgumentException("SHINE-ARCHIVE v1 требует archive.maxFileBytes < 4294967296"); - } - } - - private static String requiredIfEnabled(AppConfig c, String name) { - String v = c.getParam(name); - return v == null ? "" : v.trim(); - } - private static Path optionalPath(String s) { return s == null || s.isBlank() ? null : Path.of(s.trim()); } - private static String firstNonBlank(String... values) { - if (values != null) for (String v : values) if (v != null && !v.isBlank()) return v.trim(); - return ""; - } - private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); } - private static long parseLong(String s, long d) { return s == null || s.isBlank() ? d : Long.parseLong(s.trim()); } - private static LocalTime parseTime(String value) { - try { - return LocalTime.parse(value.trim()); - } catch (Exception e) { - throw new IllegalArgumentException("archive.publish.time должен быть в формате HH:mm, например 00:00", e); - } - } - private static ZoneId parseZone(String value) { - if (value == null || value.isBlank()) return ZoneId.systemDefault(); - try { - return ZoneId.of(value.trim()); - } catch (Exception e) { - throw new IllegalArgumentException("Некорректный archive.publish.zoneId: " + value, e); - } - } - private static int positive(int v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; } - private static int nonNegative(int v, String n) { if (v < 0) throw new IllegalArgumentException(n + " должен быть >= 0"); return v; } - private static long positiveLong(long v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java deleted file mode 100644 index 10afac77..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java +++ /dev/null @@ -1,90 +0,0 @@ -package server.archive; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.Duration; -import java.time.ZonedDateTime; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -/** Опциональный scheduler: один новый archive job в заданное локальное время каждый день. */ -public final class ArchivePublisherScheduler { - private static final Logger log = LoggerFactory.getLogger(ArchivePublisherScheduler.class); - private static final AtomicBoolean STARTED = new AtomicBoolean(false); - private static ScheduledExecutorService executor; - - private ArchivePublisherScheduler() {} - - public static void startOrLog() { - ArchivePublisherConfig cfg; - try { - cfg = ArchivePublisherConfig.load(); - } catch (Exception e) { - log.error("Не удалось прочитать archive publisher config", e); - return; - } - if (!cfg.enabled()) { - log.info("Archive publisher выключен (archive.publish.enabled=false)"); - return; - } - if (!STARTED.compareAndSet(false, true)) return; - - try { - ArchivePublisherService service = new ArchivePublisherService(cfg); - executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { - @Override public Thread newThread(Runnable r) { - Thread t = new Thread(r, "shine-archive-publisher"); - t.setDaemon(true); - return t; - } - }); - - // Незавершённую публикацию после рестарта продолжаем сразу, а новый snapshot - // создаём только в очередное назначенное суточное время. - executor.execute(() -> { - try { - service.resumeUnfinishedJob(); - } catch (Exception e) { - log.error("Не удалось продолжить незавершённый archive job", e); - } - }); - scheduleNextDaily(service, cfg); - log.info("Archive publisher включён: login={} dir={} dailyAt={} zone={}", - cfg.serverLogin(), cfg.workDir(), cfg.publishTime(), cfg.publishZone()); - } catch (Exception e) { - STARTED.set(false); - log.error("Archive publisher НЕ запущен из-за ошибки preflight", e); - } - } - - private static void scheduleNextDaily(ArchivePublisherService service, ArchivePublisherConfig cfg) { - ScheduledExecutorService e = executor; - if (e == null || e.isShutdown()) return; - - ZonedDateTime now = ZonedDateTime.now(cfg.publishZone()); - ZonedDateTime next = now.toLocalDate().atTime(cfg.publishTime()).atZone(cfg.publishZone()); - if (!next.isAfter(now)) next = next.plusDays(1); - long delayMs = Math.max(1L, Duration.between(now, next).toMillis()); - - e.schedule(() -> { - try { - service.runCycle(); - } catch (Exception ex) { - log.error("Archive publisher daily cycle failed; retry нового snapshot будет в следующее назначенное время", ex); - } finally { - scheduleNextDaily(service, cfg); - } - }, delayMs, TimeUnit.MILLISECONDS); - - log.info("Следующая архивная публикация запланирована на {}", next); - } - - public static void close() { - ScheduledExecutorService e = executor; - if (e != null) e.shutdownNow(); - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java deleted file mode 100644 index 0826fc8d..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java +++ /dev/null @@ -1,341 +0,0 @@ -package server.archive; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; -import shine.db.archive.ArchiveBigBlockRef; -import shine.db.archive.ArchiveChainCursor; -import shine.db.archive.ArchivePublishJob; -import shine.db.archive.ArchivePublishJobChain; -import shine.db.dao.ArchivePublicationDAO; -import shine.db.dao.ArchiveImportDAO; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.BlocksDAO; -import shine.db.entities.BlockEntry; -import shine.db.entities.BlockchainStateEntry; - -import java.nio.file.*; -import java.util.*; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.ReentrantLock; - -/** Один crash-safe цикл публикации большого SHINE-ARCHIVE блока. */ -public final class ArchivePublisherService { - private static final Logger log = LoggerFactory.getLogger(ArchivePublisherService.class); - - private final ArchivePublisherConfig cfg; - private final ArchivePublicationDAO archiveDao = ArchivePublicationDAO.getInstance(); - private final ArchiveImportDAO archiveImportDao = ArchiveImportDAO.getInstance(); - private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance(); - private final BlocksDAO blocksDao = BlocksDAO.getInstance(); - private final ShineArchiveWriter writer = new ShineArchiveWriter(); - private final ArweaveArchiveService arweave; - private final SolanaArchiveHeadWriter solana; - private final byte[] archiveSigningSeed32; - private final AtomicBoolean running = new AtomicBoolean(false); - - public ArchivePublisherService(ArchivePublisherConfig cfg) throws Exception { - this.cfg = Objects.requireNonNull(cfg); - cfg.validateForStart(); - Files.createDirectories(cfg.workDir()); - this.archiveSigningSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath()); - this.arweave = new ArweaveArchiveService(cfg); - this.solana = new SolanaArchiveHeadWriter(cfg); - this.solana.validateKeysAgainstPda(); - backfillOwnLocationIndex(); - } - - /** - * После обновления старого publisher-сервера заполняет универсальный location index - * из уже существующих finalized cursors, не дожидаясь следующего суточного archive block. - */ - private void backfillOwnLocationIndex() throws Exception { - Map finalized = new HashMap<>(); - for (ArchiveBigBlockRef ref : archiveDao.listFinalizedBigBlocks()) finalized.put(ref.bigBlockNumber(), ref); - if (finalized.isEmpty()) return; - long now = System.currentTimeMillis(); - int updated = 0; - for (BlockchainStateEntry state : stateDao.listAll()) { - if (state == null || state.getBlockchainName() == null || state.getBlockchainName().isBlank()) continue; - ArchiveChainCursor cursor = archiveDao.getCursor(state.getBlockchainName()); - if (cursor == null) continue; - ArchiveBigBlockRef ref = finalized.get(cursor.lastArchiveBigBlockNumber()); - if (ref == null || ref.arweaveTxId() == null || ref.arweaveTxId().length != 32) continue; - archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation( - cursor.blockchainName(), cfg.serverLogin(), b64url(ref.arweaveTxId()), - cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(), - cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), now)); - updated++; - } - if (updated > 0) log.info("Archive publisher: восстановлен location index для {} blockchain", updated); - } - - /** После рестарта продолжает только уже существующий незавершённый job; новый snapshot не создаёт. */ - public boolean resumeUnfinishedJob() throws Exception { - if (!running.compareAndSet(false, true)) return false; - try { - ArchivePublishJob job = archiveDao.findUnfinishedJob(); - if (job == null) return false; - log.info("Archive publisher: после рестарта продолжаем job id={} block={} status={}", - job.id(), job.bigBlockNumber(), job.status()); - process(job.id()); - return true; - } finally { - running.set(false); - } - } - - /** Запускает суточный цикл: продолжает незавершённый job либо создаёт новый frozen snapshot. */ - public boolean runCycle() throws Exception { - if (!running.compareAndSet(false, true)) { - log.info("Archive publisher: предыдущий цикл ещё работает, новый пропущен"); - return false; - } - try { - ArchivePublishJob job = archiveDao.findUnfinishedJob(); - if (job == null) { - job = createFrozenJob(); - if (job == null) { - log.info("Archive publisher: новых SHiNE-блоков нет"); - return false; - } - } else { - log.info("Archive publisher: продолжаем незавершённый job id={} block={} status={}", - job.id(), job.bigBlockNumber(), job.status()); - } - process(job.id()); - return true; - } finally { - running.set(false); - } - } - - private ArchivePublishJob createFrozenJob() throws Exception { - List chains = new ArrayList<>(); - for (BlockchainStateEntry listed : stateDao.listAll()) { - if (listed == null || listed.getBlockchainName() == null || listed.getBlockchainName().isBlank()) continue; - String name = listed.getBlockchainName(); - ReentrantLock lock = BlockchainLocks.lockFor(name); - lock.lock(); - try { - BlockchainStateEntry state = stateDao.getByBlockchainName(name); - if (state == null || state.getLastBlockNumber() < 0) continue; - ArchiveChainCursor cursor = archiveDao.getCursor(name); - long from; - byte[] previousSourceHash; - Long prevBig = null; - byte[] prevBigHash = null; - Long prevChunkOffset = null; - Long prevChunkSize = null; - - if (cursor == null) { - from = 0L; - previousSourceHash = new byte[32]; - } else { - long cursorBlock = cursor.lastArchivedSourceBlockNumber(); - if (cursorBlock < 0 || cursorBlock > 0xffff_ffffL) { - throw new IllegalStateException("Некорректный archive cursor для " + name + ": " + cursorBlock); - } - byte[] actualCursorHash = blocksDao.getHashByNumber(name, (int) cursorBlock); - if (!Arrays.equals(actualCursorHash, cursor.lastArchivedSourceBlockHash())) { - throw new IllegalStateException("Archive cursor hash не совпадает с локальной blockchain: " + name + "#" + cursorBlock); - } - from = cursorBlock + 1L; - previousSourceHash = cursor.lastArchivedSourceBlockHash(); - prevBig = cursor.lastArchiveBigBlockNumber(); - prevBigHash = cursor.lastArchiveBigBlockHash(); - prevChunkOffset = cursor.lastChunkOffset(); - prevChunkSize = cursor.lastChunkSize(); - } - - long to = Integer.toUnsignedLong(state.getLastBlockNumber()); - if (from > to) continue; - if (from > 0xffff_ffffL || to > 0xffff_ffffL) throw new IllegalStateException("Source block number вне u32: " + name); - byte[] lastHash = state.getLastBlockHash(); - if (lastHash == null || lastHash.length != 32) { - lastHash = blocksDao.getHashByNumber(name, (int) to); - } - require32(lastHash, "last source block hash " + name); - chains.add(new ArchivePublishJobChain( - 0L, name, from, to, previousSourceHash, lastHash, - prevBig, prevBigHash, prevChunkOffset, prevChunkSize, null, null)); - } finally { - lock.unlock(); - } - } - - if (chains.isEmpty()) return null; - chains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName)); - long bigBlock = archiveDao.nextBigBlockNumber(); - if (bigBlock > 0xffff_ffffL) throw new IllegalStateException("Закончился диапазон u32 big_block_number"); - long createdAt = System.currentTimeMillis(); - long id = archiveDao.createJobWithChains(bigBlock, createdAt, chains); - log.info("Archive snapshot создан: job={} bigBlock={} chains={}", id, bigBlock, chains.size()); - return archiveDao.getJob(id); - } - - private void process(long jobId) throws Exception { - while (true) { - ArchivePublishJob job = archiveDao.getJob(jobId); - if (job == null) throw new IllegalStateException("Archive job исчез: " + jobId); - switch (job.status()) { - case "SNAPSHOT_CREATED" -> buildFile(job); - case "FILE_BUILT" -> uploadArweave(job); - case "ARWEAVE_UPLOADED" -> waitArweave(job); - case "ARWEAVE_CONFIRMED" -> submitSolana(job); - case "SOLANA_SUBMITTED" -> waitSolana(job); - case "SOLANA_FINALIZED" -> commitCursors(job); - case "CURSORS_COMMITTED" -> { - log.info("Archive job завершён: id={} bigBlock={} file={} tx={}", job.id(), job.bigBlockNumber(), job.localArchivePath(), b64url(job.arweaveTxId())); - return; - } - case "FAILED" -> throw new IllegalStateException("Archive job помечен FAILED: " + job.errorText()); - default -> throw new IllegalStateException("Неизвестный archive job status: " + job.status()); - } - } - } - - private void buildFile(ArchivePublishJob job) throws Exception { - List chains = archiveDao.listJobChains(job.id()); - if (chains.isEmpty()) throw new IllegalStateException("SNAPSHOT_CREATED job не содержит chains: " + job.id()); - Map> records = new LinkedHashMap<>(); - - for (ArchivePublishJobChain ch : chains) { - ReentrantLock lock = BlockchainLocks.lockFor(ch.blockchainName()); - lock.lock(); - try { - if (ch.fromSourceBlockNumber() > 0) { - byte[] prev = blocksDao.getHashByNumber(ch.blockchainName(), (int)(ch.fromSourceBlockNumber() - 1)); - if (!Arrays.equals(prev, ch.previousSourceBlockHash())) { - throw new IllegalStateException("Frozen previous source hash изменился: " + ch.blockchainName()); - } - } - List range = blocksDao.listRangeByNumber( - ch.blockchainName(), (int)ch.fromSourceBlockNumber(), (int)ch.toSourceBlockNumber()); - if (range.isEmpty()) throw new IllegalStateException("Frozen range пуст: " + ch.blockchainName()); - byte[] last = range.get(range.size()-1).getBlockHash(); - if (!Arrays.equals(last, ch.lastSourceBlockHash())) { - throw new IllegalStateException("Frozen last source hash изменился: " + ch.blockchainName()); - } - records.put(ch.blockchainName(), range); - } finally { - lock.unlock(); - } - } - - List previous = archiveDao.listFinalizedBigBlocks(); - Path file = ArchiveFileNames.pendingFilePath( - cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone()); - ShineArchiveWriter.BuildResult result = writer.build( - file, cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), previous, - chains, records, archiveSigningSeed32, cfg.maxFileBytes()); - for (Map.Entry e : result.chunks().entrySet()) { - archiveDao.updateNewChunkPosition(job.id(), e.getKey(), e.getValue().offset(), e.getValue().size()); - } - archiveDao.markFileBuilt(job.id(), result.file().toString(), result.archiveHash()); - log.info("SHINE-ARCHIVE локально сохранён: block={} file={} bytes={} hash={}", - job.bigBlockNumber(), result.file(), result.fileSize(), hex(result.archiveHash())); - } - - private void uploadArweave(ArchivePublishJob job) throws Exception { - Path file = requireLocalFile(job); - byte[] verified = ShineArchiveWriter.verifyLocalFile(file); - if (!Arrays.equals(verified, job.archiveHash())) throw new IllegalStateException("Локальный archive hash отличается от job DB"); - ArweaveArchiveService.UploadResult upload = arweave.upload(file, cfg.serverLogin(), job.bigBlockNumber(), job.archiveHash()); - // Сначала надёжно фиксируем полученный TX ID в БД. Если процесс упадёт до rename, - // recovery на следующем шаге переименует тот же временный файл без повторной загрузки. - archiveDao.markArweaveUploaded(job.id(), upload.txIdBytes()); - Path uploadedFile = markLocalFileUploaded(job, file, upload.txIdBytes()); - log.info("SHINE-ARCHIVE загружен в Arweave: block={} tx={} file={}", - job.bigBlockNumber(), upload.txId(), uploadedFile); - } - - private void waitArweave(ArchivePublishJob job) throws Exception { - ensureUploadedLocalName(job); - String tx = b64url(job.arweaveTxId()); - int confirmations = arweave.waitForConfirmations(tx); - archiveDao.markArweaveConfirmed(job.id(), confirmations); - log.info("Arweave archive закреплён: block={} tx={} confirmations={}", job.bigBlockNumber(), tx, confirmations); - } - - - /** После успешного Arweave upload временный файл получает реальный TX ID в имени и остаётся локально. */ - private Path markLocalFileUploaded(ArchivePublishJob job, Path currentFile, byte[] txId) throws Exception { - require32(txId, "arweave tx id"); - Path target = ArchiveFileNames.uploadedFilePath( - cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), txId); - Files.createDirectories(target.toAbsolutePath().getParent()); - if (!currentFile.equals(target)) { - try { - Files.move(currentFile, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException e) { - Files.move(currentFile, target, StandardCopyOption.REPLACE_EXISTING); - } - } - archiveDao.updateLocalArchivePath(job.id(), target.toString()); - return target; - } - - /** Crash recovery: TX уже сохранён, а временный файл ещё не успели переименовать. */ - private void ensureUploadedLocalName(ArchivePublishJob job) throws Exception { - require32(job.arweaveTxId(), "job.arweave_tx_id"); - Path target = ArchiveFileNames.uploadedFilePath( - cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), job.arweaveTxId()); - if (Files.isRegularFile(target)) { - if (!target.toString().equals(job.localArchivePath())) { - archiveDao.updateLocalArchivePath(job.id(), target.toString()); - } - return; - } - if (job.localArchivePath() == null || job.localArchivePath().isBlank()) return; - Path current = Path.of(job.localArchivePath()); - if (Files.isRegularFile(current)) { - markLocalFileUploaded(job, current, job.arweaveTxId()); - } - } - - private void submitSolana(ArchivePublishJob job) throws Exception { - require32(job.arweaveTxId(), "job.arweave_tx_id"); - require32(job.archiveHash(), "job.archive_hash"); - SolanaArchiveHeadWriter.SubmitResult submitted = solana.submitArchiveHead(job.arweaveTxId(), job.archiveHash()); - archiveDao.markSolanaSubmitted(job.id(), submitted.signature()); - } - - private void waitSolana(ArchivePublishJob job) throws Exception { - if (job.solanaSignature() == null || job.solanaSignature().isBlank()) throw new IllegalStateException("SOLANA_SUBMITTED без signature"); - solana.waitFinalized(job.solanaSignature()); - if (!solana.currentHeadMatches(job.arweaveTxId(), job.archiveHash())) { - throw new IllegalStateException("После Solana finalized User PDA archive head не совпадает с job"); - } - archiveDao.markSolanaFinalized(job.id()); - log.info("Solana archive head finalized: block={} signature={}", job.bigBlockNumber(), job.solanaSignature()); - } - - private void commitCursors(ArchivePublishJob job) throws Exception { - archiveDao.commitCursors(job.id(), job.archiveHash()); - - // Тот же универсальный индекс используется и для собственных опубликованных архивов. - // Поэтому UI publisher-сервера получает ссылку даже если он не импортирует самого себя через whitelist. - String txId = b64url(job.arweaveTxId()); - long now = System.currentTimeMillis(); - for (ArchivePublishJobChain ch : archiveDao.listJobChains(job.id())) { - if (ch.newChunkOffset() == null || ch.newChunkSize() == null) continue; - archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation( - ch.blockchainName(), cfg.serverLogin(), txId, job.archiveHash(), job.bigBlockNumber(), - ch.newChunkOffset(), ch.newChunkSize(), ch.toSourceBlockNumber(), now)); - } - } - - private static Path requireLocalFile(ArchivePublishJob job) { - if (job.localArchivePath() == null || job.localArchivePath().isBlank()) throw new IllegalStateException("FILE_BUILT без local_archive_path"); - Path p = Path.of(job.localArchivePath()); - if (!Files.isRegularFile(p)) throw new IllegalStateException("Локальный archive-файл отсутствует: " + p); - return p; - } - - private static void require32(byte[] v, String name) { - if (v == null || v.length != 32) throw new IllegalStateException(name + " должен быть 32 bytes"); - } - private static String b64url(byte[] v) { return v == null ? "" : Base64.getUrlEncoder().withoutPadding().encodeToString(v); } - private static String hex(byte[] b){StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();} -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java deleted file mode 100644 index 2711d4c7..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java +++ /dev/null @@ -1,259 +0,0 @@ -package server.archive; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -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.*; -import java.security.spec.MGF1ParameterSpec; -import java.security.spec.PSSParameterSpec; -import java.security.spec.RSAPrivateCrtKeySpec; -import java.time.Duration; -import java.util.*; - -/** - * Публикация готовых локальных SHINE-ARCHIVE файлов в Arweave v2. - * Поддерживает стандартный data Merkle tree и загрузку через /chunk. - */ -public final class ArweaveArchiveService { - private static final Logger log = LoggerFactory.getLogger(ArweaveArchiveService.class); - private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding(); - private static final Base64.Decoder B64D = Base64.getUrlDecoder(); - - private final ArchivePublisherConfig cfg; - private final HttpClient http; - private volatile Wallet wallet; - - public ArweaveArchiveService(ArchivePublisherConfig cfg) { - this.cfg = Objects.requireNonNull(cfg); - this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); - } - - public record UploadResult(String txId, byte[] txIdBytes, String gateway) {} - - public UploadResult upload(Path archiveFile, String publisherLogin, long bigBlockNumber, byte[] archiveHash) - throws Exception { - long dataSize = Files.size(archiveFile); - if (dataSize <= 0) throw new IllegalArgumentException("Пустой SHINE-ARCHIVE"); - Wallet w = wallet(); - String gateway = trimGateway(cfg.arweaveGateway()); - - ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(archiveFile); - String anchor = getRequiredText(gateway, "/tx_anchor"); - String reward = getRequiredText(gateway, "/price/" + dataSize); - if (!reward.matches("^\\d+$")) throw new IOException("Arweave вернул некорректную цену: " + reward); - verifyBalance(gateway, w.address(), reward); - - List> tagList = new ArrayList<>(); - List> jsonTags = new ArrayList<>(); - addTag(jsonTags, tagList, "Content-Type", "application/octet-stream"); - addTag(jsonTags, tagList, "App-Name", "SHiNE"); - addTag(jsonTags, tagList, "SHiNE-Type", "archive"); - addTag(jsonTags, tagList, "SHiNE-Archive-Version", "1.0"); - addTag(jsonTags, tagList, "SHiNE-Publisher", publisherLogin); - addTag(jsonTags, tagList, "SHiNE-Big-Block", Long.toUnsignedString(bigBlockNumber)); - addTag(jsonTags, tagList, "SHiNE-Archive-SHA256", hex(archiveHash)); - - byte[] signaturePayload = deepHash(List.of( - utf8("2"), - b64Decode(w.owner()), - new byte[0], - utf8("0"), - utf8(reward), - b64Decode(anchor), - tagList, - utf8(Long.toString(dataSize)), - merkle.dataRoot() - )); - byte[] rawSignature = signPayload(w.privateKey(), signaturePayload); - byte[] txIdBytes = sha256(rawSignature); - String txId = b64(txIdBytes); - - Map tx = new LinkedHashMap<>(); - tx.put("format", 2); - tx.put("id", txId); - tx.put("last_tx", anchor); - tx.put("owner", w.owner()); - tx.put("tags", jsonTags); - tx.put("target", ""); - tx.put("quantity", "0"); - tx.put("data_root", b64(merkle.dataRoot())); - tx.put("data_size", Long.toString(dataSize)); - tx.put("data", ""); - tx.put("reward", reward); - tx.put("signature", b64(rawSignature)); - - postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60)); - log.info("Arweave tx header принят: tx={}, chunks={}, bytes={}", txId, merkle.chunks().size(), dataSize); - - for (int i=0; i body = new LinkedHashMap<>(); - body.put("data_root", b64(merkle.dataRoot())); - body.put("data_size", Long.toString(dataSize)); - body.put("data_path", b64(proof.proof())); - body.put("offset", Long.toString(proof.offset())); - body.put("chunk", b64(chunk)); - postChunkWithRetry(gateway, body, i, merkle.chunks().size()); - } - return new UploadResult(txId, txIdBytes, gateway); - } - - /** Ждёт включения tx в блок и заданного количества подтверждений. */ - public int waitForConfirmations(String txId) throws Exception { - if (cfg.arweaveMinConfirmations() <= 0) return 0; - String gateway = trimGateway(cfg.arweaveGateway()); - long deadline = System.currentTimeMillis() + cfg.arweaveConfirmTimeoutMinutes() * 60_000L; - int last = 0; - while (System.currentTimeMillis() < deadline) { - HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx/" + txId + "/status")) - .timeout(Duration.ofSeconds(20)).GET().build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (resp.statusCode() == 200) { - JsonNode json = MAPPER.readTree(resp.body()); - last = json.path("number_of_confirmations").asInt(0); - if (last >= cfg.arweaveMinConfirmations()) return last; - } else if (resp.statusCode() != 202 && resp.statusCode() != 404) { - log.warn("Arweave status tx={} HTTP {}: {}", txId, resp.statusCode(), safe(resp.body())); - } - Thread.sleep(cfg.arweaveConfirmPollSeconds() * 1000L); - } - throw new IOException("Истёк timeout ожидания Arweave confirmations для " + txId + ", last=" + last); - } - - private void postChunkWithRetry(String gateway, Map body, int index, int total) throws Exception { - Exception last = null; - for (int attempt=1; attempt<=12; attempt++) { - try { - postJsonExpect2xx(gateway + "/chunk", body, Duration.ofSeconds(60)); - if ((index + 1) % 25 == 0 || index + 1 == total) { - log.info("Arweave chunks: {}/{}", index + 1, total); - } - return; - } catch (Exception e) { - last = e; - if (attempt == 12) break; - log.warn("Arweave chunk {}/{} attempt {}/12: {}", index + 1, total, attempt, e.getMessage()); - Thread.sleep(5_000L); - } - } - throw new IOException("Не удалось загрузить Arweave chunk " + (index + 1) + "/" + total, last); - } - - private void postJsonExpect2xx(String url, Object body, Duration timeout) throws Exception { - String json = MAPPER.writeValueAsString(body); - HttpRequest req = HttpRequest.newBuilder(URI.create(url)) - .timeout(timeout) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) - .build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (resp.statusCode() < 200 || resp.statusCode() >= 300) { - throw new IOException("HTTP " + resp.statusCode() + " " + safe(resp.body())); - } - } - - private void verifyBalance(String gateway, String address, String reward) throws Exception { - if (address == null || address.isBlank()) return; - String balance = getRequiredText(gateway, "/wallet/" + address + "/balance"); - if (balance.matches("^\\d+$") && new BigInteger(balance).compareTo(new BigInteger(reward)) < 0) { - throw new IllegalStateException("Недостаточно AR: balance=" + balance + ", reward=" + reward); - } - } - - private String getRequiredText(String gateway, String path) throws Exception { - HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path)).timeout(Duration.ofSeconds(20)).GET().build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (resp.statusCode() < 200 || resp.statusCode() >= 300) { - throw new IOException("Arweave HTTP " + resp.statusCode() + " для " + path + ": " + safe(resp.body())); - } - String s = resp.body() == null ? "" : resp.body().trim(); - if (s.isBlank()) throw new IOException("Пустой Arweave ответ для " + path); - return s; - } - - private Wallet wallet() throws Exception { - Wallet local = wallet; - if (local != null) return local; - synchronized (this) { - if (wallet != null) return wallet; - JsonNode jwk = MAPPER.readTree(Files.readString(cfg.arweaveWalletJwkPath(), StandardCharsets.UTF_8)); - String owner = required(jwk, "n"); - PrivateKey pk = buildPrivateKey(jwk); - String address = b64(sha256(b64Decode(owner))); - wallet = new Wallet(owner, address, pk); - return wallet; - } - } - - private static PrivateKey buildPrivateKey(JsonNode jwk) throws GeneralSecurityException { - RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec( - big(required(jwk,"n")), big(required(jwk,"e")), big(required(jwk,"d")), - big(required(jwk,"p")), big(required(jwk,"q")), big(required(jwk,"dp")), - big(required(jwk,"dq")), big(required(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[] 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))); - return sha384(concat(sha384(tag), sha384(bytes))); - } - - private static byte[] deepHashChunks(List list, byte[] acc) throws GeneralSecurityException { - byte[] cur = acc; - for (Object item : list) cur = sha384(concat(cur, deepHash(item))); - return cur; - } - - private static void addTag(List> json, List> raw, String name, String value) { - byte[] n=utf8(name), v=utf8(value); - Map item = new LinkedHashMap<>(); - item.put("name", b64(n)); item.put("value", b64(v)); - json.add(item); raw.add(List.of(n,v)); - } - - private static String required(JsonNode node, String field) { - String s = node == null ? "" : node.path(field).asText("").trim(); - if (s.isBlank()) throw new IllegalStateException("В Arweave JWK отсутствует " + field); - return s; - } - private static BigInteger big(String s) { return new BigInteger(1, b64Decode(s)); } - private static String trimGateway(String s) { return (s == null ? "https://arweave.net" : s.trim()).replaceAll("/+$", ""); } - private static byte[] utf8(String s) { return String.valueOf(s == null ? "" : s).getBytes(StandardCharsets.UTF_8); } - private static byte[] b64Decode(String s) { return B64D.decode(s); } - private static String b64(byte[] b) { return B64.encodeToString(b); } - private static byte[] sha256(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-256").digest(b); } - private static byte[] sha384(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-384").digest(b); } - private static String hex(byte[] b) { StringBuilder s=new StringBuilder(); for(byte x:b)s.append(String.format("%02x",x)); return s.toString(); } - private static byte[] concat(byte[]... arrays) { int n=0; for(byte[] a:arrays)n=Math.addExact(n,a.length); byte[] o=new byte[n]; int p=0; for(byte[]a:arrays){System.arraycopy(a,0,o,p,a.length);p+=a.length;} return o; } - private static String safe(String s) { s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim(); return s.length()<=250?s:s.substring(0,250)+"..."; } - - private record Wallet(String owner, String address, PrivateKey privateKey) {} -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java new file mode 100644 index 00000000..4f592ea1 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java @@ -0,0 +1,37 @@ +package server.archive; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Fixed-delay publisher. Empty cycles are no-ops. */ +public final class ArweaveBlockPublisherScheduler { + private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherScheduler.class); + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static ScheduledExecutorService executor; + + private ArweaveBlockPublisherScheduler() {} + + public static void startOrLog() { + ArweaveBlocksConfig cfg; + try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); } + catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; } + if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled"); return; } + if (!STARTED.compareAndSet(false,true)) return; + try { + ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg); + executor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "shine-arweave-block-publisher"); t.setDaemon(true); return t; + }); + Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } }; + executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES); + log.info("Arweave user-block publisher enabled: interval={}m gateway={}", cfg.publishIntervalMinutes(), cfg.publishGateway()); + } catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); } + } + + public static void close() { ScheduledExecutorService e=executor; if(e!=null)e.shutdownNow(); } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java new file mode 100644 index 00000000..0a314b63 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java @@ -0,0 +1,59 @@ +package server.archive; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import shine.db.dao.BlocksDAO; +import shine.db.entities.BlockEntry; + +import java.util.ArrayList; +import java.util.List; + +/** Publishes locally-created signed user DataItems as one standard ANS-104 bundle. */ +public final class ArweaveBlockPublisherService { + private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class); + + private final ArweaveBlocksConfig cfg; + private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); + private final ArweaveL1Uploader uploader; + + public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) { + this.cfg = cfg; + this.uploader = new ArweaveL1Uploader(cfg); + } + + public int runCycle() throws Exception { + List candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems()); + if (candidates.isEmpty()) return 0; + + List items = new ArrayList<>(); + List ids = new ArrayList<>(); + long estimated = 32; + for (BlockEntry e : candidates) { + byte[] raw = e.getBlockBytes(); + byte[] id = e.getDataItemId(); + if (raw == null || raw.length == 0 || id == null || id.length != 32) continue; + long next = estimated + 64L + raw.length; + if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break; + if (next > cfg.publishMaxBundleBytes()) { + log.error("One DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}", + e.getBchName(), e.getBlockNumber(), raw.length); + continue; + } + items.add(raw); ids.add(id); estimated = next; + } + if (items.isEmpty()) return 0; + + byte[] bundle = Ans104Bundle.write(items); + List rootTags = List.of( + new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"), + new ArweaveL1Uploader.Tag("Bundle-Format", "binary"), + new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0"), + // Deliberately different from App=test5590 so discovery returns child DataItems only. + new ArweaveL1Uploader.Tag("App", "test5590-batch") + ); + ArweaveL1Uploader.UploadResult result = uploader.upload(bundle, rootTags); + blocksDAO.markArweavePublished(ids, result.txId(), System.currentTimeMillis()); + log.info("Published {} SHiNE test DataItems in root tx {} (bundle={} bytes)", items.size(), result.txId(), bundle.length); + return items.size(); + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java new file mode 100644 index 00000000..b5c1369b --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java @@ -0,0 +1,32 @@ +package server.archive; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class ArweaveBlockSyncScheduler { + private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncScheduler.class); + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static ScheduledExecutorService executor; + private ArweaveBlockSyncScheduler() {} + + public static void startOrLog() { + ArweaveBlocksConfig cfg; + try { cfg = ArweaveBlocksConfig.load(); } + catch (Exception e) { log.error("Cannot read Arweave block sync config", e); return; } + if (!cfg.syncEnabled()) { log.info("Arweave user-block sync disabled"); return; } + if (!STARTED.compareAndSet(false,true)) return; + try { + ArweaveBlockSyncService service = new ArweaveBlockSyncService(cfg); + executor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t=new Thread(r,"shine-arweave-block-sync");t.setDaemon(true);return t; }); + Runnable task=()->{try{service.runCycle();}catch(Exception e){log.error("Arweave block sync cycle failed",e);}}; + executor.scheduleWithFixedDelay(task,0,cfg.syncIntervalMinutes(),TimeUnit.MINUTES); + log.info("Arweave user-block sync enabled: interval={}m gateway={}",cfg.syncIntervalMinutes(),cfg.syncGateway()); + } catch (Exception e) { STARTED.set(false); log.error("Arweave user-block sync failed to start",e); } + } + public static void close(){ScheduledExecutorService e=executor;if(e!=null)e.shutdownNow();} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java new file mode 100644 index 00000000..2d357050 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java @@ -0,0 +1,185 @@ +package server.archive; + +import blockchain.Ans104DataItem; +import blockchain.BchBlockEntry; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; +import shine.db.dao.ArweaveBlockImportDAO; +import shine.db.dao.BlockchainStateDAO; +import shine.db.dao.BlocksDAO; +import shine.db.dao.SolanaUserPdaCurrentDAO; +import shine.db.entities.BlockchainStateEntry; +import shine.db.entities.SolanaUserPdaCurrentEntry; + +import java.io.IOException; +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.time.Duration; +import java.util.*; + +/** + * Discovers App=test5590 child DataItems, extracts their exact serialized bytes + * from the root ANS-104 bundle and imports them through the normal AddBlock checks. + */ +public final class ArweaveBlockSyncService { + private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Base64.Decoder B64URL = Base64.getUrlDecoder(); + + private final ArweaveBlocksConfig cfg; + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); + private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance(); + private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); + private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance(); + private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); + private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler(); + + public ArweaveBlockSyncService(ArweaveBlocksConfig cfg) { this.cfg = cfg; } + + public void runCycle() throws Exception { + discover(); + drainQueue(); + } + + private void discover() throws Exception { + long stored = importDAO.getLastBlockHeight(); + long minHeight = Math.max(cfg.syncStartBlockHeight(), stored); + String cursor = null; + long maxHeight = minHeight; + Map rootCache = new HashMap<>(); + int discovered = 0; + + do { + JsonNode response = graphQlPage(minHeight, cursor); + JsonNode txs = response.path("data").path("transactions"); + if (response.has("errors")) throw new IOException("Arweave GraphQL errors: " + response.path("errors")); + JsonNode edges = txs.path("edges"); + if (!edges.isArray()) throw new IOException("Arweave GraphQL returned no transactions.edges"); + + String nextCursor = null; + for (JsonNode edge : edges) { + nextCursor = edge.path("cursor").asText(null); + JsonNode node = edge.path("node"); + String itemIdText = node.path("id").asText("").trim(); + String rootTx = node.path("bundledIn").path("id").asText("").trim(); + long height = node.path("block").path("height").asLong(-1L); + if (itemIdText.isBlank() || rootTx.isBlank() || height < 0) continue; + maxHeight = Math.max(maxHeight, height); + byte[] id32; + try { id32 = B64URL.decode(itemIdText); } + catch (IllegalArgumentException bad) { continue; } + if (id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue; + + byte[] bundle = rootCache.computeIfAbsent(rootTx, key -> { + try { return downloadRootBundle(key); } + catch (Exception e) { throw new RootDownloadRuntimeException(e); } + }); + byte[] raw = Ans104Bundle.find(bundle, id32, Math.max(cfg.publishMaxItems() * 4, 100_000)); + if (raw == null) throw new IOException("DataItem " + itemIdText + " not found in root bundle " + rootTx); + Ans104DataItem parsed = new Ans104DataItem(raw); + if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue; + if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature for " + itemIdText); + importDAO.enqueueIfMissing(id32, rootTx, height, raw, System.currentTimeMillis()); + discovered++; + } + boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false); + cursor = hasNext ? nextCursor : null; + if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor"); + } while (cursor != null); + + // Keep one-height overlap: the next query includes this height and deduplicates IDs. + importDAO.setLastBlockHeight(maxHeight, System.currentTimeMillis()); + if (discovered > 0) log.info("Arweave discovery queued {} new SHiNE test DataItems through height {}", discovered, maxHeight); + } + + private void drainQueue() throws Exception { + int passes = 0; + int importedTotal = 0; + boolean progress; + do { + progress = false; + List pending = importDAO.listPending(cfg.syncQueueBatchSize()); + if (pending.isEmpty()) break; + for (ArweaveBlockImportDAO.QueueItem q : pending) { + Ans104DataItem item; + BchBlockEntry block; + try { + item = new Ans104DataItem(q.rawDataItem()); + if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch"); + if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag"); + if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature"); + block = new BchBlockEntry(q.rawDataItem()); + } catch (Exception e) { + importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis()); + continue; + } + + SolanaUserPdaCurrentEntry user = usersDAO.getByBlockchainKey(item.owner32()); + if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) { + importDAO.setPendingError(q.dataItemId(), "unknown_owner", System.currentTimeMillis()); + continue; + } + ensureState(user); + Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(user.getBlockchainName(), q.rawDataItem()); + if (result.ok()) { + importDAO.delete(q.dataItemId()); + importedTotal++; progress = true; + continue; + } + String reason = result.reasonCode(); + if ("bad_block_number".equals(reason) || "bad_prev_hash".equals(reason) + || "prev_line_block_not_found".equals(reason) || "channel_not_found".equals(reason) + || "chain_resync_in_progress".equals(reason)) { + importDAO.setPendingError(q.dataItemId(), reason, System.currentTimeMillis()); + } else { + importDAO.reject(q.dataItemId(), reason, System.currentTimeMillis()); + } + } + passes++; + } while (progress && passes < 8); + if (importedTotal > 0) log.info("Imported {} SHiNE test DataItems from Arweave", importedTotal); + } + + private void ensureState(SolanaUserPdaCurrentEntry user) throws Exception { + BlockchainStateEntry existing = stateDAO.getByBlockchainName(user.getBlockchainName()); + if (existing != null) return; + BlockchainStateEntry s = new BlockchainStateEntry(); + s.setBlockchainName(user.getBlockchainName()); s.setLogin(user.getLogin()); s.setBlockchainKey(user.getBlockchainKey()); + s.setSizeLimit(user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L); + s.setFileSizeBytes(0L); s.setLastBlockNumber(-1); s.setLastBlockHash(null); s.setUpdatedAtMs(System.currentTimeMillis()); + stateDAO.insertIfMissing(s); + } + + private JsonNode graphQlPage(long minHeight, String cursor) throws Exception { + String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\""; + String query = "query { transactions(tags:[{name:\"App\",values:[\"test5590\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id bundledIn { id } block { height } } } } }"; + String body = MAPPER.writeValueAsString(Map.of("query", query)); + HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql")) + .timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json") + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("GraphQL HTTP " + resp.statusCode()); + return MAPPER.readTree(resp.body()); + } + + private byte[] downloadRootBundle(String txId) throws Exception { + HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/" + txId)) + .timeout(Duration.ofMinutes(5)).GET().build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray()); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Arweave root HTTP " + resp.statusCode() + " tx=" + txId); + byte[] body = resp.body(); + if (body == null || body.length == 0) throw new IOException("Empty Arweave root bundle " + txId); + if (body.length > cfg.syncMaxRootBundleBytes()) throw new IOException("Root bundle exceeds syncMaxRootBundleBytes: " + body.length); + return body; + } + + private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");} + private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");} + private static final class RootDownloadRuntimeException extends RuntimeException { RootDownloadRuntimeException(Throwable cause){super(cause);} } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java new file mode 100644 index 00000000..116be97f --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java @@ -0,0 +1,66 @@ +package server.archive; + +import utils.config.AppConfig; + +import java.nio.file.Path; + +/** Configuration of the new per-user-block ANS-104 Arweave transport. */ +public record ArweaveBlocksConfig( + boolean publishEnabled, + int publishIntervalMinutes, + int publishMaxItems, + long publishMaxBundleBytes, + String publishGateway, + Path walletJwkPath, + int minConfirmations, + int confirmPollSeconds, + int confirmTimeoutMinutes, + boolean syncEnabled, + int syncIntervalMinutes, + int syncPageSize, + int syncQueueBatchSize, + long syncStartBlockHeight, + long syncMaxRootBundleBytes, + String syncGateway +) { + public static final String TEST_TAG_NAME = "App"; + public static final String TEST_TAG_VALUE = "test5590"; + public static final String CHANNEL_TAG_NAME = "c"; + + public static ArweaveBlocksConfig load() { + AppConfig c = AppConfig.getInstance(); + return new ArweaveBlocksConfig( + c.getBoolean("arweave.blocks.publish.enabled", false), + positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"), + positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"), + positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"), + orDefault(c.getParam("arweave.blocks.publish.gateway"), "https://arweave.net"), + optionalPath(c.getParam("arweave.blocks.publish.walletJwkPath")), + nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"), + positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"), + positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"), + c.getBoolean("arweave.blocks.sync.enabled", false), + positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"), + clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100), + positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"), + nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"), + positiveLong(parseLong(c.getParam("arweave.blocks.sync.maxRootBundleBytes"), 256L * 1024 * 1024), "sync.maxRootBundleBytes"), + orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com") + ); + } + + public void validatePublisher() { + if (publishEnabled && walletJwkPath == null) { + throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required when publisher is enabled"); + } + } + + private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); } + private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); } + private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); } + private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;} + private static int nonNegative(int v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;} + private static long positiveLong(long v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;} + private static long nonNegativeLong(long v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;} + private static int clamp(int v,int min,int max){return Math.max(min,Math.min(max,v));} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java new file mode 100644 index 00000000..db5ca59e --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java @@ -0,0 +1,141 @@ +package server.archive; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.security.*; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; +import java.security.spec.RSAPrivateCrtKeySpec; +import java.time.Duration; +import java.util.*; + +/** Existing native Arweave L1 transport adapted to upload in-memory ANS-104 bundles. */ +public final class ArweaveL1Uploader { + private static final Logger log = LoggerFactory.getLogger(ArweaveL1Uploader.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder B64D = Base64.getUrlDecoder(); + + public record Tag(String name, String value) {} + public record UploadResult(String txId, byte[] txIdBytes) {} + + private final ArweaveBlocksConfig cfg; + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); + private volatile Wallet wallet; + + public ArweaveL1Uploader(ArweaveBlocksConfig cfg) { this.cfg = Objects.requireNonNull(cfg); } + + public UploadResult upload(byte[] data, List tags) throws Exception { + if (data == null || data.length == 0) throw new IllegalArgumentException("Arweave upload data is empty"); + Wallet w = wallet(); + String gateway = trimGateway(cfg.publishGateway()); + ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(data); + String anchor = getRequiredText(gateway, "/tx_anchor"); + String reward = getRequiredText(gateway, "/price/" + data.length); + if (!reward.matches("^\\d+$")) throw new IOException("Bad Arweave price: " + reward); + verifyBalance(gateway, w.address(), reward); + + List> tagList = new ArrayList<>(); + List> jsonTags = new ArrayList<>(); + for (Tag tag : tags == null ? List.of() : tags) addTag(jsonTags, tagList, tag.name(), tag.value()); + + byte[] signaturePayload = deepHash(List.of( + utf8("2"), b64Decode(w.owner()), new byte[0], utf8("0"), utf8(reward), + b64Decode(anchor), tagList, utf8(Integer.toString(data.length)), merkle.dataRoot() + )); + byte[] rawSignature = signPayload(w.privateKey(), signaturePayload); + byte[] txIdBytes = sha256(rawSignature); + String txId = b64(txIdBytes); + + Map tx = new LinkedHashMap<>(); + tx.put("format",2); tx.put("id",txId); tx.put("last_tx",anchor); tx.put("owner",w.owner()); + tx.put("tags",jsonTags); tx.put("target",""); tx.put("quantity","0"); + tx.put("data_root",b64(merkle.dataRoot())); tx.put("data_size",Integer.toString(data.length)); tx.put("data",""); + tx.put("reward",reward); tx.put("signature",b64(rawSignature)); + postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60)); + + for (int i=0;i body=new LinkedHashMap<>(); + body.put("data_root",b64(merkle.dataRoot())); body.put("data_size",Integer.toString(data.length)); + body.put("data_path",b64(proof.proof())); body.put("offset",Long.toString(proof.offset())); + body.put("chunk",b64(ArweaveMerkle.readChunk(data,meta))); + postChunkWithRetry(gateway,body,i,merkle.chunks().size()); + } + if (cfg.minConfirmations() > 0) waitForConfirmations(txId); + log.info("ANS-104 bundle uploaded to Arweave: tx={} bytes={} items-transported",txId,data.length); + return new UploadResult(txId,txIdBytes); + } + + private int waitForConfirmations(String txId) throws Exception { + String gateway=trimGateway(cfg.publishGateway()); + long deadline=System.currentTimeMillis()+cfg.confirmTimeoutMinutes()*60_000L; int last=0; + while(System.currentTimeMillis() resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if(resp.statusCode()==200){JsonNode j=MAPPER.readTree(resp.body());last=j.path("number_of_confirmations").asInt(0);if(last>=cfg.minConfirmations())return last;} + else if(resp.statusCode()!=202&&resp.statusCode()!=404)log.warn("Arweave status tx={} HTTP {}",txId,resp.statusCode()); + Thread.sleep(cfg.confirmPollSeconds()*1000L); + } + throw new IOException("Arweave confirmation timeout for "+txId+", last="+last); + } + + private void postChunkWithRetry(String gateway,Map body,int index,int total)throws Exception{ + Exception last=null; + for(int attempt=1;attempt<=12;attempt++)try{postJsonExpect2xx(gateway+"/chunk",body,Duration.ofSeconds(60));return;}catch(Exception e){last=e;if(attempt==12)break;Thread.sleep(5000L);} + throw new IOException("Failed Arweave chunk "+(index+1)+"/"+total,last); + } + private void postJsonExpect2xx(String url,Object body,Duration timeout)throws Exception{ + HttpRequest req=HttpRequest.newBuilder(URI.create(url)).timeout(timeout).header("Content-Type","application/json").header("Accept","application/json") + .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body),StandardCharsets.UTF_8)).build(); + HttpResponse resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("HTTP "+resp.statusCode()+" "+safe(resp.body())); + } + private void verifyBalance(String gateway,String address,String reward)throws Exception{ + String balance=getRequiredText(gateway,"/wallet/"+address+"/balance"); + if(balance.matches("^\\d+$")&&new BigInteger(balance).compareTo(new BigInteger(reward))<0)throw new IllegalStateException("Not enough AR: balance="+balance+" reward="+reward); + } + private String getRequiredText(String gateway,String path)throws Exception{ + HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+path)).timeout(Duration.ofSeconds(20)).GET().build(); + HttpResponse resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("Arweave HTTP "+resp.statusCode()+" for "+path+": "+safe(resp.body())); + String s=resp.body()==null?"":resp.body().trim();if(s.isBlank())throw new IOException("Empty Arweave response for "+path);return s; + } + private Wallet wallet()throws Exception{ + Wallet local=wallet;if(local!=null)return local;synchronized(this){if(wallet!=null)return wallet; + JsonNode jwk=MAPPER.readTree(Files.readString(cfg.walletJwkPath(),StandardCharsets.UTF_8));String owner=required(jwk,"n"); + PrivateKey pk=buildPrivateKey(jwk);String address=b64(sha256(b64Decode(owner)));return wallet=new Wallet(owner,address,pk);} + } + private static PrivateKey buildPrivateKey(JsonNode jwk)throws GeneralSecurityException{ + RSAPrivateCrtKeySpec spec=new RSAPrivateCrtKeySpec(big(required(jwk,"n")),big(required(jwk,"e")),big(required(jwk,"d")),big(required(jwk,"p")),big(required(jwk,"q")),big(required(jwk,"dp")),big(required(jwk,"dq")),big(required(jwk,"qi"))); + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } + private static byte[] signPayload(PrivateKey key,byte[] payload)throws GeneralSecurityException{ + Signature s=Signature.getInstance("RSASSA-PSS");s.setParameter(new PSSParameterSpec("SHA-256","MGF1",MGF1ParameterSpec.SHA256,32,1));s.initSign(key);s.update(payload);return s.sign(); + } + private static byte[] deepHash(Object data)throws GeneralSecurityException{ + if(data instanceof List list){byte[] acc=sha384(utf8("list"+list.size()));for(Object item:list)acc=sha384(concat(acc,deepHash(item)));return acc;} + if(!(data instanceof byte[] bytes))throw new IllegalArgumentException("deepHash byte[]/List expected");return sha384(concat(sha384(utf8("blob"+bytes.length)),sha384(bytes))); + } + private static void addTag(List> json,List> raw,String name,String value){byte[]n=utf8(name),v=utf8(value);Mapm=new LinkedHashMap<>();m.put("name",b64(n));m.put("value",b64(v));json.add(m);raw.add(List.of(n,v));} + private static String required(JsonNode n,String f){String s=n==null?"":n.path(f).asText("").trim();if(s.isBlank())throw new IllegalStateException("Arweave JWK missing "+f);return s;} + private static BigInteger big(String s){return new BigInteger(1,b64Decode(s));} + private static String trimGateway(String s){return(s==null?"https://arweave.net":s.trim()).replaceAll("/+$","");} + private static byte[] utf8(String s){return String.valueOf(s==null?"":s).getBytes(StandardCharsets.UTF_8);} + private static byte[] b64Decode(String s){return B64D.decode(s);}private static String b64(byte[]b){return B64.encodeToString(b);} + private static byte[] sha256(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-256").digest(b);}private static byte[] sha384(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-384").digest(b);} + private static byte[] concat(byte[]...a){int n=0;for(byte[]x:a)n=Math.addExact(n,x.length);byte[]o=new byte[n];int p=0;for(byte[]x:a){System.arraycopy(x,0,o,p,x.length);p+=x.length;}return o;} + private static String safe(String s){s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return s.length()<=250?s:s.substring(0,250)+"...";} + private record Wallet(String owner,String address,PrivateKey privateKey){} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java index a33c81ef..98b688cf 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java @@ -29,6 +29,55 @@ final class ArweaveMerkle { private ArweaveMerkle() {} + + static Prepared prepare(byte[] data) throws IOException { + if (data == null || data.length == 0) throw new IOException("Arweave data is empty"); + List chunks = chunkBytes(data); + List leaves = new ArrayList<>(chunks.size()); + for (Chunk c : chunks) { + byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange())))); + leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange())); + } + Node root = buildLayers(leaves); + List proofs = new ArrayList<>(); + resolveProofs(root, new byte[0], proofs); + if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) { + chunks = new ArrayList<>(chunks); + chunks.remove(chunks.size() - 1); + proofs.remove(proofs.size() - 1); + } + if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch"); + return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs)); + } + + private static List chunkBytes(byte[] data) { + List out = new ArrayList<>(); + long remaining = data.length; + long cursor = 0; + int p = 0; + while (remaining >= MAX_CHUNK_SIZE) { + long chunkSize = MAX_CHUNK_SIZE; + long next = remaining - MAX_CHUNK_SIZE; + if (next > 0 && next < MIN_CHUNK_SIZE) chunkSize = (remaining + 1) / 2; + byte[] chunk = Arrays.copyOfRange(data, p, p + (int) chunkSize); + out.add(new Chunk(sha256(chunk), cursor, cursor + chunkSize)); + p += (int) chunkSize; + cursor += chunkSize; + remaining -= chunkSize; + } + byte[] rest = Arrays.copyOfRange(data, p, p + (int) remaining); + out.add(new Chunk(sha256(rest), cursor, cursor + remaining)); + return out; + } + + static byte[] readChunk(byte[] data, Chunk c) throws IOException { + long len = c.size(); + if (data == null || len < 0 || len > Integer.MAX_VALUE || c.minByteRange() < 0 || c.maxByteRange() > data.length) { + throw new IOException("Invalid Arweave chunk range"); + } + return Arrays.copyOfRange(data, (int) c.minByteRange(), (int) c.maxByteRange()); + } + static Prepared prepare(Path file) throws IOException { long fileSize = Files.size(file); List chunks = chunkFile(file, fileSize); diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java deleted file mode 100644 index 9d705429..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java +++ /dev/null @@ -1,101 +0,0 @@ -package server.archive; - -import sync.util.Base58Util; -import utils.crypto.Ed25519Util; - -import java.io.*; -import java.nio.charset.StandardCharsets; -import java.nio.file.*; -import java.security.MessageDigest; -import java.util.*; - -/** Строгий reader/verifier формата SHINE-ARCHIVE v1.0. */ -public final class ShineArchiveReader { - public record Reference(long bigBlockNumber, byte[] archiveHash, String arweaveTxId) {} - public record Chunk(long offset, long size, String blockchainName, List rawRecords, - long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {} - public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin, - List references, List chunks, byte[] archiveHash) {} - - public Document readAndVerify(Path file, String expectedPublisherLogin, byte[] expectedHash, String rootPublicKeyBase58) throws Exception { - long fileSize = Files.size(file); - if (fileSize < 13 + 38 + 1 + 32 + 64) throw new IOException("SHINE-ARCHIVE слишком короткий"); - if (fileSize >= 0x1_0000_0000L) throw new IOException("SHINE-ARCHIVE v1 >= 4 GiB не поддерживается"); - - byte[] computedHash = digestPrefix(file, fileSize - 96); - try (RandomAccessFile in = new RandomAccessFile(file.toFile(), "r")) { - byte[] magic = new byte[ShineArchiveWriter.MAGIC.length]; - in.readFully(magic); - if (!Arrays.equals(magic, ShineArchiveWriter.MAGIC)) throw new IOException("Bad SHINE-ARCHIVE magic"); - int major=in.readUnsignedByte(), minor=in.readUnsignedByte(); - if (major != 1 || minor != 0) throw new IOException("Unsupported SHINE-ARCHIVE version " + major + "." + minor); - long headerSize=u32(in); long bigBlock=u32(in); long createdAt=in.readLong(); - int creatorLen=in.readUnsignedByte(); int referencesMode=in.readUnsignedByte(); - long refsCount=u32(in); int refSize=in.readUnsignedShort(); long parentRef=u32(in); - long chunksCount=u32(in); long recordsCount=u32(in); - if (referencesMode != ShineArchiveWriter.REFERENCES_FULL || refSize != ShineArchiveWriter.REFERENCE_ENTRY_SIZE) { - throw new IOException("Unsupported references table"); - } - String creator=readUtf8(in,creatorLen); - if (!creator.equalsIgnoreCase(String.valueOf(expectedPublisherLogin))) { - throw new SecurityException("Archive creator " + creator + " != trusted publisher " + expectedPublisherLogin); - } - if (headerSize != in.getFilePointer()) throw new IOException("Некорректный header_size"); - if (refsCount > 10_000_000L || chunksCount > 10_000_000L) throw new IOException("Слишком много refs/chunks"); - - List refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE)); - for(long i=0;i= refs.size()) throw new IOException("parent_reference_index вне таблицы"); - - List chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE)); - long parsedRecords=0; - for(long ci=0;ci fileSize-96) throw new IOException("Некорректный chunk_size"); - int nameLen=in.readUnsignedByte(); String bch=readUtf8(in,nameLen); long count=u32(in); - if (count > 10_000_000L) throw new IOException("Слишком много records в chunk " + bch); - List records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE)); - for(long ri=0;riInteger.MAX_VALUE || in.getFilePointer()+n>chunkEnd-12) throw new IOException("Некорректный record_size"); - records.add(readFixed(in,(int)n)); parsedRecords++; - } - long prevRef=u32(in), prevOffset=u32(in), prevSize=u32(in); - if (prevRef != ShineArchiveWriter.NO_REFERENCE_U32 && prevRef >= refs.size()) throw new IOException("previous_big_block_ref вне таблицы"); - if (in.getFilePointer()!=chunkEnd) throw new IOException("Chunk framing mismatch: " + bch); - chunks.add(new Chunk(chunkOffset,chunkSize,bch,List.copyOf(records),prevRef,prevOffset,prevSize)); - } - if (parsedRecords != recordsCount) throw new IOException("total_user_records_count mismatch"); - - int closerLen=in.readUnsignedByte(); String closer=readUtf8(in,closerLen); - if(!closer.equals(creator)) throw new SecurityException("creator_login != closer_login"); - if(in.getFilePointer()!=fileSize-96) throw new IOException("Некорректная граница footer/hash"); - byte[] storedHash=readFixed(in,32); byte[] signature=readFixed(in,64); - if(in.getFilePointer()!=fileSize) throw new IOException("Лишние данные после signature"); - if(!Arrays.equals(storedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с footer"); - if(expectedHash!=null && !Arrays.equals(expectedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с ожидаемым hash"); - byte[] root=Base58Util.decode(rootPublicKeyBase58); - if(root.length!=32) throw new SecurityException("Некорректный root public key publisher-а"); - byte[] signed=new byte[ShineArchiveWriter.SIGNATURE_DOMAIN.length+32]; - System.arraycopy(ShineArchiveWriter.SIGNATURE_DOMAIN,0,signed,0,ShineArchiveWriter.SIGNATURE_DOMAIN.length); - System.arraycopy(computedHash,0,signed,ShineArchiveWriter.SIGNATURE_DOMAIN.length,32); - if(!Ed25519Util.verify(signed,signature,root)) throw new SecurityException("Не прошла подпись SHINE-ARCHIVE publisher-а"); - return new Document(bigBlock,createdAt,creator,List.copyOf(refs),List.copyOf(chunks),computedHash); - } - } - - private static byte[] digestPrefix(Path file,long bytes) throws Exception { - MessageDigest md=MessageDigest.getInstance("SHA-256"); - try(InputStream in=new BufferedInputStream(Files.newInputStream(file))) { - byte[] buf=new byte[1024*1024]; long left=bytes; - while(left>0){int n=in.read(buf,0,(int)Math.min(buf.length,left)); if(n<0) throw new EOFException(); md.update(buf,0,n); left-=n;} - } - return md.digest(); - } - private static long u32(RandomAccessFile in) throws IOException { return Integer.toUnsignedLong(in.readInt()); } - private static byte[] readFixed(RandomAccessFile in,int n) throws IOException { byte[] b=new byte[n]; in.readFully(b); return b; } - private static String readUtf8(RandomAccessFile in,int n) throws IOException { return new String(readFixed(in,n), StandardCharsets.UTF_8); } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java deleted file mode 100644 index e1e99d4e..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java +++ /dev/null @@ -1,310 +0,0 @@ -package server.archive; - -import shine.db.archive.ArchiveBigBlockRef; -import shine.db.archive.ArchivePublishJobChain; -import shine.db.entities.BlockEntry; -import utils.crypto.Ed25519Util; - -import java.io.*; -import java.nio.charset.StandardCharsets; -import java.nio.file.*; -import java.security.DigestOutputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.*; - -/** - * Writer бинарного SHINE-ARCHIVE v1.0. - * - * Один blockchain_name встречается максимум один раз и содержит все новые raw records этой цепочки. - */ -public final class ShineArchiveWriter { - public static final byte VERSION_MAJOR = 1; - public static final byte VERSION_MINOR = 0; - public static final byte REFERENCES_FULL = 0; - public static final long NO_REFERENCE_U32 = 0xffff_ffffL; - public static final int REFERENCE_ENTRY_SIZE = 68; - public static final byte[] MAGIC = "SHINE-ARCHIVE".getBytes(StandardCharsets.US_ASCII); - public static final byte[] SIGNATURE_DOMAIN = "SHINE-ARCHIVE-V1".getBytes(StandardCharsets.US_ASCII); - - public record ChunkPosition(long offset, long size) {} - public record BuildResult(Path file, byte[] archiveHash, Map chunks, long fileSize) {} - - public BuildResult build( - Path finalPath, - String creatorLogin, - long bigBlockNumber, - long createdAtMs, - List previousBigBlocks, - List chains, - Map> recordsByBlockchain, - byte[] signingPrivateSeed32, - long maxFileBytes - ) throws IOException { - Objects.requireNonNull(finalPath, "finalPath"); - Objects.requireNonNull(previousBigBlocks, "previousBigBlocks"); - Objects.requireNonNull(chains, "chains"); - Objects.requireNonNull(recordsByBlockchain, "recordsByBlockchain"); - requireU32(bigBlockNumber, "big_block_number"); - if (signingPrivateSeed32 == null || signingPrivateSeed32.length != 32) { - throw new IllegalArgumentException("Archive signing key должен быть 32-byte Ed25519 seed"); - } - - byte[] loginBytes = utf8U8(creatorLogin, "creator_login"); - if (previousBigBlocks.size() > 0xffff_ffffL || chains.size() > 0xffff_ffffL) { - throw new IllegalArgumentException("Слишком много references/chunks для v1"); - } - - List sortedChains = new ArrayList<>(chains); - sortedChains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName)); - - long totalRecords = 0; - for (ArchivePublishJobChain ch : sortedChains) { - List rs = recordsByBlockchain.get(ch.blockchainName()); - if (rs == null || rs.isEmpty()) { - throw new IllegalArgumentException("Нет frozen records для " + ch.blockchainName()); - } - totalRecords += rs.size(); - requireU32(totalRecords, "total_user_records_count"); - } - - Map referenceIndexByBlock = new HashMap<>(); - for (int i = 0; i < previousBigBlocks.size(); i++) { - ArchiveBigBlockRef ref = previousBigBlocks.get(i); - requireU32(ref.bigBlockNumber(), "PreviousBigBlockReference.big_block_number"); - require32(ref.archiveHash(), "PreviousBigBlockReference.big_block_hash"); - require32(ref.arweaveTxId(), "PreviousBigBlockReference.arweave_tx_id"); - referenceIndexByBlock.put(ref.bigBlockNumber(), i); - } - - Files.createDirectories(finalPath.toAbsolutePath().getParent()); - Path tmp = finalPath.resolveSibling(finalPath.getFileName() + ".tmp"); - Files.deleteIfExists(tmp); - - Map positions = new LinkedHashMap<>(); - MessageDigest digest = sha256(); - - try (OutputStream fileOut = new BufferedOutputStream(Files.newOutputStream(tmp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)); - CountingOutputStream counting = new CountingOutputStream(fileOut); - DigestOutputStream digestOut = new DigestOutputStream(counting, digest); - DataOutputStream out = new DataOutputStream(digestOut)) { - - // ---------- header ---------- - out.write(MAGIC); - out.writeByte(VERSION_MAJOR); - out.writeByte(VERSION_MINOR); - - long headerSize = fixedHeaderSize() + loginBytes.length; - writeU32(out, headerSize); - writeU32(out, bigBlockNumber); - out.writeLong(createdAtMs); - out.writeByte(loginBytes.length); - out.writeByte(REFERENCES_FULL); - writeU32(out, previousBigBlocks.size()); - out.writeShort(REFERENCE_ENTRY_SIZE); - long parentIndex = previousBigBlocks.isEmpty() ? NO_REFERENCE_U32 : previousBigBlocks.size() - 1L; - writeU32(out, parentIndex); - writeU32(out, sortedChains.size()); - writeU32(out, totalRecords); - out.write(loginBytes); - - // ---------- полный список предыдущих больших блоков ---------- - for (ArchiveBigBlockRef ref : previousBigBlocks) { - writeU32(out, ref.bigBlockNumber()); - out.write(ref.archiveHash()); - out.write(ref.arweaveTxId()); - } - - // ---------- по одному chunk на blockchain_name ---------- - for (ArchivePublishJobChain ch : sortedChains) { - List records = new ArrayList<>(recordsByBlockchain.get(ch.blockchainName())); - records.sort(Comparator.comparingInt(BlockEntry::getBlockNumber)); - validateFrozenRange(ch, records); - - byte[] name = utf8U8(ch.blockchainName(), "blockchain_name"); - long chunkSize = calculateChunkSize(name.length, records); - requireU32(chunkSize, "chunk_size"); - long chunkOffset = counting.getCount(); - requireU32(chunkOffset, "chunk_offset"); - if (chunkOffset + chunkSize > maxFileBytes) { - throw new ArchiveTooLargeException("Большой архив превысит archive.maxFileBytes: " + (chunkOffset + chunkSize)); - } - - writeU32(out, chunkSize); - out.writeByte(name.length); - out.write(name); - writeU32(out, records.size()); - for (BlockEntry record : records) { - byte[] raw = record.getBlockBytes(); - if (raw == null) throw new IllegalArgumentException("block_bytes=NULL: " + ch.blockchainName() + "#" + record.getBlockNumber()); - writeU32(out, raw.length); - out.write(raw); - } - - if (ch.previousArchiveBigBlockNumber() == null) { - writeU32(out, NO_REFERENCE_U32); - writeU32(out, 0); - writeU32(out, 0); - } else { - Integer refIndex = referenceIndexByBlock.get(ch.previousArchiveBigBlockNumber()); - if (refIndex == null) { - throw new IllegalStateException("Предыдущий big block blockchain отсутствует в FULL reference table: " - + ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber()); - } - ArchiveBigBlockRef previousRef = previousBigBlocks.get(refIndex); - require32(ch.previousArchiveBigBlockHash(), "previous_archive_big_block_hash " + ch.blockchainName()); - if (!Arrays.equals(previousRef.archiveHash(), ch.previousArchiveBigBlockHash())) { - throw new IllegalStateException("Hash предыдущего big block не совпадает с FULL reference table: " - + ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber()); - } - if (ch.previousChunkOffset() == null || ch.previousChunkSize() == null) { - throw new IllegalStateException("Нет предыдущих chunk offset/size: " + ch.blockchainName()); - } - writeU32(out, refIndex); - writeU32(out, ch.previousChunkOffset()); - writeU32(out, ch.previousChunkSize()); - } - positions.put(ch.blockchainName(), new ChunkPosition(chunkOffset, chunkSize)); - } - - // ---------- footer, login входит в hash ---------- - out.writeByte(loginBytes.length); - out.write(loginBytes); - out.flush(); - - byte[] archiveHash = digest.digest(); - byte[] signedPayload = new byte[SIGNATURE_DOMAIN.length + archiveHash.length]; - System.arraycopy(SIGNATURE_DOMAIN, 0, signedPayload, 0, SIGNATURE_DOMAIN.length); - System.arraycopy(archiveHash, 0, signedPayload, SIGNATURE_DOMAIN.length, archiveHash.length); - byte[] signature = Ed25519Util.sign(signedPayload, signingPrivateSeed32); - - // hash/signature не должны попасть в hash самого блока. - digestOut.on(false); - out.write(archiveHash); - out.write(signature); - out.flush(); - - long finalSize = counting.getCount(); - if (finalSize >= 0x1_0000_0000L || finalSize > maxFileBytes) { - throw new ArchiveTooLargeException("Размер SHINE-ARCHIVE v1 превышает лимит: " + finalSize); - } - } catch (Exception e) { - Files.deleteIfExists(tmp); - if (e instanceof IOException ioe) throw ioe; - throw new IOException("Не удалось собрать SHINE-ARCHIVE", e); - } - - // До Arweave upload существует уже готовый локальный файл с окончательным именем. - try { - Files.move(tmp, finalPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException e) { - Files.move(tmp, finalPath, StandardCopyOption.REPLACE_EXISTING); - } - - byte[] hash = readStoredArchiveHash(finalPath); - long size = Files.size(finalPath); - return new BuildResult(finalPath, hash, Collections.unmodifiableMap(positions), size); - } - - /** Проверяет локальный файл перед оплатой Arweave. */ - public static byte[] verifyLocalFile(Path file) throws IOException { - long size = Files.size(file); - if (size < MAGIC.length + 2 + 96) throw new IOException("Слишком короткий SHINE-ARCHIVE: " + file); - byte[] stored = readStoredArchiveHash(file); - MessageDigest md = sha256(); - long hashInputSize = size - 96; // [archive_hash 32][signature 64] - try (InputStream in = Files.newInputStream(file)) { - byte[] buf = new byte[1024 * 1024]; - long left = hashInputSize; - while (left > 0) { - int n = in.read(buf, 0, (int)Math.min(buf.length, left)); - if (n < 0) throw new EOFException("Неожиданный EOF при проверке архива"); - md.update(buf, 0, n); - left -= n; - } - } - byte[] actual = md.digest(); - if (!Arrays.equals(stored, actual)) throw new IOException("SHA-256 локального SHINE-ARCHIVE не совпадает с footer"); - return actual; - } - - public static byte[] readStoredArchiveHash(Path file) throws IOException { - long size = Files.size(file); - if (size < 96) throw new EOFException("Файл слишком короткий"); - try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) { - raf.seek(size - 96); - byte[] hash = new byte[32]; - raf.readFully(hash); - return hash; - } - } - - private static long fixedHeaderSize() { - // magic + major/minor + headerSize + blockNo + time + creatorLen + mode + refCount + refSize + parentIndex + chunkCount + recordCount - return 13L + 1 + 1 + 4 + 4 + 8 + 1 + 1 + 4 + 2 + 4 + 4 + 4; - } - - private static long calculateChunkSize(int nameLen, List records) { - long size = 4L + 1L + nameLen + 4L + 12L; - for (BlockEntry e : records) { - byte[] raw = e.getBlockBytes(); - if (raw == null) throw new IllegalArgumentException("raw record is null"); - size += 4L + raw.length; - } - return size; - } - - private static void validateFrozenRange(ArchivePublishJobChain ch, List records) { - long expected = ch.toSourceBlockNumber() - ch.fromSourceBlockNumber() + 1; - if (expected <= 0 || expected != records.size()) { - throw new IllegalArgumentException("Frozen range неполон для " + ch.blockchainName() - + ": ожидалось=" + expected + ", получено=" + records.size()); - } - long n = ch.fromSourceBlockNumber(); - for (BlockEntry e : records) { - if (Integer.toUnsignedLong(e.getBlockNumber()) != n) { - throw new IllegalArgumentException("Нарушена непрерывность " + ch.blockchainName() - + ": ожидался block " + n + ", получен " + Integer.toUnsignedLong(e.getBlockNumber())); - } - n++; - } - } - - private static byte[] utf8U8(String value, String name) { - if (value == null) throw new IllegalArgumentException(name + " == null"); - byte[] bytes = value.getBytes(StandardCharsets.UTF_8); - if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException(name + " должен занимать 1..255 UTF-8 байт"); - return bytes; - } - - private static void require32(byte[] v, String name) { - if (v == null || v.length != 32) throw new IllegalArgumentException(name + " должен быть 32 bytes"); - } - - private static void requireU32(long v, String name) { - if (v < 0 || v > 0xffff_ffffL) throw new IllegalArgumentException(name + " вне u32: " + v); - } - - private static void writeU32(DataOutputStream out, long v) throws IOException { - requireU32(v, "u32"); - out.writeInt((int)v); // DataOutputStream = big-endian; битовый шаблон корректен и для unsigned > 2^31. - } - - private static MessageDigest sha256() { - try { return MessageDigest.getInstance("SHA-256"); } - catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } - } - - /** OutputStream со счётчиком без ограничения int. */ - private static final class CountingOutputStream extends FilterOutputStream { - private long count; - CountingOutputStream(OutputStream out) { super(out); } - long getCount() { return count; } - @Override public void write(int b) throws IOException { out.write(b); count++; } - @Override public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); count += len; } - } - - public static final class ArchiveTooLargeException extends IOException { - public ArchiveTooLargeException(String message) { super(message); } - } -} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java deleted file mode 100644 index 33ea9c65..00000000 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java +++ /dev/null @@ -1,373 +0,0 @@ -package server.archive; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import sync.codec.ShineUsersCodec; -import sync.codec.ShineUsersCodec.UserPdaSnapshot; -import sync.util.Base58Util; -import sync.util.SolanaPdaUtil; -import utils.crypto.Ed25519Util; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -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.security.MessageDigest; -import java.time.Duration; -import java.util.*; - -/** - * Обновляет ArchiveHeadBlock (type=100) обычной инструкцией update_user_pda. - * Отдельной Solana-инструкции для архива нет. - */ -public final class SolanaArchiveHeadWriter { - private static final Logger log = LoggerFactory.getLogger(SolanaArchiveHeadWriter.class); - private static final ObjectMapper JSON = new ObjectMapper(); - - private static final String SYSTEM_PROGRAM = "11111111111111111111111111111111"; - private static final String ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111"; - private static final String COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111"; - private static final String SYSVAR_INSTRUCTIONS = "Sysvar1nstructions1111111111111111111111111"; - private static final String USER_SEED_PREFIX = "user_login="; - private static final String ECONOMY_SEED = "shine_users_economy_config"; - private static final String INFLOW_SEED = "shine_payments_inflow_vault"; - private static final byte[] LAST_BLOCK_STATE_PREFIX = "SHiNE_LAST_BLOCK".getBytes(StandardCharsets.UTF_8); - - private final ArchivePublisherConfig cfg; - private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); - private final byte[] rootSeed32; - private final byte[] clientSeed32; - - public SolanaArchiveHeadWriter(ArchivePublisherConfig cfg) throws IOException { - this.cfg = Objects.requireNonNull(cfg); - this.rootSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath()); - this.clientSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaClientKeyPath()); - } - - public record SubmitResult(String signature, String userPda) {} - - /** Preflight: проверяет, что локальные root/client private keys соответствуют текущему User PDA. */ - public void validateKeysAgainstPda() throws Exception { - Current current = loadCurrent(); - byte[] expectedRoot = Base58Util.decode(current.snapshot.rootKey()); - byte[] expectedClient = Base58Util.decode(current.snapshot.clientKey()); - if (!Arrays.equals(expectedRoot, Ed25519Util.derivePublicKey(rootSeed32))) { - throw new IllegalStateException("archive.solana.rootKeyPath не соответствует root_key User PDA " + cfg.serverLogin()); - } - if (!Arrays.equals(expectedClient, Ed25519Util.derivePublicKey(clientSeed32))) { - throw new IllegalStateException("archive.solana.clientKeyPath не соответствует client_key User PDA " + cfg.serverLogin()); - } - } - - public SubmitResult submitArchiveHead(byte[] arweaveTxId32, byte[] archiveHash32) throws Exception { - require32(arweaveTxId32, "Arweave TX ID"); - require32(archiveHash32, "archive hash"); - - Current current = loadCurrent(); - UserPdaSnapshot old = current.snapshot; - byte[] rootPub = Ed25519Util.derivePublicKey(rootSeed32); - byte[] clientPub = Ed25519Util.derivePublicKey(clientSeed32); - if (!Arrays.equals(rootPub, Base58Util.decode(old.rootKey()))) { - throw new IllegalStateException("Root key archive publisher-а не совпадает с User PDA"); - } - if (!Arrays.equals(clientPub, Base58Util.decode(old.clientKey()))) { - throw new IllegalStateException("Client key archive publisher-а не совпадает с User PDA"); - } - - byte[] currentRaw = Base64.getDecoder().decode(old.rawDataBase64()); - int currentRecordLen = u16le(currentRaw, 7); - byte[] currentUnsigned = Arrays.copyOf(currentRaw, currentRecordLen - 64); - byte[] prevHash = sha256(currentUnsigned); - - long updatedAt = System.currentTimeMillis(); - String txB64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(arweaveTxId32); - String archiveHashHex = hex(archiveHash32); - String zeroSig = Base58Util.encode(new byte[64]); - - UserPdaSnapshot nextUnsignedSnapshot = new UserPdaSnapshot( - old.pdaAddress(), old.login(), old.recordNumber() + 1, old.slot(), "", - old.recoveryKey(), old.rootKey(), old.clientKey(), old.blockchainName(), old.blockchainKey(), - old.paidLimitBytes(), old.usedBytes(), old.lastBlockNumber(), old.lastBlockHash(), - old.lastBlockSignature(), old.arweaveTxId(), txB64Url, archiveHashHex, - old.isServer(), old.addressFormatType(), old.addressFormatVersion(), old.serverAddress(), - old.syncServers(), old.accessServers(), old.sessionsMode(), old.sessions(), old.trustedCount(), - old.createdAtMs(), updatedAt, hex(prevHash), zeroSig, "" - ); - byte[] nextFull = Base64.getDecoder().decode(ShineUsersCodec.serializeSnapshotToBase64(nextUnsignedSnapshot)); - int nextRecordLen = u16le(nextFull, 7); - byte[] nextUnsigned = Arrays.copyOf(nextFull, nextRecordLen - 64); - byte[] nextUnsignedHash = sha256(nextUnsigned); - byte[] rootSignature = Ed25519Util.sign(nextUnsignedHash, rootSeed32); - - byte[] updateData = buildUpdateInstruction(old, updatedAt, prevHash, arweaveTxId32, archiveHash32, rootSignature); - byte[] lastStateHash = sha256(buildLastBlockState(old)); - byte[] lastBlockSignature = Base58Util.decode(old.lastBlockSignature()); - byte[] blockchainPub = Base58Util.decode(old.blockchainKey()); - - byte[] rootEdData = buildEd25519Instruction(rootSignature, rootPub, nextUnsignedHash); - byte[] bchEdData = buildEd25519Instruction(lastBlockSignature, blockchainPub, lastStateHash); - - String blockhash = getLatestBlockhash(); - byte[] message = buildLegacyMessage(current.userPda, updateData, rootEdData, bchEdData, blockhash, clientPub); - byte[] clientSignature = Ed25519Util.sign(message, clientSeed32); - byte[] transaction = serializeTransaction(clientSignature, message); - String signature = sendTransaction(transaction); - log.info("Archive head отправлен в Solana: login={} pda={} signature={}", cfg.serverLogin(), current.userPda, signature); - return new SubmitResult(signature, current.userPda); - } - - public void waitFinalized(String signature) throws Exception { - long deadline = System.currentTimeMillis() + cfg.solanaConfirmTimeoutMinutes() * 60_000L; - while (System.currentTimeMillis() < deadline) { - JsonNode result = rpc("getSignatureStatuses", List.of(List.of(signature), Map.of("searchTransactionHistory", true))).path("result"); - JsonNode values = result.path("value"); - JsonNode status = values.isArray() && values.size() > 0 ? values.get(0) : null; - if (status != null && !status.isNull()) { - if (status.hasNonNull("err")) { - throw new IOException("Solana archive-head tx завершилась ошибкой: " + status.path("err")); - } - String confirmation = status.path("confirmationStatus").asText(""); - if ("finalized".equals(confirmation)) return; - } - Thread.sleep(cfg.solanaConfirmPollSeconds() * 1000L); - } - throw new IOException("Timeout ожидания Solana finalized: " + signature); - } - - public boolean currentHeadMatches(byte[] txId32, byte[] hash32) throws Exception { - Current current = loadCurrent(); - if (current.snapshot.archiveHeadTxId() == null || current.snapshot.archiveHeadTxId().isBlank()) return false; - byte[] currentTx = Base64.getUrlDecoder().decode(current.snapshot.archiveHeadTxId()); - byte[] currentHash = unhex(current.snapshot.archiveHeadHash()); - return Arrays.equals(currentTx, txId32) && Arrays.equals(currentHash, hash32); - } - - private Current loadCurrent() throws Exception { - String normalized = cfg.serverLogin().trim().toLowerCase(Locale.ROOT); - String userPda = SolanaPdaUtil.findProgramAddress( - List.of(USER_SEED_PREFIX.getBytes(StandardCharsets.UTF_8), normalized.getBytes(StandardCharsets.UTF_8)), - cfg.solanaUsersProgramId()); - JsonNode accountResult = rpc("getAccountInfo", List.of(userPda, Map.of("encoding", "base64", "commitment", cfg.solanaCommitment()))).path("result"); - JsonNode value = accountResult.path("value"); - if (value.isMissingNode() || value.isNull()) throw new IOException("User PDA не найден для " + cfg.serverLogin()); - String owner = value.path("owner").asText(""); - if (!cfg.solanaUsersProgramId().equals(owner)) throw new IOException("User PDA принадлежит другой программе: " + owner); - JsonNode data = value.path("data"); - if (!data.isArray() || data.size() < 1) throw new IOException("Некорректный getAccountInfo.data"); - String rawB64 = data.get(0).asText(); - UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(userPda, 0L, rawB64, ""); - return new Current(userPda, snapshot); - } - - private byte[] buildUpdateInstruction(UserPdaSnapshot s, long updatedAt, byte[] prevHash, - byte[] archiveTx, byte[] archiveHash, byte[] rootSig) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - out.write(4); // IX_UPDATE_USER_PDA - strU8(out, s.login()); - out.write(Base58Util.decode(s.recoveryKey())); - out.write(Base58Util.decode(s.rootKey())); - u64le(out, s.createdAtMs()); - u64le(out, updatedAt); - u32le(out, Integer.toUnsignedLong(s.recordNumber() + 1)); - out.write(prevHash); - u64le(out, 0L); // additional_limit - out.write(Base58Util.decode(s.clientKey())); - out.write(Base58Util.decode(s.blockchainKey())); - strU8(out, s.blockchainName()); - u64le(out, s.usedBytes()); - u32le(out, Integer.toUnsignedLong(s.lastBlockNumber())); - out.write(unhex(s.lastBlockHash())); - out.write(Base58Util.decode(s.lastBlockSignature())); - strU8(out, s.arweaveTxId()); - out.write(s.isServer() ? 1 : 0); - if (s.isServer()) { - out.write(s.addressFormatType() & 0xff); - out.write(s.addressFormatVersion() & 0xff); - strU8(out, s.serverAddress()); - if (s.syncServers().size() > 255) throw new IOException("Слишком много sync_servers"); - out.write(s.syncServers().size()); - for (String v : s.syncServers()) strU8(out, v); - } - if (s.accessServers().size() > 255) throw new IOException("Слишком много access_servers"); - out.write(s.accessServers().size()); - for (String v : s.accessServers()) strU8(out, v); - out.write(s.sessionsMode() & 0xff); - if (s.sessions().size() > 255) throw new IOException("Слишком много sessions"); - out.write(s.sessions().size()); - for (ShineUsersCodec.UserSessionSnapshot session : s.sessions()) { - out.write(session.sessionType() & 0xff); - out.write(session.sessionVersion() & 0xff); - strU8(out, session.sessionName()); - out.write(Base58Util.decode(session.sessionPubKey())); - } - out.write(s.trustedCount() & 0xff); - // Archive extension к обычному update_user_pda: marker=1 + tx32 + hash32. - out.write(1); - out.write(archiveTx); - out.write(archiveHash); - out.write(rootSig); - return out.toByteArray(); - } - - private static byte[] buildLastBlockState(UserPdaSnapshot s) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - out.write(LAST_BLOCK_STATE_PREFIX); - strU8(out, s.login()); - strU8(out, s.blockchainName()); - u32le(out, Integer.toUnsignedLong(s.lastBlockNumber())); - out.write(unhex(s.lastBlockHash())); - u64le(out, s.usedBytes()); - return out.toByteArray(); - } - - private byte[] buildLegacyMessage(String userPda, byte[] updateData, byte[] rootEd, byte[] bchEd, - String recentBlockhash, byte[] clientPub) throws IOException { - String economy = SolanaPdaUtil.findProgramAddress(List.of(ECONOMY_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaUsersProgramId()); - String inflow = SolanaPdaUtil.findProgramAddress(List.of(INFLOW_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaPaymentsProgramId()); - - List accounts = List.of( - new Account(Base58Util.encode(clientPub), true, true), - new Account(userPda, false, true), - new Account(inflow, false, true), - new Account(SYSTEM_PROGRAM, false, false), - new Account(SYSVAR_INSTRUCTIONS, false, false), - new Account(economy, false, false), - new Account(COMPUTE_BUDGET_PROGRAM, false, false), - new Account(ED25519_PROGRAM, false, false), - new Account(cfg.solanaUsersProgramId(), false, false) - ); - Map ix = new HashMap<>(); - for (int i=0;i instructions = new ArrayList<>(); - instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeHeapData(262_144))); - instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeLimitData(800_000))); - instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], rootEd)); - instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], bchEd)); - instructions.add(new CompiledInstruction(ix.get(cfg.solanaUsersProgramId()), new int[]{ - ix.get(Base58Util.encode(clientPub)), ix.get(userPda), ix.get(SYSTEM_PROGRAM), ix.get(inflow), - ix.get(SYSVAR_INSTRUCTIONS), ix.get(economy) - }, updateData)); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - out.write(1); // numRequiredSignatures - out.write(0); // numReadonlySignedAccounts - out.write(6); // readonly unsigned: system, sysvar, economy, compute, ed25519, users - shortVec(out, accounts.size()); - for (Account a : accounts) { - byte[] pk = Base58Util.decode(a.pubkey); - if (pk.length != 32) throw new IOException("Solana pubkey !=32: " + a.pubkey); - out.write(pk); - } - byte[] bh = Base58Util.decode(recentBlockhash); - if (bh.length != 32) throw new IOException("recentBlockhash !=32"); - out.write(bh); - shortVec(out, instructions.size()); - for (CompiledInstruction ci : instructions) { - out.write(ci.programIdIndex); - shortVec(out, ci.accounts.length); - for (int a : ci.accounts) out.write(a); - shortVec(out, ci.data.length); - out.write(ci.data); - } - return out.toByteArray(); - } - - private String getLatestBlockhash() throws Exception { - JsonNode result = rpc("getLatestBlockhash", List.of(Map.of("commitment", cfg.solanaCommitment()))).path("result"); - String blockhash = result.path("value").path("blockhash").asText(""); - if (blockhash.isBlank()) throw new IOException("Solana getLatestBlockhash не вернул blockhash"); - return blockhash; - } - - private String sendTransaction(byte[] tx) throws Exception { - String txB64 = Base64.getEncoder().encodeToString(tx); - JsonNode result = rpc("sendTransaction", List.of(txB64, Map.of( - "encoding", "base64", - "skipPreflight", false, - "preflightCommitment", "confirmed", - "maxRetries", 5 - ))).path("result"); - String sig = result.asText(""); - if (sig.isBlank()) throw new IOException("Solana sendTransaction не вернул signature"); - return sig; - } - - private JsonNode rpc(String method, List params) throws Exception { - Map body = new LinkedHashMap<>(); - body.put("jsonrpc", "2.0"); body.put("id", 1); body.put("method", method); body.put("params", params); - HttpRequest req = HttpRequest.newBuilder(URI.create(cfg.solanaRpcUrl())) - .timeout(Duration.ofSeconds(60)) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body), StandardCharsets.UTF_8)) - .build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Solana RPC HTTP " + resp.statusCode() + ": " + safe(resp.body())); - JsonNode root = JSON.readTree(resp.body()); - if (root.hasNonNull("error")) throw new IOException("Solana RPC " + method + " error: " + root.path("error")); - return root; - } - - private static byte[] serializeTransaction(byte[] signature64, byte[] message) throws IOException { - if (signature64.length != 64) throw new IOException("Solana signature !=64"); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - shortVec(out, 1); - out.write(signature64); - out.write(message); - return out.toByteArray(); - } - - private static byte[] buildEd25519Instruction(byte[] signature64, byte[] publicKey32, byte[] message) { - if (signature64.length != 64 || publicKey32.length != 32) throw new IllegalArgumentException("Некорректный Ed25519 ix key/signature"); - int sigOff=16, pkOff=sigOff+64, msgOff=pkOff+32; - if (message.length > 0xffff) throw new IllegalArgumentException("Ed25519 message слишком длинный"); - byte[] data = new byte[msgOff + message.length]; - data[0]=1; data[1]=0; - putU16le(data,2,sigOff); putU16le(data,4,0xffff); - putU16le(data,6,pkOff); putU16le(data,8,0xffff); - putU16le(data,10,msgOff); putU16le(data,12,message.length); putU16le(data,14,0xffff); - System.arraycopy(signature64,0,data,sigOff,64); - System.arraycopy(publicKey32,0,data,pkOff,32); - System.arraycopy(message,0,data,msgOff,message.length); - return data; - } - - private static byte[] computeHeapData(int bytes) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(1); u32le(out, Integer.toUnsignedLong(bytes)); return out.toByteArray(); - } - private static byte[] computeLimitData(int units) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(2); u32le(out, Integer.toUnsignedLong(units)); return out.toByteArray(); - } - - private static void shortVec(ByteArrayOutputStream out, int value) { - int rem=value; - while (true) { - int elem=rem & 0x7f; rem >>>=7; - if (rem==0) { out.write(elem); return; } - out.write(elem | 0x80); - } - } - private static void strU8(ByteArrayOutputStream out, String value) throws IOException { - byte[] b=String.valueOf(value==null?"":value).getBytes(StandardCharsets.UTF_8); - if (b.length>255) throw new IOException("Строка >255 bytes"); out.write(b.length); out.write(b); - } - private static void u32le(ByteArrayOutputStream out, long v) { for(int i=0;i<4;i++) out.write((int)(v >>> (8*i)) & 0xff); } - private static void u64le(ByteArrayOutputStream out, long v) { for(int i=0;i<8;i++) out.write((int)(v >>> (8*i)) & 0xff); } - private static void putU16le(byte[] d,int o,int v){d[o]=(byte)v;d[o+1]=(byte)(v>>>8);} - private static int u16le(byte[] b,int o){return (b[o]&255)|((b[o+1]&255)<<8);} - private static byte[] sha256(byte[] bytes) throws Exception { return MessageDigest.getInstance("SHA-256").digest(bytes); } - private static String hex(byte[] b){StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();} - private static byte[] unhex(String s){String x=String.valueOf(s==null?"":s).trim(); if(x.length()!=64) throw new IllegalArgumentException("hex32 expected");byte[]o=new byte[32];for(int i=0;i<32;i++)o[i]=(byte)Integer.parseInt(x.substring(i*2,i*2+2),16);return o;} - private static void require32(byte[] b,String n){if(b==null||b.length!=32)throw new IllegalArgumentException(n+" должен быть 32 bytes");} - private static String safe(String s){String x=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return x.length()<=500?x:x.substring(0,500)+"...";} - - private record Current(String userPda, UserPdaSnapshot snapshot) {} - private record Account(String pubkey, boolean signer, boolean writable) {} - private record CompiledInstruction(int programIdIndex, int[] accounts, byte[] data) {} -} diff --git a/SHiNE-server/shine-server-archive/src/test/java/server/archive/Ans104BundleTest.java b/SHiNE-server/shine-server-archive/src/test/java/server/archive/Ans104BundleTest.java new file mode 100644 index 00000000..2b83e9e1 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/test/java/server/archive/Ans104BundleTest.java @@ -0,0 +1,36 @@ +package server.archive; + +import blockchain.Ans104DataItem; +import org.junit.jupiter.api.Test; +import utils.crypto.Ed25519Util; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +final class Ans104BundleTest { + @Test + void writeReadAndFindPreserveExactDataItems() { + byte[] a = item("a"); + byte[] b = item("b"); + byte[] bundle = Ans104Bundle.write(List.of(a, b)); + + List entries = Ans104Bundle.read(bundle, 10); + assertEquals(2, entries.size()); + assertArrayEquals(a, entries.get(0).rawDataItem()); + assertArrayEquals(b, entries.get(1).rawDataItem()); + + byte[] bId = new Ans104DataItem(b).id32(); + assertArrayEquals(b, Ans104Bundle.find(bundle, bId, 10)); + } + + private static byte[] item(String payload) { + byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("bundle-" + payload); + byte[] owner = Ed25519Util.derivePublicKey(privateKey); + byte[] data = payload.getBytes(StandardCharsets.UTF_8); + List tags = List.of(new Ans104DataItem.Tag("App", "test5590")); + byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey); + return Ans104DataItem.serializeEd25519(signature, owner, tags, data); + } +} diff --git a/SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java b/SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java deleted file mode 100644 index 5f394f39..00000000 --- a/SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package server.archive; - -import org.junit.jupiter.api.Test; - -import java.time.Instant; -import java.time.ZoneId; -import java.util.Base64; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class ArchiveFileNamesTest { - private static final long CREATED_AT = Instant.parse("2026-09-11T00:00:00Z").toEpochMilli(); - private static final ZoneId UTC = ZoneId.of("UTC"); - - @Test - void usesTemporaryAndFinalArweaveNames() { - byte[] tx = new byte[32]; - for (int i = 0; i < tx.length; i++) tx[i] = (byte) i; - String txText = Base64.getUrlEncoder().withoutPadding().encodeToString(tx); - - assertEquals("archive01.00001.11.09.26.tmp.SHiNE-archive", - ArchiveFileNames.pendingFileName("archive01", 1, CREATED_AT, UTC)); - assertEquals("archive01.00001.11.09.26." + txText + ".SHiNE-archive", - ArchiveFileNames.uploadedFileName("archive01", 1, CREATED_AT, UTC, tx)); - assertEquals("archive01.100000.11.09.26.tmp.SHiNE-archive", - ArchiveFileNames.pendingFileName("archive01", 100000, CREATED_AT, UTC)); - } - - @Test - void sanitizesLoginForFileSystemOnly() { - assertEquals("archive_user.00002.11.09.26.tmp.SHiNE-archive", - ArchiveFileNames.pendingFileName("archive/user", 2, CREATED_AT, UTC)); - } -} diff --git a/SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java b/SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java deleted file mode 100644 index 7fdcd2de..00000000 --- a/SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package server.archive; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import shine.db.archive.ArchiveBigBlockRef; -import shine.db.archive.ArchivePublishJobChain; -import shine.db.entities.BlockEntry; - -import java.io.DataInputStream; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.time.ZoneId; -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -class ShineArchiveWriterTest { - @TempDir Path dir; - - @Test - void writesOneChunkWithAllRecordsAndOneBacklink() throws Exception { - Path file = ArchiveFileNames.pendingFilePath(dir, "serverA", 2, 123456789L, ZoneId.of("UTC")); - ArchiveBigBlockRef previous = new ArchiveBigBlockRef(1, new byte[32], new byte[32]); - ArchivePublishJobChain chain = new ArchivePublishJobChain( - 1, "alice-001", 10, 11, new byte[32], new byte[32], - 1L, new byte[32], 100L, 55L, null, null); - - ShineArchiveWriter.BuildResult result = new ShineArchiveWriter().build( - file, "serverA", 2, 123456789L, - List.of(previous), List.of(chain), - Map.of("alice-001", List.of(block(10, new byte[]{1,2,3}), block(11, new byte[]{4,5}))), - new byte[32], 4_000_000_000L); - - assertArrayEquals(result.archiveHash(), ShineArchiveWriter.verifyLocalFile(file)); - assertEquals("serverA.00002.02.01.70.tmp.SHiNE-archive", file.getFileName().toString()); - - try (InputStream raw = java.nio.file.Files.newInputStream(file); DataInputStream in = new DataInputStream(raw)) { - assertEquals("SHINE-ARCHIVE", new String(in.readNBytes(13), StandardCharsets.US_ASCII)); - assertEquals(1, in.readUnsignedByte()); - assertEquals(0, in.readUnsignedByte()); - long headerSize = Integer.toUnsignedLong(in.readInt()); - assertEquals(2, Integer.toUnsignedLong(in.readInt())); - assertEquals(123456789L, in.readLong()); - int creatorLen = in.readUnsignedByte(); - assertEquals(ShineArchiveWriter.REFERENCES_FULL, in.readUnsignedByte()); - assertEquals(1, Integer.toUnsignedLong(in.readInt())); - assertEquals(68, in.readUnsignedShort()); - assertEquals(0, Integer.toUnsignedLong(in.readInt())); - assertEquals(1, Integer.toUnsignedLong(in.readInt())); - assertEquals(2, Integer.toUnsignedLong(in.readInt())); - assertEquals("serverA", new String(in.readNBytes(creatorLen), StandardCharsets.UTF_8)); - assertEquals(headerSize, 51L + creatorLen); - - assertEquals(1, Integer.toUnsignedLong(in.readInt())); - in.skipNBytes(64); // previous hash + txid - - long chunkSize = Integer.toUnsignedLong(in.readInt()); - int nameLen = in.readUnsignedByte(); - assertEquals("alice-001", new String(in.readNBytes(nameLen), StandardCharsets.UTF_8)); - assertEquals(2, Integer.toUnsignedLong(in.readInt())); - assertEquals(3, Integer.toUnsignedLong(in.readInt())); - assertArrayEquals(new byte[]{1,2,3}, in.readNBytes(3)); - assertEquals(2, Integer.toUnsignedLong(in.readInt())); - assertArrayEquals(new byte[]{4,5}, in.readNBytes(2)); - assertEquals(0, Integer.toUnsignedLong(in.readInt())); // previous ref index - assertEquals(100, Integer.toUnsignedLong(in.readInt())); - assertEquals(55, Integer.toUnsignedLong(in.readInt())); - assertEquals(4L + 1 + nameLen + 4 + (4+3) + (4+2) + 12, chunkSize); - } - } - - private static BlockEntry block(int number, byte[] bytes) { - BlockEntry e = new BlockEntry(); - e.setBlockNumber(number); - e.setBlockBytes(bytes); - e.setBlockHash(new byte[32]); - e.setBlockSignature(new byte[64]); - return e; - } -} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java new file mode 100644 index 00000000..55ae570b --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java @@ -0,0 +1,363 @@ +package blockchain; + +import utils.crypto.Ed25519Util; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Minimal ANS-104 DataItem parser/verifier used by SHiNE. + * + * SHiNE accepts only generic Ed25519 signature type 2: + * uint16le signatureType + * signature[64] + * owner[32] + * target_present[1] + target[32] when present + * anchor_present[1] + anchor[32] when present + * tags_count uint64le + * tags_size uint64le + * avro_tags[tags_size] + * data[*] + * + * The signing message matches the deployed arbundles/Turbo DataItem implementation: + * deepHash(["dataitem", "1", signatureType, owner, target, anchor, rawAvroTags, data]). + */ +public final class Ans104DataItem { + public static final int SIGNATURE_TYPE_ED25519 = 2; + public static final int SIGNATURE_LEN = 64; + public static final int OWNER_LEN = 32; + public static final int OPTIONAL_FIELD_LEN = 32; + public static final int ID_LEN = 32; + public static final int MAX_DATA_ITEM_BYTES = 8 * 1024 * 1024; + public static final int MAX_TAGS_BYTES = 64 * 1024; + public static final long MAX_TAGS_COUNT = 128; + + public record Tag(String name, String value) {} + + private final int signatureType; + private final byte[] signature; + private final byte[] owner; + private final byte[] target; + private final byte[] anchor; + private final long tagsCount; + private final byte[] rawTags; + private final List tags; + private final byte[] data; + private final byte[] rawBytes; + private final byte[] id; + + public Ans104DataItem(byte[] bytes) { + Objects.requireNonNull(bytes, "bytes == null"); + if (bytes.length < 2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + 1) { + throw new IllegalArgumentException("ANS-104 DataItem too short: " + bytes.length); + } + if (bytes.length > MAX_DATA_ITEM_BYTES) { + throw new IllegalArgumentException("ANS-104 DataItem too large: " + bytes.length); + } + + ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + this.signatureType = Short.toUnsignedInt(bb.getShort()); + if (signatureType != SIGNATURE_TYPE_ED25519) { + throw new IllegalArgumentException("Unsupported ANS-104 signature type: " + signatureType + " (expected 2)"); + } + + this.signature = readExact(bb, SIGNATURE_LEN, "signature"); + this.owner = readExact(bb, OWNER_LEN, "owner"); + this.target = readOptional32(bb, "target"); + this.anchor = readOptional32(bb, "anchor"); + + this.tagsCount = readUnsignedLongAsNonNegative(bb, "tags_count"); + long tagsSizeLong = readUnsignedLongAsNonNegative(bb, "tags_size"); + if (tagsCount > MAX_TAGS_COUNT) { + throw new IllegalArgumentException("Too many ANS-104 tags: " + tagsCount); + } + if (tagsSizeLong > MAX_TAGS_BYTES || tagsSizeLong > bb.remaining()) { + throw new IllegalArgumentException("Bad ANS-104 tags_size: " + tagsSizeLong + ", remaining=" + bb.remaining()); + } + this.rawTags = readExact(bb, (int) tagsSizeLong, "tags"); + this.tags = Collections.unmodifiableList(parseAvroTags(rawTags, tagsCount)); + this.data = new byte[bb.remaining()]; + bb.get(this.data); + this.rawBytes = Arrays.copyOf(bytes, bytes.length); + this.id = sha256(signature); + } + + public int signatureType() { return signatureType; } + public byte[] signature64() { return Arrays.copyOf(signature, signature.length); } + public byte[] owner32() { return Arrays.copyOf(owner, owner.length); } + public byte[] target() { return Arrays.copyOf(target, target.length); } + public byte[] anchor() { return Arrays.copyOf(anchor, anchor.length); } + public long tagsCount() { return tagsCount; } + public byte[] rawTags() { return Arrays.copyOf(rawTags, rawTags.length); } + public List tags() { return tags; } + public byte[] data() { return Arrays.copyOf(data, data.length); } + public byte[] rawBytes() { return Arrays.copyOf(rawBytes, rawBytes.length); } + public byte[] id32() { return Arrays.copyOf(id, id.length); } + + public String tagValue(String name) { + if (name == null) return null; + for (Tag tag : tags) { + if (name.equals(tag.name())) return tag.value(); + } + return null; + } + + public boolean hasTag(String name, String value) { + for (Tag tag : tags) { + if (Objects.equals(name, tag.name()) && Objects.equals(value, tag.value())) return true; + } + return false; + } + + public boolean verifySignature() { + return Ed25519Util.verify(signingMessage(), signature, owner); + } + + public boolean ownerEquals(byte[] expectedOwner32) { + return expectedOwner32 != null && expectedOwner32.length == OWNER_LEN && Arrays.equals(owner, expectedOwner32); + } + + public byte[] signingMessage() { + return buildSigningMessage(owner, target, anchor, tags, data); + } + + /** + * Builds the exact ANS-104 signing message for generic Ed25519 DataItems. + * Useful for SHiNE clients/tests that must sign a DataItem before serializing it. + */ + public static byte[] buildSigningMessage(byte[] owner32, List tags, byte[] data) { + return buildSigningMessage(owner32, new byte[0], new byte[0], tags, data); + } + + public static byte[] buildSigningMessage(byte[] owner32, byte[] target, byte[] anchor, List tags, byte[] data) { + requireLen(owner32, OWNER_LEN, "owner"); + byte[] safeTarget = target == null ? new byte[0] : Arrays.copyOf(target, target.length); + byte[] safeAnchor = anchor == null ? new byte[0] : Arrays.copyOf(anchor, anchor.length); + if (!(safeTarget.length == 0 || safeTarget.length == OPTIONAL_FIELD_LEN)) { + throw new IllegalArgumentException("target must be empty or 32 bytes"); + } + if (!(safeAnchor.length == 0 || safeAnchor.length == OPTIONAL_FIELD_LEN)) { + throw new IllegalArgumentException("anchor must be empty or 32 bytes"); + } + List safeTags = validateTags(tags); + byte[] safeData = data == null ? new byte[0] : Arrays.copyOf(data, data.length); + + byte[] rawTags = serializeAvroTags(safeTags); + return deepHash(List.of( + utf8("dataitem"), + utf8("1"), + utf8(Integer.toString(SIGNATURE_TYPE_ED25519)), + Arrays.copyOf(owner32, owner32.length), + safeTarget, + safeAnchor, + rawTags, + safeData + )); + } + + /** Serialize one SHiNE-ready ANS-104 DataItem with empty target/anchor. */ + public static byte[] serializeEd25519(byte[] signature64, byte[] owner32, List tags, byte[] data) { + requireLen(signature64, SIGNATURE_LEN, "signature"); + requireLen(owner32, OWNER_LEN, "owner"); + List safeTags = validateTags(tags); + byte[] rawTags = serializeAvroTags(safeTags); + byte[] safeData = data == null ? new byte[0] : data; + + ByteArrayOutputStream out = new ByteArrayOutputStream( + 2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + rawTags.length + safeData.length); + writeU16LE(out, SIGNATURE_TYPE_ED25519); + out.writeBytes(signature64); + out.writeBytes(owner32); + out.write(0); // target absent + out.write(0); // anchor absent + writeU64LE(out, safeTags.size()); + writeU64LE(out, rawTags.length); + out.writeBytes(rawTags); + out.writeBytes(safeData); + return out.toByteArray(); + } + + private static byte[] readOptional32(ByteBuffer bb, String name) { + if (!bb.hasRemaining()) throw new IllegalArgumentException("Missing " + name + " presence byte"); + int present = Byte.toUnsignedInt(bb.get()); + if (present == 0) return new byte[0]; + if (present != 1) throw new IllegalArgumentException("Bad " + name + " presence byte: " + present); + return readExact(bb, OPTIONAL_FIELD_LEN, name); + } + + private static byte[] readExact(ByteBuffer bb, int len, String field) { + if (len < 0 || bb.remaining() < len) { + throw new IllegalArgumentException("ANS-104 " + field + " truncated: need=" + len + " remaining=" + bb.remaining()); + } + byte[] out = new byte[len]; + bb.get(out); + return out; + } + + private static long readUnsignedLongAsNonNegative(ByteBuffer bb, String field) { + if (bb.remaining() < 8) throw new IllegalArgumentException("Missing ANS-104 " + field); + long v = bb.getLong(); + if (v < 0) throw new IllegalArgumentException("ANS-104 " + field + " exceeds signed long range"); + return v; + } + + private static List validateTags(List tags) { + List safe = tags == null ? List.of() : List.copyOf(tags); + if (safe.size() > MAX_TAGS_COUNT) { + throw new IllegalArgumentException("Too many ANS-104 tags: " + safe.size()); + } + for (Tag tag : safe) { + if (tag == null || tag.name() == null || tag.value() == null || tag.name().isEmpty() || tag.value().isEmpty()) { + throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty"); + } + if (utf8(tag.name()).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes"); + if (utf8(tag.value()).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes"); + } + return safe; + } + + private static byte[] serializeAvroTags(List tags) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + if (!tags.isEmpty()) { + writeAvroLong(out, tags.size()); + for (Tag tag : tags) { + writeAvroString(out, tag.name()); + writeAvroString(out, tag.value()); + } + } + writeAvroLong(out, 0); // array terminator; also the complete encoding for an empty array + byte[] raw = out.toByteArray(); + if (raw.length > MAX_TAGS_BYTES) throw new IllegalArgumentException("ANS-104 tags exceed " + MAX_TAGS_BYTES + " bytes"); + return raw; + } + + private static void writeAvroString(ByteArrayOutputStream out, String value) { + byte[] bytes = utf8(value); + writeAvroLong(out, bytes.length); + out.writeBytes(bytes); + } + + private static void writeAvroLong(ByteArrayOutputStream out, long value) { + long n = (value << 1) ^ (value >> 63); + while ((n & ~0x7fL) != 0) { + out.write((int) ((n & 0x7f) | 0x80)); + n >>>= 7; + } + out.write((int) n); + } + + private static void writeU16LE(ByteArrayOutputStream out, int value) { + out.write(value & 0xff); + out.write((value >>> 8) & 0xff); + } + + private static void writeU64LE(ByteArrayOutputStream out, long value) { + for (int i = 0; i < 8; i++) out.write((int) ((value >>> (8 * i)) & 0xff)); + } + + private static void requireLen(byte[] bytes, int expected, String name) { + if (bytes == null || bytes.length != expected) { + throw new IllegalArgumentException(name + " must be " + expected + " bytes"); + } + } + + private static List parseAvroTags(byte[] raw, long declaredCount) { + AvroCursor c = new AvroCursor(raw); + List out = new ArrayList<>(); + while (!c.eof()) { + long count = c.readLong(); + if (count == 0) break; + if (count < 0) { + count = -count; + long blockBytes = c.readLong(); + if (blockBytes < 0) throw new IllegalArgumentException("Negative Avro tag block size"); + } + if (count > MAX_TAGS_COUNT || out.size() + count > MAX_TAGS_COUNT) { + throw new IllegalArgumentException("Too many Avro tags"); + } + for (long i = 0; i < count; i++) { + String name = c.readString(); + String value = c.readString(); + if (name.isEmpty() || value.isEmpty()) throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty"); + if (name.getBytes(StandardCharsets.UTF_8).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes"); + if (value.getBytes(StandardCharsets.UTF_8).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes"); + out.add(new Tag(name, value)); + } + } + if (!c.eof()) { + throw new IllegalArgumentException("Trailing bytes in ANS-104 Avro tags"); + } + if (out.size() != declaredCount) { + throw new IllegalArgumentException("ANS-104 tags_count mismatch: declared=" + declaredCount + " parsed=" + out.size()); + } + return out; + } + + private static final class AvroCursor { + private final byte[] data; + private int p; + private AvroCursor(byte[] data) { this.data = data == null ? new byte[0] : data; } + boolean eof() { return p == data.length; } + long readLong() { + long raw = 0; + int shift = 0; + while (true) { + if (p >= data.length) throw new IllegalArgumentException("Truncated Avro long"); + int b = data[p++] & 0xff; + raw |= (long) (b & 0x7f) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 63) throw new IllegalArgumentException("Avro long too large"); + } + return (raw >>> 1) ^ -(raw & 1L); + } + String readString() { + long len = readLong(); + if (len < 0 || len > Integer.MAX_VALUE || p + len > data.length) { + throw new IllegalArgumentException("Bad Avro string length: " + len); + } + String s = new String(data, p, (int) len, StandardCharsets.UTF_8); + p += (int) len; + return s; + } + } + + private static byte[] deepHash(Object item) { + if (item instanceof byte[] bytes) { + return deepHashBlob(bytes); + } + if (item instanceof List items) { + byte[] acc = sha384(utf8("list" + items.size())); + for (Object child : items) { + acc = sha384(concat(acc, deepHash(child))); + } + return acc; + } + throw new IllegalArgumentException("Unsupported deepHash item: " + (item == null ? "null" : item.getClass())); + } + + private static byte[] deepHashBlob(byte[] bytes) { + byte[] tag = utf8("blob" + bytes.length); + return sha384(concat(sha384(tag), sha384(bytes))); + } + + private static byte[] utf8(String s) { return s.getBytes(StandardCharsets.UTF_8); } + private static byte[] sha256(byte[] b) { return digest("SHA-256", b); } + private static byte[] sha384(byte[] b) { return digest("SHA-384", b); } + private static byte[] digest(String alg, byte[] b) { + try { return MessageDigest.getInstance(alg).digest(b); } + catch (Exception e) { throw new IllegalStateException(alg + " unavailable", e); } + } + private static byte[] concat(byte[] a, byte[] b) { + ByteArrayOutputStream out = new ByteArrayOutputStream(a.length + b.length); + out.writeBytes(a); out.writeBytes(b); return out.toByteArray(); + } +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java index 255644d3..2e6d77be 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java @@ -9,364 +9,116 @@ import java.util.Arrays; import java.util.Objects; /** - * BchBlockEntry — универсальный блок формата SHiNE (Frame v0). + * SHiNE user block, Frame v1, carried as the data payload of an ANS-104 DataItem. * - * ========================================================================= - * FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА) - * ========================================================================= + * The constructor accepts the complete serialized ANS-104 DataItem because this is + * exactly what is stored in PostgreSQL and exchanged between servers. * - * Все числа BigEndian. + * SHiNE Frame v1 (all integer fields BigEndian): + * [2] frameCode = 0x0001 + * [32] prevHash32 = SHA-256(Frame v1 bytes of previous SHiNE block) + * [4] blockSize = exact Frame v1 byte length + * [4] blockNumber + * [8] timestamp unix seconds + * [2] type + * [2] subType + * [2] version + * [N] bodyBytes * - * PREIMAGE (входит в blockSize, подписывается): - * [2] frameCode (uint16) код/версия рамки: - * - 0x0000 = Frame v0 (текущий) - * [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка) - * [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode, - * НО БЕЗ sigMarker и БЕЗ signature64 - * [4] blockNumber (int32) глобальный номер блока (>=0) - * [8] timestamp (int64) unix seconds - * [2] type (uint16) тип сообщения - * [2] subType (uint16) подтип сообщения - * [2] version (uint16) версия формата сообщения - * [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version) - * - * TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0): - * [2] sigMarker (uint16) маркер подписи: - * - 0x0100 (256) = далее подпись Ed25519 64 байта - * [64] signature64 (bytes) Ed25519 signature над hash32 - * - * hash32 НЕ хранится в блоке. - * hash32 вычисляется при парсинге: - * preimage = первые blockSize байт - * hash32 = SHA-256(preimage) - * - * Правила MVP-парсера (Frame v0): - * - frameCode должен быть строго 0x0000, иначе REJECT. - * - sigMarker должен быть строго 0x0100, иначе REJECT. - * - подпись обязана присутствовать всегда (sigMarker+signature64). - * - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”. - * - * Важно по безопасности: - * - sigMarker в v0 не входит в подписываемые байты → его можно подменить, - * поэтому единственная безопасная логика: "если не 0x0100 — reject". - * ========================================================================= + * The user signature is NOT stored in this frame. It is the single Ed25519/Solana + * signature of the surrounding ANS-104 DataItem. */ public final class BchBlockEntry { - - public static final int SIGNATURE_LEN = 64; public static final int HASH_LEN = 32; - - public static final int FRAME_CODE_LEN = 2; - public static final int SIG_MARKER_LEN = 2; - - /** Frame v0 */ - public static final int FRAME_CODE_V0 = 0x0000; - - /** sigMarker: 256 = 0x0100 */ - public static final int SIG_MARKER_ED25519 = 0x0100; - - /** - * Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature), - * чтобы не уложить сервер по памяти/диску. - */ - public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024; - - /** - * Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов). - * Если timestamp больше now + 60 сек — блок считаем неверным. - */ + public static final int FRAME_CODE_V1 = 0x0001; + public static final int MAX_BLOCK_FULL_BYTES = Ans104DataItem.MAX_DATA_ITEM_BYTES; public static final long MAX_FUTURE_SECONDS = 60; + public static final int FRAME_HEADER_SIZE = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2; - /** - * Размер фиксированной части PREIMAGE (без bodyBytes). - * - * PREIMAGE header: - * frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8) - * + type(2) + subType(2) + version(2) - */ - public static final int PREIMAGE_HEADER_SIZE = - 2 // frameCode - + 32 // prevHash32 - + 4 // blockSize - + 4 // blockNumber - + 8 // timestamp - + 2 // type - + 2 // subType - + 2; // version - - /** Минимальный полный размер блока (без bodyBytes). */ - public static final int MIN_FULL_BYTES = - PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN; - - // --- HEADER (PREIMAGE) --- - public final int frameCode; // uint16 (v0=0) - public final byte[] prevHash32; // 32 - public final int blockSize; // preimage size (включая frameCode) - public final int blockNumber; // >=0 + public final int frameCode; + public final byte[] prevHash32; + public final int blockSize; + public final int blockNumber; public final long timestamp; public final short type; public final short subType; public final short version; - - // --- BODY (PREIMAGE) --- public final byte[] bodyBytes; - - /** Распарсенное тело (создаётся сразу при парсинге блока). */ public final BodyRecord body; - // --- TAIL --- - public final int sigMarker; // uint16 (v0: 0x0100) - private final byte[] signature64; // 64 + private final Ans104DataItem dataItem; + private final byte[] frameBytes; + private final byte[] hash32; - // --- derived --- - private final byte[] hash32; // 32, computed - private final byte[] preimage; // blockSize bytes - private final byte[] fullBytes; // preimage + sigMarker + signature - - /* ===================================================================== */ - /* ====================== Конструктор из байт ========================== */ - /* ===================================================================== */ - - public BchBlockEntry(byte[] fullBytes) { - Objects.requireNonNull(fullBytes, "fullBytes == null"); - - if (fullBytes.length < MIN_FULL_BYTES) { - throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES); - } - if (fullBytes.length > MAX_BLOCK_FULL_BYTES) { - throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES); + /** Parse a complete serialized ANS-104 DataItem containing one SHiNE Frame v1. */ + public BchBlockEntry(byte[] fullDataItemBytes) { + Objects.requireNonNull(fullDataItemBytes, "fullDataItemBytes == null"); + this.dataItem = new Ans104DataItem(fullDataItemBytes); + this.frameBytes = dataItem.data(); + if (frameBytes.length < FRAME_HEADER_SIZE) { + throw new IllegalArgumentException("SHiNE Frame v1 too short: " + frameBytes.length); } - ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN); - - // [2] frameCode + ByteBuffer bb = ByteBuffer.wrap(frameBytes).order(ByteOrder.BIG_ENDIAN); this.frameCode = Short.toUnsignedInt(bb.getShort()); - if (this.frameCode != FRAME_CODE_V0) { - throw new IllegalArgumentException(String.format( - "Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0 - )); + if (frameCode != FRAME_CODE_V1) { + throw new IllegalArgumentException(String.format("Bad SHiNE frameCode: 0x%04X (expected 0x%04X)", frameCode, FRAME_CODE_V1)); } - - // [32] prevHash32 this.prevHash32 = new byte[32]; - bb.get(this.prevHash32); - - // [4] blockSize + bb.get(prevHash32); this.blockSize = bb.getInt(); - if (blockSize < PREIMAGE_HEADER_SIZE) { - throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE); + if (blockSize != frameBytes.length || blockSize < FRAME_HEADER_SIZE) { + throw new IllegalArgumentException("SHiNE blockSize mismatch: declared=" + blockSize + " actual=" + frameBytes.length); } - - // fullLen must match exactly: blockSize + sigMarker(2) + signature(64) - int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN; - if (expectedFullLen != fullBytes.length) { - throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize - + " expectedFullLen=" + expectedFullLen - + " fullLen=" + fullBytes.length); - } - if (expectedFullLen > MAX_BLOCK_FULL_BYTES) { - throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES); - } - - // [4] blockNumber this.blockNumber = bb.getInt(); - if (this.blockNumber < 0) { - throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber); - } - - // [8] timestamp + if (blockNumber < 0) throw new IllegalArgumentException("blockNumber < 0: " + blockNumber); this.timestamp = bb.getLong(); - - // запрет “в будущее” больше чем на 1 минуту long now = Instant.now().getEpochSecond(); - if (this.timestamp > now + MAX_FUTURE_SECONDS) { - throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp - + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS); + if (timestamp > now + MAX_FUTURE_SECONDS) { + throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp + " now=" + now); } - - // [2][2][2] type/subType/version this.type = bb.getShort(); this.subType = bb.getShort(); this.version = bb.getShort(); - - // [N] bodyBytes - int bodyLen = blockSize - PREIMAGE_HEADER_SIZE; - if (bodyLen < 0) { - throw new IllegalArgumentException("Invalid body length: " + bodyLen); - } - this.bodyBytes = new byte[bodyLen]; - bb.get(this.bodyBytes); - - // TAIL: [2] sigMarker - this.sigMarker = Short.toUnsignedInt(bb.getShort()); - if (this.sigMarker != SIG_MARKER_ED25519) { - throw new IllegalArgumentException(String.format( - "Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519 - )); - } - - // TAIL: [64] signature64 - this.signature64 = new byte[SIGNATURE_LEN]; - bb.get(this.signature64); - - // preimage = первые blockSize байт (включая frameCode) - this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize); - - // hash32 = sha256(preimage) - this.hash32 = BchCryptoVerifier.sha256(preimage); - - // parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check() - this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes); - - this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length); - - if (bb.remaining() != 0) { - throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); - } + this.bodyBytes = new byte[bb.remaining()]; + bb.get(bodyBytes); + this.body = BodyRecordParser.parse(type, subType, version, bodyBytes); + this.hash32 = BchCryptoVerifier.sha256(frameBytes); } - /* ===================================================================== */ - /* ====================== Конструктор сборки ============================ */ - /* ===================================================================== */ - - public BchBlockEntry(byte[] prevHash32, - int blockNumber, - long timestamp, - short type, - short subType, - short version, - byte[] bodyBytes, - byte[] signature64) { - - Objects.requireNonNull(prevHash32, "prevHash32 == null"); - Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - Objects.requireNonNull(signature64, "signature64 == null"); - - if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32"); - if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64"); - - if (blockNumber < 0) { - throw new IllegalArgumentException("blockNumber < 0: " + blockNumber); - } - - // запрет “в будущее” больше чем на 1 минуту - long now = Instant.now().getEpochSecond(); - if (timestamp > now + MAX_FUTURE_SECONDS) { - throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp - + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS); - } - - this.frameCode = FRAME_CODE_V0; - this.prevHash32 = Arrays.copyOf(prevHash32, 32); - this.blockNumber = blockNumber; - this.timestamp = timestamp; - this.type = type; - this.subType = subType; - this.version = version; - this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length); - - // blockSize = размер preimage (включая frameCode) - this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length; - - int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN; - if (fullLen > MAX_BLOCK_FULL_BYTES) { - throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES); - } - - // parse body по header + ОБЯЗАТЕЛЬНЫЙ check() - this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes); - - // tail marker фиксирован - this.sigMarker = SIG_MARKER_ED25519; - this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN); - - // build preimage - ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN); - pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF)); - pre.put(this.prevHash32); - pre.putInt(this.blockSize); - pre.putInt(this.blockNumber); - pre.putLong(this.timestamp); - pre.putShort(this.type); - pre.putShort(this.subType); - pre.putShort(this.version); - pre.put(this.bodyBytes); - - this.preimage = pre.array(); - this.hash32 = BchCryptoVerifier.sha256(preimage); - - // build fullBytes: preimage + sigMarker + signature64 - ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN); - full.put(this.preimage); - full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF)); - full.put(this.signature64); - this.fullBytes = full.array(); - } - - /* ===================================================================== */ - /* ============================ Getters ================================= */ - /* ===================================================================== */ - - public byte[] getPreimageBytes() { - return Arrays.copyOf(preimage, preimage.length); - } - - /** Возвращает подпись Ed25519 (64 байта). */ - public byte[] getSignature64() { - return Arrays.copyOf(signature64, SIGNATURE_LEN); - } - - /** Возвращает hash32 = SHA-256(preimage). */ - public byte[] getHash32() { - return Arrays.copyOf(hash32, HASH_LEN); - } - - /** Возвращает полный блок: preimage + sigMarker + signature. */ - public byte[] toBytes() { - return Arrays.copyOf(fullBytes, fullBytes.length); - } + public Ans104DataItem getDataItem() { return dataItem; } + public byte[] getFrameBytes() { return Arrays.copyOf(frameBytes, frameBytes.length); } + /** Kept as an alias for code/docs that called the old field preimage. */ + public byte[] getPreimageBytes() { return getFrameBytes(); } + public byte[] getSignature64() { return dataItem.signature64(); } + public byte[] getOwner32() { return dataItem.owner32(); } + public byte[] getDataItemId32() { return dataItem.id32(); } + public byte[] getHash32() { return Arrays.copyOf(hash32, hash32.length); } + /** Full serialized ANS-104 DataItem. */ + public byte[] toBytes() { return dataItem.rawBytes(); } @Override public String toString() { - String timeIso; - try { - timeIso = Instant.ofEpochSecond(timestamp).toString(); - } catch (Exception e) { - timeIso = "некорректныйTimestamp"; - } - - return "BchBlockEntry{" - + "FRAME{frameCode=0x" + hex4(frameCode) - + "}, HDR{" - + "blockSize=" + blockSize - + ", blockNumber=" + blockNumber - + ", timestamp=" + timestamp + " (" + timeIso + ")" - + ", type=" + (type & 0xFFFF) - + ", subType=" + (subType & 0xFFFF) - + ", version=" + (version & 0xFFFF) - + ", prevHash32(hex)=" + toHex(prevHash32) - + "}" - + ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}" - + ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}" - + ", DERIVED{hash32(hex)=" + toHex(hash32) + "}" - + "}"; - } - - private static String hex4(int v) { - String s = Integer.toHexString(v & 0xFFFF); - while (s.length() < 4) s = "0" + s; - return s; + return "BchBlockEntry{" + + "frameCode=0x" + Integer.toHexString(frameCode) + + ", blockNumber=" + blockNumber + + ", timestamp=" + timestamp + + ", type=" + (type & 0xffff) + + ", subType=" + (subType & 0xffff) + + ", version=" + (version & 0xffff) + + ", bodyLen=" + bodyBytes.length + + ", dataItemId=" + toHex(getDataItemId32()) + + ", blockHash=" + toHex(hash32) + + '}'; } private static String toHex(byte[] bytes) { - if (bytes == null) return "null"; - char[] HEX = "0123456789abcdef".toCharArray(); + char[] h = "0123456789abcdef".toCharArray(); char[] out = new char[bytes.length * 2]; for (int i = 0; i < bytes.length; i++) { - int vv = bytes[i] & 0xFF; - out[i * 2] = HEX[vv >>> 4]; - out[i * 2 + 1] = HEX[vv & 0x0F]; + int v = bytes[i] & 0xff; + out[i * 2] = h[v >>> 4]; out[i * 2 + 1] = h[v & 15]; } return new String(out); } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java index ca657e80..8a8ddb49 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java @@ -1,42 +1,27 @@ package blockchain; -import utils.crypto.Ed25519Util; - import java.security.MessageDigest; import java.util.Objects; -/** - * Верификатор SHiNE (Frame v0): - * - * preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000), - * = всё до TAIL (sigMarker+signature). - * - * hash32 = SHA-256(preimage) - * verify = Ed25519.verify(hash32, signature64, pubKey32) - */ +/** Cryptographic checks for SHiNE Frame v1 + surrounding ANS-104 DataItem. */ public final class BchCryptoVerifier { - private BchCryptoVerifier() {} public static byte[] sha256(byte[] data) { Objects.requireNonNull(data, "data == null"); - try { - MessageDigest d = MessageDigest.getInstance("SHA-256"); - return d.digest(data); - } catch (Exception e) { - throw new IllegalStateException("SHA-256 unavailable", e); - } + try { return MessageDigest.getInstance("SHA-256").digest(data); } + catch (Exception e) { throw new IllegalStateException("SHA-256 unavailable", e); } } + /** + * Verifies that the ANS-104 owner is the current blockchain key and that the + * ANS-104 Ed25519 signature is valid. The SHiNE block hash itself remains + * SHA-256(Frame v1 bytes) and is intentionally independent of Arweave. + */ public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) { Objects.requireNonNull(block, "block == null"); Objects.requireNonNull(publicKey32, "publicKey32 == null"); - if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32"); - - byte[] hash32 = block.getHash32(); - byte[] sig64 = block.getSignature64(); - - return Ed25519Util.verify(hash32, sig64, publicKey32); + return block.getDataItem().ownerEquals(publicKey32) && block.getDataItem().verifySignature(); } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java b/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java deleted file mode 100644 index a7550fb9..00000000 --- a/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java +++ /dev/null @@ -1,297 +0,0 @@ -package utils.files; - -import java.io.IOException; -import java.nio.file.*; -import java.util.Objects; - -/** - * =============================================================== - * FileStoreUtil — утилита работы с файлами в папке data/. - * - * Теперь поддерживает: - * - основной файл блокчейна: .bch - * - временный файл блокчейна: .tmp_bch - * - sidecar-файл проверки записи: .write_check - * - marker-файл записи: .write_pending - * - * Важное: - * - validateSimpleFileName() запрещает path traversal. - * - atomicReplaceBlockchainFile(): пытается сделать ATOMIC_MOVE (если ФС поддерживает), - * иначе делает обычный REPLACE_EXISTING move. - * =============================================================== - */ -public final class FileStoreUtil { - - /** Базовая папка для хранения всех файлов (создаётся автоматически). */ - public static final String DATA_DIR_NAME = "data"; - - /** Расширение основного файла блокчейна. */ - public static final String BLOCKCHAIN_FILE_EXTENSION = ".bch"; - - /** Расширение временного файла (старое+новое). */ - public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch"; - - /** Маркер того, что chain сейчас в процессе полного resync. */ - public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending"; - - /** Marker того, что обычный AddBlock находится в опасной фазе записи. */ - public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending"; - - /** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */ - public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check"; - - private static final FileStoreUtil INSTANCE = new FileStoreUtil(); - - private final Path dataDirPath; - - private FileStoreUtil() { - this.dataDirPath = Paths.get(DATA_DIR_NAME); - ensureDataDirExists(); - } - - public static FileStoreUtil getInstance() { - return INSTANCE; - } - - /* ===================================================================== */ - /* ======================== Базовые операции =========================== */ - /* ===================================================================== */ - - public void newFile(String fileName, byte[] data) { - Objects.requireNonNull(data, "data == null"); - Path target = resolveSafe(fileName); - try { - Files.write(target, data, - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING, - StandardOpenOption.WRITE); - } catch (IOException e) { - throw new IllegalStateException("Не удалось записать файл: " + target, e); - } - } - - public void addDataToFile(String fileName, byte[] data) { - Objects.requireNonNull(data, "data == null"); - Path target = resolveSafe(fileName); - try { - Files.write(target, data, - StandardOpenOption.CREATE, - StandardOpenOption.WRITE, - StandardOpenOption.APPEND); - } catch (IOException e) { - throw new IllegalStateException("Не удалось дописать файл: " + target, e); - } - } - - public byte[] readAllDataFromFile(String fileName) { - Path target = resolveSafe(fileName); - if (!Files.exists(target)) { - throw new IllegalStateException("Файл не найден: " + target); - } - try { - return Files.readAllBytes(target); - } catch (IOException e) { - throw new IllegalStateException("Не удалось прочитать файл: " + target, e); - } - } - - public boolean exists(String fileName) { - Path target = resolveSafe(fileName); - return Files.exists(target); - } - - public long size(String fileName) { - Path target = resolveSafe(fileName); - try { - return Files.size(target); - } catch (IOException e) { - throw new IllegalStateException("Не удалось получить размер файла: " + target, e); - } - } - - /* ===================================================================== */ - /* ===================== Блокчейн-файлы по имени ======================= */ - /* ===================================================================== */ - - /** .bch */ - public String buildBlockchainFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_FILE_EXTENSION; - } - - /** .tmp_bch */ - public String buildBlockchainTmpFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_TMP_EXTENSION; - } - - public Path resolveBlockchainPath(String blockchainName) { - return resolveSafe(buildBlockchainFileName(blockchainName)); - } - - public Path resolveBlockchainTmpPath(String blockchainName) { - return resolveSafe(buildBlockchainTmpFileName(blockchainName)); - } - - public byte[] readBlockchain(String blockchainName) { - return readAllDataFromFile(buildBlockchainFileName(blockchainName)); - } - - public void writeBlockchainTmp(String blockchainName, byte[] data) { - newFile(buildBlockchainTmpFileName(blockchainName), data); - } - - /** .write_check */ - public String buildBlockchainWriteCheckFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION; - } - - public Path resolveBlockchainWriteCheckPath(String blockchainName) { - return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName)); - } - - public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) { - StringBuilder sb = new StringBuilder(128); - sb.append("blockNumber=").append(blockNumber).append('\n'); - sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n'); - newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); - } - - /** .write_pending */ - public String buildBlockchainWritePendingMarkerFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION; - } - - public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) { - return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName)); - } - - public void writeBlockchainWritePendingMarker(String blockchainName) { - newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]); - } - - /** .resync_pending */ - public String buildBlockchainResyncMarkerFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION; - } - - public Path resolveBlockchainResyncMarkerPath(String blockchainName) { - return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName)); - } - - public void writeBlockchainResyncMarker(String blockchainName, String markerContent) { - byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8); - newFile(buildBlockchainResyncMarkerFileName(blockchainName), data); - } - - public void deleteIfExists(Path path) { - if (path == null) { - return; - } - try { - Files.deleteIfExists(path); - } catch (IOException e) { - throw new IllegalStateException("Не удалось удалить файл: " + path, e); - } - } - - public void deleteBlockchainFileIfExists(String blockchainName) { - deleteIfExists(resolveBlockchainPath(blockchainName)); - } - - public void deleteBlockchainTmpFileIfExists(String blockchainName) { - deleteIfExists(resolveBlockchainTmpPath(blockchainName)); - } - - public void deleteBlockchainWriteCheckIfExists(String blockchainName) { - deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName)); - } - - public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) { - deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName)); - } - - public void deleteBlockchainResyncMarkerIfExists(String blockchainName) { - deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName)); - } - - public boolean existsBlockchainResyncMarker(String blockchainName) { - return exists(buildBlockchainResyncMarkerFileName(blockchainName)); - } - - /** - * Атомарно заменить основной файл блокчейна временным: - * .tmp_bch -> .bch - * - * Стратегия: - * 1) Пытаемся Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING) - * 2) Если ATOMIC_MOVE не поддерживается — делаем move с REPLACE_EXISTING без атомарности - * - * Важный нюанс: - * - атомарность гарантируется только в пределах одной файловой системы. - */ - public void atomicReplaceBlockchainFile(String blockchainName) { - Path tmp = resolveBlockchainTmpPath(blockchainName); - Path main = resolveBlockchainPath(blockchainName); - - if (!Files.exists(tmp)) { - throw new IllegalStateException("TMP-файл не найден: " + tmp); - } - - try { - // 1) Пытаемся атомарный move - Files.move(tmp, main, - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE); - } catch (AtomicMoveNotSupportedException e) { - // 2) Если ФС не поддерживает атомарный move — делаем обычный replace - try { - Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException ex) { - throw new IllegalStateException("Не удалось заменить файл блокчейна (non-atomic): " + main, ex); - } - } catch (IOException e) { - throw new IllegalStateException("Не удалось заменить файл блокчейна (atomic): " + main, e); - } - } - - /* ===================================================================== */ - /* ============================ Helpers ================================= */ - /* ===================================================================== */ - - private void ensureDataDirExists() { - try { - if (!Files.exists(dataDirPath)) { - Files.createDirectories(dataDirPath); - } - } catch (IOException e) { - throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e); - } - } - - private Path resolveSafe(String fileName) { - validateSimpleFileName(fileName); - return dataDirPath.resolve(fileName); - } - - /** - * Валидация "простого имени": - * - запрещаем слэши, обратные слэши, ".." - * - запрещаем пустоту - * - * Важно: сюда у нас попадает и blockchainName (как часть имени файла), - * поэтому blockchainName должен быть "простым": без путей. - */ - private void validateSimpleFileName(String fileName) { - Objects.requireNonNull(fileName, "fileName == null"); - if (fileName.isBlank()) { - throw new IllegalArgumentException("Имя файла не должно быть пустым"); - } - if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) { - throw new IllegalArgumentException("Недопустимое имя файла: " + fileName); - } - } -} diff --git a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java new file mode 100644 index 00000000..ec120078 --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java @@ -0,0 +1,74 @@ +package blockchain; + +import org.junit.jupiter.api.Test; +import utils.crypto.Ed25519Util; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +final class Ans104DataItemTest { + @Test + void ed25519RoundTripWithTestAndChannelTags() { + byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-test-key"); + byte[] owner = Ed25519Util.derivePublicKey(privateKey); + byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8); + List tags = List.of( + new Ans104DataItem.Tag("App", "test5590"), + new Ans104DataItem.Tag("c", "books") + ); + + byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data); + byte[] signature = Ed25519Util.sign(message, privateKey); + byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data); + + Ans104DataItem parsed = new Ans104DataItem(raw); + assertEquals(2, parsed.signatureType()); + assertArrayEquals(owner, parsed.owner32()); + assertArrayEquals(data, parsed.data()); + assertTrue(parsed.hasTag("App", "test5590")); + assertEquals("books", parsed.tagValue("c")); + assertTrue(parsed.verifySignature()); + assertEquals(32, parsed.id32().length); + } + + @Test + void mutationBreaksSignature() { + byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-mutation-key"); + byte[] owner = Ed25519Util.derivePublicKey(privateKey); + byte[] data = "original".getBytes(StandardCharsets.UTF_8); + List tags = List.of(new Ans104DataItem.Tag("App", "test5590")); + byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey); + byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data); + raw[raw.length - 1] ^= 1; + assertFalse(new Ans104DataItem(raw).verifySignature()); + } + @Test + void signingMessageMatchesArbundlesTurboCompatibilityVector() { + byte[] owner = new byte[32]; + for (int i = 0; i < owner.length; i++) owner[i] = (byte) i; + byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8); + List tags = List.of( + new Ans104DataItem.Tag("App", "test5590"), + new Ans104DataItem.Tag("c", "books") + ); + + byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data); + byte[] expected = hex("7e67d0debce103606d697a1e3785130ca20cb89cd8a06f9a65b98b2d2427eaf411fcf7d482da268e44f3ac25c57c3cb9"); + assertArrayEquals(expected, actual); + } + + private static byte[] hex(String value) { + if ((value.length() & 1) != 0) throw new IllegalArgumentException("odd hex length"); + byte[] out = new byte[value.length() / 2]; + for (int i = 0; i < out.length; i++) { + int hi = Character.digit(value.charAt(i * 2), 16); + int lo = Character.digit(value.charAt(i * 2 + 1), 16); + if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex"); + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + +} diff --git a/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md b/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md index 3e132bed..20b4dcc5 100644 --- a/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md +++ b/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md @@ -1,7 +1,7 @@ # utils.crypto Пакет отвечает за криптографию — подписи и хэши блоков. -Используется при создании и проверке целостности `.bch`-блоков. +Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются. --- diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index fa153599..b5f838ff 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -40,6 +40,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_21 = 21; public static final int SCHEMA_VERSION_22 = 22; public static final int SCHEMA_VERSION_23 = 23; + public static final int SCHEMA_VERSION_24 = 24; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -63,6 +64,7 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql"; public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql"; public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql"; + public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql"; private DatabaseInitializer() {} @@ -224,6 +226,10 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE); currentVersion = SCHEMA_VERSION_23; } + if (currentVersion < SCHEMA_VERSION_24) { + runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE); + currentVersion = SCHEMA_VERSION_24; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java deleted file mode 100644 index 027d6b03..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java +++ /dev/null @@ -1,8 +0,0 @@ -package shine.db.archive; - -/** Ссылка на уже успешно опубликованный большой архивный блок. */ -public record ArchiveBigBlockRef( - long bigBlockNumber, - byte[] archiveHash, - byte[] arweaveTxId -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java deleted file mode 100644 index 111afa2e..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java +++ /dev/null @@ -1,14 +0,0 @@ -package shine.db.archive; - -/** Последний известный архивный chunk конкретной blockchain на этом сервере. */ -public record ArchiveBlockchainLocation( - String blockchainName, - String publisherLogin, - String arweaveTxId, - byte[] archiveHash, - long bigBlockNumber, - long chunkOffset, - long chunkSize, - long sourceLastBlockNumber, - long updatedAtMs -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java deleted file mode 100644 index db9cd0ea..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java +++ /dev/null @@ -1,15 +0,0 @@ -package shine.db.archive; - -/** - * Последнее окончательно опубликованное архивное положение одной SHiNE blockchain. - */ -public record ArchiveChainCursor( - String blockchainName, - long lastArchivedSourceBlockNumber, - byte[] lastArchivedSourceBlockHash, - long lastArchiveBigBlockNumber, - byte[] lastArchiveBigBlockHash, - long lastChunkOffset, - long lastChunkSize, - long updatedAtMs -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java deleted file mode 100644 index 9fff0a20..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java +++ /dev/null @@ -1,16 +0,0 @@ -package shine.db.archive; - -/** Crash-safe состояние публикации одного большого SHINE-ARCHIVE блока. */ -public record ArchivePublishJob( - long id, - long bigBlockNumber, - String status, - long createdAtMs, - String localArchivePath, - byte[] archiveHash, - byte[] arweaveTxId, - Integer arweaveConfirmations, - String solanaSignature, - String errorText, - long updatedAtMs -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java deleted file mode 100644 index 36a665a8..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java +++ /dev/null @@ -1,17 +0,0 @@ -package shine.db.archive; - -/** Замороженный диапазон одной blockchain внутри конкретной архивной публикации. */ -public record ArchivePublishJobChain( - long jobId, - String blockchainName, - long fromSourceBlockNumber, - long toSourceBlockNumber, - byte[] previousSourceBlockHash, - byte[] lastSourceBlockHash, - Long previousArchiveBigBlockNumber, - byte[] previousArchiveBigBlockHash, - Long previousChunkOffset, - Long previousChunkSize, - Long newChunkOffset, - Long newChunkSize -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java deleted file mode 100644 index a3ddeb6a..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java +++ /dev/null @@ -1,14 +0,0 @@ -package shine.db.archive; - -/** - * Текущая archive head trusted publisher-а из локальной таблицы, - * которую уже заполнил обычный Solana users sync. - */ -public record ArchivePublisherHead( - String login, - String rootKey, - String archiveHeadTxId, - String archiveHeadHash, - boolean archiveImported, - String lastImportedArchiveTxId -) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java deleted file mode 100644 index 6fd3def3..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java +++ /dev/null @@ -1,150 +0,0 @@ -package shine.db.dao; - -import shine.db.DbController; -import shine.db.archive.ArchiveBlockchainLocation; -import shine.db.archive.ArchivePublisherHead; - -import java.sql.*; -import java.util.*; - -/** БД индекс последних archive chunks и локальное состояние импорта archive head. */ -public final class ArchiveImportDAO { - private static volatile ArchiveImportDAO instance; - private final DbController db = DbController.getInstance(); - - private ArchiveImportDAO() {} - - public static ArchiveImportDAO getInstance() { - if (instance == null) synchronized (ArchiveImportDAO.class) { - if (instance == null) instance = new ArchiveImportDAO(); - } - return instance; - } - - /** - * Возвращает только доверенных publisher-ов, для которых обычный Solana users sync - * уже увидел archive head, но этот head ещё не был полностью импортирован локально. - */ - public List listPendingPublisherHeads(Set approvedLogins) throws SQLException { - if (approvedLogins == null || approvedLogins.isEmpty()) return List.of(); - List out = new ArrayList<>(); - String sql = """ - SELECT login, root_key, archive_head_tx_id, archive_head_hash, - archive_imported, archive_last_imported_tx_id - FROM solana_user_pda_current - WHERE LOWER(login)=LOWER(?) - AND is_server=TRUE - AND archive_head_tx_id<>'' - AND archive_head_hash<>'' - AND archive_imported=FALSE - """; - try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { - for (String login : approvedLogins) { - ps.setString(1, login); - try (ResultSet rs = ps.executeQuery()) { - if (rs.next()) { - out.add(new ArchivePublisherHead( - rs.getString("login"), - rs.getString("root_key"), - rs.getString("archive_head_tx_id"), - rs.getString("archive_head_hash"), - rs.getBoolean("archive_imported"), - rs.getString("archive_last_imported_tx_id"))); - } - } - } - } - return out; - } - - /** - * Сдвигает локальный курсор импорта внутри FULL archive chain. - * Update выполняется только если текущий head всё ещё тот же, с которым работает importer. - */ - public boolean advanceImportCursor(String publisherLogin, String expectedHeadTxId, String importedTxId) throws SQLException { - String sql = """ - UPDATE solana_user_pda_current - SET archive_last_imported_tx_id=? - WHERE LOWER(login)=LOWER(?) - AND archive_head_tx_id=? - """; - try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, importedTxId == null ? "" : importedTxId); - ps.setString(2, publisherLogin); - ps.setString(3, expectedHeadTxId); - return ps.executeUpdate() == 1; - } - } - - /** - * Помечает именно текущий head как полностью импортированный. - * Если Solana users sync успел заменить head на более новый, update не произойдёт. - */ - public boolean markHeadImported(String publisherLogin, String expectedHeadTxId) throws SQLException { - String sql = """ - UPDATE solana_user_pda_current - SET archive_imported=TRUE, - archive_last_imported_tx_id=archive_head_tx_id - WHERE LOWER(login)=LOWER(?) - AND archive_head_tx_id=? - """; - try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, publisherLogin); - ps.setString(2, expectedHeadTxId); - return ps.executeUpdate() == 1; - } - } - - public void upsertLocation(ArchiveBlockchainLocation e) throws SQLException { - String sql=""" - INSERT INTO archive_blockchain_location( - blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number, - chunk_offset,chunk_size,source_last_block_number,updated_at_ms) - VALUES(?,?,?,?,?,?,?,?,?) - ON CONFLICT(blockchain_name) DO UPDATE SET - publisher_login=EXCLUDED.publisher_login,arweave_tx_id=EXCLUDED.arweave_tx_id, - archive_hash=EXCLUDED.archive_hash,big_block_number=EXCLUDED.big_block_number, - chunk_offset=EXCLUDED.chunk_offset,chunk_size=EXCLUDED.chunk_size, - source_last_block_number=EXCLUDED.source_last_block_number,updated_at_ms=EXCLUDED.updated_at_ms - WHERE EXCLUDED.source_last_block_number >= archive_blockchain_location.source_last_block_number - """; - try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) { - ps.setString(1,e.blockchainName()); ps.setString(2,e.publisherLogin()); ps.setString(3,e.arweaveTxId()); - ps.setBytes(4,e.archiveHash()); ps.setLong(5,e.bigBlockNumber()); ps.setLong(6,e.chunkOffset()); - ps.setLong(7,e.chunkSize()); ps.setLong(8,e.sourceLastBlockNumber()); ps.setLong(9,e.updatedAtMs()); ps.executeUpdate(); - } - } - - public ArchiveBlockchainLocation getLocation(String blockchainName) throws SQLException { - String sql=""" - SELECT blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number, - chunk_offset,chunk_size,source_last_block_number,updated_at_ms - FROM archive_blockchain_location WHERE LOWER(blockchain_name)=LOWER(?) - """; - try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) { - ps.setString(1,blockchainName); - try(ResultSet rs=ps.executeQuery()) { - if(!rs.next()) return null; - return new ArchiveBlockchainLocation(rs.getString(1),rs.getString(2),rs.getString(3),rs.getBytes(4), - rs.getLong(5),rs.getLong(6),rs.getLong(7),rs.getLong(8),rs.getLong(9)); - } - } - } - - /** Данные PDA, нужные для создания пустого локального blockchain_state перед первым архивным импортом. */ - public BlockchainIdentity getBlockchainIdentity(String blockchainName) throws SQLException { - String sql=""" - SELECT login, blockchain_name, blockchain_key, paid_limit_bytes - FROM solana_user_pda_current WHERE LOWER(blockchain_name)=LOWER(?) LIMIT 1 - """; - try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) { - ps.setString(1,blockchainName); - try(ResultSet rs=ps.executeQuery()) { - if(!rs.next()) return null; - return new BlockchainIdentity(rs.getString(1),rs.getString(2),rs.getString(3),rs.getLong(4)); - } - } - } - - public record BlockchainIdentity(String login,String blockchainName,String blockchainKey,long sizeLimit) {} -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java deleted file mode 100644 index 0d241896..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java +++ /dev/null @@ -1,472 +0,0 @@ -package shine.db.dao; - -import shine.db.DbController; -import shine.db.archive.ArchiveBigBlockRef; -import shine.db.archive.ArchiveChainCursor; -import shine.db.archive.ArchivePublishJob; -import shine.db.archive.ArchivePublishJobChain; - -import java.sql.*; -import java.util.ArrayList; -import java.util.List; - -/** - * DAO архивного publisher-а. Все курсоры считаются подтверждёнными только после Solana finalized. - */ -public final class ArchivePublicationDAO { - private static volatile ArchivePublicationDAO instance; - private final DbController db = DbController.getInstance(); - - private ArchivePublicationDAO() {} - - public static ArchivePublicationDAO getInstance() { - if (instance == null) { - synchronized (ArchivePublicationDAO.class) { - if (instance == null) instance = new ArchivePublicationDAO(); - } - } - return instance; - } - - public ArchiveChainCursor getCursor(String blockchainName) throws SQLException { - try (Connection c = db.getConnection()) { - return getCursor(c, blockchainName); - } - } - - public ArchiveChainCursor getCursor(Connection c, String blockchainName) throws SQLException { - String sql = """ - SELECT blockchain_name, last_archived_source_block_number, last_archived_source_block_hash, - last_archive_big_block_number, last_archive_big_block_hash, - last_chunk_offset, last_chunk_size, updated_at_ms - FROM archive_chain_cursor - WHERE blockchain_name = ? - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, blockchainName); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapCursor(rs); - } - } - } - - public long nextBigBlockNumber() throws SQLException { - String sql = """ - SELECT COALESCE(MAX(big_block_number), 0) + 1 - FROM archive_publish_job - WHERE status = 'CURSORS_COMMITTED' - """; - try (Connection c = db.getConnection(); - PreparedStatement ps = c.prepareStatement(sql); - ResultSet rs = ps.executeQuery()) { - rs.next(); - return rs.getLong(1); - } - } - - public List listFinalizedBigBlocks() throws SQLException { - String sql = """ - SELECT big_block_number, archive_hash, arweave_tx_id - FROM archive_publish_job - WHERE status = 'CURSORS_COMMITTED' - AND archive_hash IS NOT NULL - AND arweave_tx_id IS NOT NULL - ORDER BY big_block_number ASC - """; - List out = new ArrayList<>(); - try (Connection c = db.getConnection(); - PreparedStatement ps = c.prepareStatement(sql); - ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - out.add(new ArchiveBigBlockRef( - rs.getLong("big_block_number"), - rs.getBytes("archive_hash"), - rs.getBytes("arweave_tx_id") - )); - } - } - return out; - } - - public ArchivePublishJob findUnfinishedJob() throws SQLException { - String sql = """ - SELECT id, big_block_number, status, created_at_ms, local_archive_path, - archive_hash, arweave_tx_id, arweave_confirmations, solana_signature, - error_text, updated_at_ms - FROM archive_publish_job - WHERE status NOT IN ('CURSORS_COMMITTED', 'FAILED') - ORDER BY id ASC - LIMIT 1 - """; - try (Connection c = db.getConnection(); - PreparedStatement ps = c.prepareStatement(sql); - ResultSet rs = ps.executeQuery()) { - return rs.next() ? mapJob(rs) : null; - } - } - - public ArchivePublishJob getJob(long id) throws SQLException { - String sql = """ - SELECT id, big_block_number, status, created_at_ms, local_archive_path, - archive_hash, arweave_tx_id, arweave_confirmations, solana_signature, - error_text, updated_at_ms - FROM archive_publish_job - WHERE id = ? - """; - try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { - ps.setLong(1, id); - try (ResultSet rs = ps.executeQuery()) { - return rs.next() ? mapJob(rs) : null; - } - } - } - - - /** Атомарно создаёт frozen archive job и все его blockchain ranges. */ - public long createJobWithChains(long bigBlockNumber, long createdAtMs, List chains) throws SQLException { - if (chains == null || chains.isEmpty()) throw new IllegalArgumentException("Archive job без chains не создаётся"); - try (Connection c = db.getConnection()) { - boolean oldAuto = c.getAutoCommit(); - c.setAutoCommit(false); - try { - long jobId; - try (PreparedStatement ps = c.prepareStatement(""" - INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms) - VALUES(?, 'SNAPSHOT_CREATED', ?, ?) - RETURNING id - """)) { - ps.setLong(1, bigBlockNumber); - ps.setLong(2, createdAtMs); - ps.setLong(3, createdAtMs); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job"); - jobId = rs.getLong(1); - } - } - String sql = """ - INSERT INTO archive_publish_job_chain( - job_id, blockchain_name, from_source_block_number, to_source_block_number, - previous_source_block_hash, last_source_block_hash, - previous_archive_big_block_number, previous_archive_big_block_hash, - previous_chunk_offset, previous_chunk_size, new_chunk_offset, new_chunk_size - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - for (ArchivePublishJobChain ch : chains) { - bindJobChain(ps, new ArchivePublishJobChain( - jobId, ch.blockchainName(), ch.fromSourceBlockNumber(), ch.toSourceBlockNumber(), - ch.previousSourceBlockHash(), ch.lastSourceBlockHash(), - ch.previousArchiveBigBlockNumber(), ch.previousArchiveBigBlockHash(), - ch.previousChunkOffset(), ch.previousChunkSize(), null, null)); - ps.addBatch(); - } - ps.executeBatch(); - } - c.commit(); - return jobId; - } catch (Exception e) { - c.rollback(); - if (e instanceof SQLException se) throw se; - throw new SQLException("Не удалось создать frozen archive job", e); - } finally { - c.setAutoCommit(oldAuto); - } - } - } - - public long createJob(long bigBlockNumber, long createdAtMs) throws SQLException { - String sql = """ - INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms) - VALUES(?, 'SNAPSHOT_CREATED', ?, ?) - RETURNING id - """; - try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { - ps.setLong(1, bigBlockNumber); - ps.setLong(2, createdAtMs); - ps.setLong(3, createdAtMs); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job"); - return rs.getLong(1); - } - } - } - - public void insertJobChain(ArchivePublishJobChain e) throws SQLException { - String sql = """ - INSERT INTO archive_publish_job_chain( - job_id, blockchain_name, from_source_block_number, to_source_block_number, - previous_source_block_hash, last_source_block_hash, - previous_archive_big_block_number, previous_archive_big_block_hash, - previous_chunk_offset, previous_chunk_size, - new_chunk_offset, new_chunk_size - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) - """; - try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { - bindJobChain(ps, e); - ps.executeUpdate(); - } - } - - public List listJobChains(long jobId) throws SQLException { - String sql = """ - SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number, - previous_source_block_hash, last_source_block_hash, - previous_archive_big_block_number, previous_archive_big_block_hash, - previous_chunk_offset, previous_chunk_size, - new_chunk_offset, new_chunk_size - FROM archive_publish_job_chain - WHERE job_id = ? - ORDER BY blockchain_name ASC - """; - List out = new ArrayList<>(); - try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { - ps.setLong(1, jobId); - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) out.add(mapJobChain(rs)); - } - } - return out; - } - - public void updateNewChunkPosition(long jobId, String blockchainName, long offset, long size) throws SQLException { - String sql = """ - UPDATE archive_publish_job_chain - SET new_chunk_offset=?, new_chunk_size=? - WHERE job_id=? AND blockchain_name=? - """; - try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { - ps.setLong(1, offset); - ps.setLong(2, size); - ps.setLong(3, jobId); - ps.setString(4, blockchainName); - if (ps.executeUpdate() != 1) throw new SQLException("Не найдена archive_publish_job_chain для " + blockchainName); - } - } - - public void markFileBuilt(long jobId, String localPath, byte[] hash) throws SQLException { - updateJob(jobId, "FILE_BUILT", "local_archive_path=? , archive_hash=?", ps -> { - ps.setString(1, localPath); - ps.setBytes(2, hash); - }); - } - - public void markArweaveUploaded(long jobId, byte[] txId) throws SQLException { - updateJob(jobId, "ARWEAVE_UPLOADED", "arweave_tx_id=?", ps -> ps.setBytes(1, txId)); - } - - /** Обновляет только локальное имя/путь уже собранного archive-файла. */ - public void updateLocalArchivePath(long jobId, String localPath) throws SQLException { - updateJob(jobId, null, "local_archive_path=?", ps -> ps.setString(1, localPath)); - } - - public void markArweaveConfirmed(long jobId, int confirmations) throws SQLException { - updateJob(jobId, "ARWEAVE_CONFIRMED", "arweave_confirmations=?", ps -> ps.setInt(1, confirmations)); - } - - public void markSolanaSubmitted(long jobId, String signature) throws SQLException { - updateJob(jobId, "SOLANA_SUBMITTED", "solana_signature=?", ps -> ps.setString(1, signature)); - } - - public void markSolanaFinalized(long jobId) throws SQLException { - updateJob(jobId, "SOLANA_FINALIZED", null, null); - } - - public void markFailed(long jobId, String error) throws SQLException { - updateJob(jobId, "FAILED", "error_text=?", ps -> ps.setString(1, error == null ? "" : error)); - } - - /** - * После Solana finalized атомарно переводит курсоры всех blockchain на новый chunk и закрывает job. - */ - public void commitCursors(long jobId, byte[] bigBlockHash) throws SQLException { - try (Connection c = db.getConnection()) { - boolean oldAuto = c.getAutoCommit(); - c.setAutoCommit(false); - try { - ArchivePublishJob job = getJob(c, jobId); - if (job == null) throw new SQLException("Archive job не найден: " + jobId); - List chains = listJobChains(c, jobId); - long now = System.currentTimeMillis(); - String upsert = """ - INSERT INTO archive_chain_cursor( - blockchain_name, last_archived_source_block_number, last_archived_source_block_hash, - last_archive_big_block_number, last_archive_big_block_hash, - last_chunk_offset, last_chunk_size, updated_at_ms - ) VALUES(?,?,?,?,?,?,?,?) - ON CONFLICT(blockchain_name) DO UPDATE SET - last_archived_source_block_number=EXCLUDED.last_archived_source_block_number, - last_archived_source_block_hash=EXCLUDED.last_archived_source_block_hash, - last_archive_big_block_number=EXCLUDED.last_archive_big_block_number, - last_archive_big_block_hash=EXCLUDED.last_archive_big_block_hash, - last_chunk_offset=EXCLUDED.last_chunk_offset, - last_chunk_size=EXCLUDED.last_chunk_size, - updated_at_ms=EXCLUDED.updated_at_ms - """; - try (PreparedStatement ps = c.prepareStatement(upsert)) { - for (ArchivePublishJobChain ch : chains) { - if (ch.newChunkOffset() == null || ch.newChunkSize() == null) { - throw new SQLException("Не сохранены координаты нового chunk: " + ch.blockchainName()); - } - ps.setString(1, ch.blockchainName()); - ps.setLong(2, ch.toSourceBlockNumber()); - ps.setBytes(3, ch.lastSourceBlockHash()); - ps.setLong(4, job.bigBlockNumber()); - ps.setBytes(5, bigBlockHash); - ps.setLong(6, ch.newChunkOffset()); - ps.setLong(7, ch.newChunkSize()); - ps.setLong(8, now); - ps.addBatch(); - } - ps.executeBatch(); - } - try (PreparedStatement ps = c.prepareStatement( - "UPDATE archive_publish_job SET status='CURSORS_COMMITTED', updated_at_ms=? WHERE id=?")) { - ps.setLong(1, now); - ps.setLong(2, jobId); - ps.executeUpdate(); - } - c.commit(); - } catch (Exception e) { - c.rollback(); - if (e instanceof SQLException se) throw se; - throw new SQLException("Не удалось commit archive cursors", e); - } finally { - c.setAutoCommit(oldAuto); - } - } - } - - private ArchivePublishJob getJob(Connection c, long id) throws SQLException { - String sql = """ - SELECT id, big_block_number, status, created_at_ms, local_archive_path, - archive_hash, arweave_tx_id, arweave_confirmations, solana_signature, - error_text, updated_at_ms - FROM archive_publish_job WHERE id=? - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setLong(1, id); - try (ResultSet rs = ps.executeQuery()) { return rs.next() ? mapJob(rs) : null; } - } - } - - private List listJobChains(Connection c, long jobId) throws SQLException { - String sql = """ - SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number, - previous_source_block_hash, last_source_block_hash, - previous_archive_big_block_number, previous_archive_big_block_hash, - previous_chunk_offset, previous_chunk_size, - new_chunk_offset, new_chunk_size - FROM archive_publish_job_chain - WHERE job_id=? ORDER BY blockchain_name ASC - """; - List out = new ArrayList<>(); - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setLong(1, jobId); - try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapJobChain(rs)); } - } - return out; - } - - @FunctionalInterface - private interface Binder { void bind(PreparedStatement ps) throws SQLException; } - - private void updateJob(long jobId, String status, String setFragment, Binder binder) throws SQLException { - if (setFragment != null && !setFragment.isBlank()) { - try (Connection c = db.getConnection()) { - updateJobWithExtra(c, jobId, status, setFragment, binder); - } - return; - } - String sql = status == null - ? "UPDATE archive_publish_job SET updated_at_ms=? WHERE id=?" - : "UPDATE archive_publish_job SET status=?, updated_at_ms=? WHERE id=?"; - try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { - int i = 1; - if (status != null) ps.setString(i++, status); - ps.setLong(i++, System.currentTimeMillis()); - ps.setLong(i, jobId); - ps.executeUpdate(); - } - } - - private void updateJobWithExtra(Connection c, long jobId, String status, String setFragment, Binder binder) throws SQLException { - String statusSql = status == null ? "" : ", status=?"; - String sql = "UPDATE archive_publish_job SET " + setFragment + statusSql + ", updated_at_ms=? WHERE id=?"; - try (PreparedStatement ps = c.prepareStatement(sql)) { - binder.bind(ps); - int count = countParameters(setFragment); - int i = count + 1; - if (status != null) ps.setString(i++, status); - ps.setLong(i++, System.currentTimeMillis()); - ps.setLong(i, jobId); - ps.executeUpdate(); - } - } - - private static int countParameters(String sqlPart) { - int n = 0; - for (int i = 0; i < sqlPart.length(); i++) if (sqlPart.charAt(i) == '?') n++; - return n; - } - - private static void bindJobChain(PreparedStatement ps, ArchivePublishJobChain e) throws SQLException { - int i=1; - ps.setLong(i++, e.jobId()); - ps.setString(i++, e.blockchainName()); - ps.setLong(i++, e.fromSourceBlockNumber()); - ps.setLong(i++, e.toSourceBlockNumber()); - ps.setBytes(i++, e.previousSourceBlockHash()); - ps.setBytes(i++, e.lastSourceBlockHash()); - setLongNullable(ps, i++, e.previousArchiveBigBlockNumber()); - ps.setBytes(i++, e.previousArchiveBigBlockHash()); - setLongNullable(ps, i++, e.previousChunkOffset()); - setLongNullable(ps, i++, e.previousChunkSize()); - setLongNullable(ps, i++, e.newChunkOffset()); - setLongNullable(ps, i, e.newChunkSize()); - } - - private static ArchiveChainCursor mapCursor(ResultSet rs) throws SQLException { - return new ArchiveChainCursor( - rs.getString("blockchain_name"), - rs.getLong("last_archived_source_block_number"), - rs.getBytes("last_archived_source_block_hash"), - rs.getLong("last_archive_big_block_number"), - rs.getBytes("last_archive_big_block_hash"), - rs.getLong("last_chunk_offset"), - rs.getLong("last_chunk_size"), - rs.getLong("updated_at_ms") - ); - } - - private static ArchivePublishJob mapJob(ResultSet rs) throws SQLException { - int conf = rs.getInt("arweave_confirmations"); - Integer confObj = rs.wasNull() ? null : conf; - return new ArchivePublishJob( - rs.getLong("id"), rs.getLong("big_block_number"), rs.getString("status"), - rs.getLong("created_at_ms"), rs.getString("local_archive_path"), - rs.getBytes("archive_hash"), rs.getBytes("arweave_tx_id"), confObj, - rs.getString("solana_signature"), rs.getString("error_text"), rs.getLong("updated_at_ms") - ); - } - - private static ArchivePublishJobChain mapJobChain(ResultSet rs) throws SQLException { - return new ArchivePublishJobChain( - rs.getLong("job_id"), rs.getString("blockchain_name"), - rs.getLong("from_source_block_number"), rs.getLong("to_source_block_number"), - rs.getBytes("previous_source_block_hash"), rs.getBytes("last_source_block_hash"), - getLongNullable(rs, "previous_archive_big_block_number"), rs.getBytes("previous_archive_big_block_hash"), - getLongNullable(rs, "previous_chunk_offset"), getLongNullable(rs, "previous_chunk_size"), - getLongNullable(rs, "new_chunk_offset"), getLongNullable(rs, "new_chunk_size") - ); - } - - private static Long getLongNullable(ResultSet rs, String name) throws SQLException { - long v = rs.getLong(name); - return rs.wasNull() ? null : v; - } - - private static void setLongNullable(PreparedStatement ps, int idx, Long v) throws SQLException { - if (v == null) ps.setNull(idx, Types.BIGINT); else ps.setLong(idx, v); - } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java new file mode 100644 index 00000000..371fbf9d --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java @@ -0,0 +1,138 @@ +package shine.db.dao; + +import shine.db.DbController; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +/** Persistent discovery/import queue for ANS-104 SHiNE blocks found through Arweave. */ +public final class ArweaveBlockImportDAO { + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_REJECTED = "REJECTED"; + + public record QueueItem(byte[] dataItemId, String rootTxId, long blockHeight, + byte[] rawDataItem, String status, String lastError, + long firstSeenAtMs, long updatedAtMs) {} + + private static volatile ArweaveBlockImportDAO instance; + private final DbController db = DbController.getInstance(); + + private ArweaveBlockImportDAO() {} + + public static ArweaveBlockImportDAO getInstance() { + if (instance == null) { + synchronized (ArweaveBlockImportDAO.class) { + if (instance == null) instance = new ArweaveBlockImportDAO(); + } + } + return instance; + } + + public long getLastBlockHeight() throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement("SELECT last_block_height FROM arweave_block_sync_state WHERE id=1"); + ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getLong(1) : 0L; + } + } + + public void setLastBlockHeight(long height, long nowMs) throws SQLException { + String sql = """ + INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms) + VALUES(1,?,?) + ON CONFLICT(id) DO UPDATE SET last_block_height=EXCLUDED.last_block_height, updated_at_ms=EXCLUDED.updated_at_ms + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, Math.max(0L, height)); + ps.setLong(2, nowMs); + ps.executeUpdate(); + } + } + + /** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */ + public boolean enqueueIfMissing(byte[] dataItemId, String rootTxId, long blockHeight, byte[] rawDataItem, long nowMs) + throws SQLException { + String sql = """ + INSERT INTO arweave_block_import_queue( + data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms + ) VALUES(?,?,?,?,?,'',?,?) + ON CONFLICT(data_item_id) DO NOTHING + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setBytes(1, dataItemId); + ps.setString(2, rootTxId); + ps.setLong(3, blockHeight); + ps.setBytes(4, rawDataItem); + ps.setString(5, STATUS_PENDING); + ps.setLong(6, nowMs); + ps.setLong(7, nowMs); + return ps.executeUpdate() > 0; + } + } + + public boolean exists(byte[] dataItemId) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement("SELECT 1 FROM arweave_block_import_queue WHERE data_item_id=? LIMIT 1")) { + ps.setBytes(1, dataItemId); + try (ResultSet rs = ps.executeQuery()) { return rs.next(); } + } + } + + public List listPending(int limit) throws SQLException { + int safeLimit = Math.max(1, Math.min(limit, 100_000)); + String sql = """ + SELECT data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms + FROM arweave_block_import_queue + WHERE status=? + ORDER BY block_height ASC, first_seen_at_ms ASC + LIMIT ? + """; + List out = new ArrayList<>(); + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, STATUS_PENDING); + ps.setInt(2, safeLimit); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + out.add(new QueueItem( + rs.getBytes("data_item_id"), rs.getString("root_tx_id"), rs.getLong("block_height"), + rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"), + rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms"))); + } + } + } + return out; + } + + public void setPendingError(byte[] id, String error, long nowMs) throws SQLException { + updateStatus(id, STATUS_PENDING, error, nowMs); + } + + public void reject(byte[] id, String error, long nowMs) throws SQLException { + updateStatus(id, STATUS_REJECTED, error, nowMs); + } + + public void delete(byte[] id) throws SQLException { + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement( + "DELETE FROM arweave_block_import_queue WHERE data_item_id=?")) { + ps.setBytes(1, id); + ps.executeUpdate(); + } + } + + private void updateStatus(byte[] id, String status, String error, long nowMs) throws SQLException { + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement( + "UPDATE arweave_block_import_queue SET status=?,last_error=?,updated_at_ms=? WHERE data_item_id=?")) { + ps.setString(1, status); + ps.setString(2, trimError(error)); + ps.setLong(3, nowMs); + ps.setBytes(4, id); + ps.executeUpdate(); + } + } + + private static String trimError(String s) { + String v = s == null ? "" : s.replace('\n',' ').replace('\r',' ').trim(); + return v.length() <= 500 ? v : v.substring(0,500); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java index 2cfc5645..4f461d3f 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -23,7 +23,7 @@ import java.sql.SQLException; * - нам нужен один понятный "блок операции", который можно вызвать из resync-flow; * - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам; * - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии; - * - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию: + * - пользовательские blockchain-файлы больше не существуют; cleanup полностью DB-only. * SQL-транзакция и файловая система не коммитятся атомарно вместе; * поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом * отдельным recovery/resync-слоем после успешного commit. @@ -71,7 +71,7 @@ public final class BlockchainResyncCleanupDAO { * - если blockchain_state уже отсутствует, login берём из blockchainName. * * Отдельно важно: - * - здесь НЕТ удаления .bch/.tmp_bch; + * - filesystem blockchain storage отсутствует; * - здесь НЕТ повторной загрузки цепочки; * - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow. */ diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java index 2719c31c..1b40ffe0 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java @@ -9,286 +9,147 @@ import java.sql.*; import java.util.ArrayList; import java.util.List; -/** - * DAO для таблицы blocks (новый формат). - * - * Правило: - * - методы с Connection НЕ закрывают соединение - * - методы без Connection сами открывают и закрывают соединение - * - * Ключ: - * - (bch_name, block_number) — уникальная пара в рамках общей БД сервера. - */ +/** DAO for SHiNE Frame v1 blocks. block_bytes contains the complete ANS-104 DataItem. */ public final class BlocksDAO { - private static volatile BlocksDAO instance; private final DbController db = DbController.getInstance(); private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class); private BlocksDAO() { } - public static BlocksDAO getInstance() { - if (instance == null) { - synchronized (BlocksDAO.class) { - if (instance == null) instance = new BlocksDAO(); - } - } + if (instance == null) synchronized (BlocksDAO.class) { if (instance == null) instance = new BlocksDAO(); } return instance; } - // -------------------- INSERT -------------------- - - /** Вставка с внешним соединением. Соединение НЕ закрывает. */ public void insert(Connection c, BlockEntry e) throws SQLException { - log.info("DBG BlockEntry: type={} sub={} lineCode={} prevLineNumber={} thisLineNumber={} prevLineHashLen={}", - e.getMsgType(), e.getMsgSubType(), - e.getLineCode(), e.getPrevLineNumber(), e.getThisLineNumber(), - e.getPrevLineHash() == null ? null : e.getPrevLineHash().length - ); - String sql = """ INSERT INTO blocks ( - login, - bch_name, - block_number, - msg_type, - msg_sub_type, - block_bytes, - to_login, - to_bch_name, - to_block_number, - to_block_hash, - block_hash, - block_signature, - edited_by_block_number, - line_code, - prev_line_number, - prev_line_hash, - this_line_number - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, + to_login,to_bch_name,to_block_number,to_block_hash, + block_hash,block_signature,data_item_id, + arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, + edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """; - try (PreparedStatement ps = c.prepareStatement(sql)) { int i = 1; - ps.setString(i++, e.getLogin()); ps.setString(i++, e.getBchName()); ps.setInt(i++, e.getBlockNumber()); - ps.setInt(i++, e.getMsgType()); ps.setInt(i++, e.getMsgSubType()); - ps.setBytes(i++, e.getBlockBytes()); - - if (e.getToLogin() != null) ps.setString(i++, e.getToLogin()); - else ps.setNull(i++, Types.VARCHAR); - - if (e.getToBchName() != null) ps.setString(i++, e.getToBchName()); - else ps.setNull(i++, Types.VARCHAR); - - if (e.getToBlockNumber() != null) ps.setInt(i++, e.getToBlockNumber()); - else ps.setNull(i++, Types.INTEGER); - - if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash()); - else ps.setNull(i++, Types.BINARY); - + setNullableString(ps, i++, e.getToLogin()); + setNullableString(ps, i++, e.getToBchName()); + setNullableInt(ps, i++, e.getToBlockNumber()); + setNullableBytes(ps, i++, e.getToBlockHash()); ps.setBytes(i++, e.getBlockHash()); ps.setBytes(i++, e.getBlockSignature()); - - if (e.getEditedByBlockNumber() != null) ps.setInt(i++, e.getEditedByBlockNumber()); - else ps.setNull(i++, Types.INTEGER); - - // NEW: line_code - if (e.getLineCode() != null) ps.setInt(i++, e.getLineCode()); - else ps.setNull(i++, Types.INTEGER); - - if (e.getPrevLineNumber() != null) ps.setInt(i++, e.getPrevLineNumber()); - else ps.setNull(i++, Types.INTEGER); - - if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash()); - else ps.setNull(i++, Types.BINARY); - - if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber()); - else ps.setNull(i++, Types.INTEGER); - + ps.setBytes(i++, e.getDataItemId()); + ps.setBoolean(i++, e.isArweavePublishPending()); + if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs()); + setNullableString(ps, i++, e.getArweaveRootTxId()); + setNullableInt(ps, i++, e.getEditedByBlockNumber()); + setNullableInt(ps, i++, e.getLineCode()); + setNullableInt(ps, i++, e.getPrevLineNumber()); + setNullableBytes(ps, i++, e.getPrevLineHash()); + setNullableInt(ps, i++, e.getThisLineNumber()); ps.executeUpdate(); } } - /** Вставка без внешнего соединения. Сам открывает/закрывает. */ - public void insert(BlockEntry e) throws SQLException { - try (Connection c = db.getConnection()) { - insert(c, e); + public void insert(BlockEntry e) throws SQLException { try (Connection c = db.getConnection()) { insert(c, e); } } + + public boolean existsByDataItemId(byte[] dataItemId) throws SQLException { + if (dataItemId == null || dataItemId.length != 32) return false; + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement("SELECT 1 FROM blocks WHERE data_item_id=? LIMIT 1")) { + ps.setBytes(1, dataItemId); + try (ResultSet rs = ps.executeQuery()) { return rs.next(); } } } - // -------------------- SELECT: HASH BY NUMBER -------------------- - - /** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */ - public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException { + public List listPendingArweave(int limit) throws SQLException { + int safeLimit = Math.max(1, Math.min(limit, 100_000)); String sql = """ - SELECT block_hash + SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, + to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id, + arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, + edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number FROM blocks - WHERE bch_name = ? AND block_number = ? - LIMIT 1 + WHERE arweave_publish_pending = TRUE + ORDER BY bch_name ASC, block_number ASC + LIMIT ? """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, bchName); - ps.setInt(2, blockNumber); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return rs.getBytes("block_hash"); - } + List out = new ArrayList<>(); + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setInt(1, safeLimit); + try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); } } + return out; } - public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { + public void markArweavePublished(List dataItemIds, String rootTxId, long publishedAtMs) throws SQLException { + if (dataItemIds == null || dataItemIds.isEmpty()) return; + String sql = """ + UPDATE blocks + SET arweave_publish_pending=FALSE, arweave_published_at_ms=?, arweave_root_tx_id=? + WHERE data_item_id=? + """; try (Connection c = db.getConnection()) { - return getHashByNumber(c, bchName, blockNumber); + c.setAutoCommit(false); + try (PreparedStatement ps = c.prepareStatement(sql)) { + for (byte[] id : dataItemIds) { + ps.setLong(1, publishedAtMs); ps.setString(2, rootTxId); ps.setBytes(3, id); ps.addBatch(); + } + ps.executeBatch(); c.commit(); + } catch (Exception e) { c.rollback(); throw e; } + finally { c.setAutoCommit(true); } } } - // -------------------- SELECT: FULL ENTRY -------------------- + public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException { + try (PreparedStatement ps = c.prepareStatement("SELECT block_hash FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1")) { + ps.setString(1,bchName); ps.setInt(2,blockNumber); + try (ResultSet rs=ps.executeQuery()) { return rs.next()?rs.getBytes(1):null; } + } + } + public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { try(Connection c=db.getConnection()){return getHashByNumber(c,bchName,blockNumber);} } public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException { - String sql = """ - SELECT - login, - bch_name, - block_number, - msg_type, - msg_sub_type, - block_bytes, - to_login, - to_bch_name, - to_block_number, - to_block_hash, - block_hash, - block_signature, - edited_by_block_number, - line_code, - prev_line_number, - prev_line_hash, - this_line_number - FROM blocks - WHERE bch_name = ? AND block_number = ? - LIMIT 1 - """; + String sql = baseSelect()+" WHERE bch_name=? AND block_number=? LIMIT 1"; + try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,bchName); ps.setInt(2,blockNumber); try(ResultSet rs=ps.executeQuery()){return rs.next()?mapRow(rs):null;} } + } + public BlockEntry getByNumber(String bchName,int blockNumber)throws SQLException{try(Connection c=db.getConnection()){return getByNumber(c,bchName,blockNumber);} } - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, bchName); - ps.setInt(2, blockNumber); - - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapRow(rs); - } - } + public List listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} } + public List listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{ + String sql=baseSelect()+" WHERE bch_name=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC"; + List out=new ArrayList<>(); + try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,bchName);ps.setInt(2,from);ps.setInt(3,to);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(mapRow(rs));}} + return out; } - public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException { - try (Connection c = db.getConnection()) { - return getByNumber(c, bchName, blockNumber); - } - } - - public List listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException { - try (Connection c = db.getConnection()) { - return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive); - } - } - - public List listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException { - String sql = """ - SELECT - login, - bch_name, - block_number, - msg_type, - msg_sub_type, - block_bytes, - to_login, - to_bch_name, - to_block_number, - to_block_hash, - block_hash, - block_signature, - edited_by_block_number, - line_code, - prev_line_number, - prev_line_hash, - this_line_number - FROM blocks - WHERE bch_name = ? - AND block_number >= ? - AND block_number <= ? - ORDER BY block_number ASC - """; - - List result = new ArrayList<>(); - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, bchName); - ps.setInt(2, fromBlockNumberInclusive); - ps.setInt(3, toBlockNumberInclusive); - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - result.add(mapRow(rs)); - } - } - } - return result; - } - - // -------------------- INTERNAL -------------------- - - private BlockEntry mapRow(ResultSet rs) throws SQLException { - BlockEntry e = new BlockEntry(); - - e.setLogin(rs.getString("login")); - e.setBchName(rs.getString("bch_name")); - e.setBlockNumber(rs.getInt("block_number")); - - e.setMsgType(rs.getInt("msg_type")); - e.setMsgSubType(rs.getInt("msg_sub_type")); - - e.setBlockBytes(rs.getBytes("block_bytes")); - - String toLogin = rs.getString("to_login"); - if (rs.wasNull()) toLogin = null; - e.setToLogin(toLogin); - - String toBchName = rs.getString("to_bch_name"); - if (rs.wasNull()) toBchName = null; - e.setToBchName(toBchName); - - Integer toBlockNumber = (Integer) rs.getObject("to_block_number"); - e.setToBlockNumber(toBlockNumber); - - byte[] toHash = rs.getBytes("to_block_hash"); - if (rs.wasNull()) toHash = null; - e.setToBlockHash(toHash); - - e.setBlockHash(rs.getBytes("block_hash")); - e.setBlockSignature(rs.getBytes("block_signature")); - - Integer editedBy = (Integer) rs.getObject("edited_by_block_number"); - e.setEditedByBlockNumber(editedBy); - - // NEW: line_code - Integer lineCode = (Integer) rs.getObject("line_code"); - e.setLineCode(lineCode); - - Integer prevLn = (Integer) rs.getObject("prev_line_number"); - e.setPrevLineNumber(prevLn); - - byte[] prevLh = rs.getBytes("prev_line_hash"); - if (rs.wasNull()) prevLh = null; - e.setPrevLineHash(prevLh); - - Integer thisLn = (Integer) rs.getObject("this_line_number"); - e.setThisLineNumber(thisLn); + private static String baseSelect(){return """ + SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, + to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id, + arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, + edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number + FROM blocks + """;} + private BlockEntry mapRow(ResultSet rs)throws SQLException{ + BlockEntry e=new BlockEntry(); + e.setLogin(rs.getString("login")); e.setBchName(rs.getString("bch_name")); e.setBlockNumber(rs.getInt("block_number")); + e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes")); + e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash")); + e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id")); + e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms")); e.setArweaveRootTxId(rs.getString("arweave_root_tx_id")); + e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number")); return e; } + + private static void setNullableString(PreparedStatement ps,int i,String v)throws SQLException{if(v==null)ps.setNull(i,Types.VARCHAR);else ps.setString(i,v);} + private static void setNullableInt(PreparedStatement ps,int i,Integer v)throws SQLException{if(v==null)ps.setNull(i,Types.INTEGER);else ps.setInt(i,v);} + private static void setNullableBytes(PreparedStatement ps,int i,byte[] v)throws SQLException{if(v==null)ps.setNull(i,Types.BINARY);else ps.setBytes(i,v);} } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java index 762a4292..d9076c99 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java @@ -120,4 +120,15 @@ public final class ChannelNameStateDAO { insert(c, entry); } } + public String getSlugByOwnerAndRoot(String ownerBchName, int rootBlockNumber) throws SQLException { + String sql = "SELECT slug FROM channel_names_state WHERE owner_bch_name=? AND channel_root_block_number=? LIMIT 1"; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerBchName); + ps.setInt(2, rootBlockNumber); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString(1) : null; + } + } + } + } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java index 923bff29..560945a4 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java @@ -8,6 +8,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.Base64; /** * Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем. @@ -60,6 +61,23 @@ public final class SolanaUserPdaCurrentDAO { } } + /** Resolve a current user by the 32-byte Ed25519 blockchain key used as ANS-104 owner. */ + public SolanaUserPdaCurrentEntry getByBlockchainKey(byte[] owner32) throws SQLException { + if (owner32 == null || owner32.length != 32) return null; + String wanted = Base64.getEncoder().encodeToString(owner32); + String sql = """ + SELECT login, blockchain_name, blockchain_key, paid_limit_bytes + FROM solana_user_pda_current + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + SolanaUserPdaCurrentEntry e = mapRow(rs); + if (wanted.equals(e.getBlockchainKey())) return e; + } + } + return null; + } + private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException { SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry(); entry.setLogin(rs.getString("login")); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java index 39256dc4..12df97cf 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java @@ -37,6 +37,10 @@ public class BlockEntry { private byte[] blockHash; private byte[] blockSignature; + private byte[] dataItemId; + private boolean arweavePublishPending; + private Long arweavePublishedAtMs; + private String arweaveRootTxId; private Integer editedByBlockNumber; @@ -85,6 +89,15 @@ public class BlockEntry { public byte[] getBlockSignature() { return blockSignature; } public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; } + public byte[] getDataItemId() { return dataItemId; } + public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; } + public boolean isArweavePublishPending() { return arweavePublishPending; } + public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; } + public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; } + public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; } + public String getArweaveRootTxId() { return arweaveRootTxId; } + public void setArweaveRootTxId(String arweaveRootTxId) { this.arweaveRootTxId = arweaveRootTxId; } + public Integer getEditedByBlockNumber() { return editedByBlockNumber; } public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; } diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v24.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v24.sql new file mode 100644 index 00000000..cba0781a --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v24.sql @@ -0,0 +1,56 @@ +-- Frame v1 intentionally has no backward compatibility with the old signed block format. +-- Refuse to migrate a populated blockchain database: test/dev data must be reset first. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM blocks LIMIT 1) + OR EXISTS (SELECT 1 FROM blockchain_state WHERE last_block_number >= 0 LIMIT 1) THEN + RAISE EXCEPTION 'migration_v24 requires an empty blockchain database; reset test/dev blockchain data before upgrading'; + END IF; +END $$; + +-- Старый SHINE-ARCHIVE v1 больше не используется. +DROP TABLE IF EXISTS archive_publish_job_chain CASCADE; +DROP TABLE IF EXISTS archive_publish_job CASCADE; +DROP TABLE IF EXISTS archive_chain_cursor CASCADE; +DROP TABLE IF EXISTS archive_blockchain_location CASCADE; + +-- SHiNE Frame v1 / ANS-104 DataItem storage. No legacy .bch files are required. +ALTER TABLE blocks + ADD COLUMN IF NOT EXISTS data_item_id BYTEA; +ALTER TABLE blocks + ADD COLUMN IF NOT EXISTS arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE blocks + ADD COLUMN IF NOT EXISTS arweave_published_at_ms BIGINT; +ALTER TABLE blocks + ADD COLUMN IF NOT EXISTS arweave_root_tx_id TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id + ON blocks(data_item_id) + WHERE data_item_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending + ON blocks(block_number) + WHERE arweave_publish_pending = TRUE; + +CREATE TABLE IF NOT EXISTS arweave_block_sync_state ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + last_block_height BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL DEFAULT 0 +); +INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms) +VALUES(1,0,0) ON CONFLICT(id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS arweave_block_import_queue ( + data_item_id BYTEA PRIMARY KEY, + root_tx_id TEXT NOT NULL, + block_height BIGINT NOT NULL, + raw_data_item BYTEA NOT NULL, + status TEXT NOT NULL, + last_error TEXT NOT NULL DEFAULT '', + first_seen_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending + ON arweave_block_import_queue(block_height,first_seen_at_ms) + WHERE status='PENDING'; + +UPDATE db_schema_version SET schema_version=24, updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id=1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index d9c1365b..9109d1c4 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version ( ); INSERT INTO db_schema_version (id, schema_version, updated_at_ms) - VALUES (1, 20, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) + VALUES (1, 24, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) ON CONFLICT (id) DO UPDATE SET schema_version = EXCLUDED.schema_version, updated_at_ms = EXCLUDED.updated_at_ms; @@ -291,12 +291,6 @@ AFTER TRUNCATE ON solana_user_pda_current FOR EACH STATEMENT EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate(); -DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current; -CREATE TRIGGER trg_user_stats_state_ai -AFTER INSERT ON solana_user_pda_current -FOR EACH ROW -EXECUTE FUNCTION shine_user_stats_state_ai(); - CREATE TABLE IF NOT EXISTS solana_user_pda_history ( id BIGSERIAL PRIMARY KEY, tx_signature TEXT NOT NULL, @@ -503,6 +497,10 @@ CREATE TABLE IF NOT EXISTS blocks ( to_block_hash BYTEA, block_hash BYTEA NOT NULL, block_signature BYTEA NOT NULL, + data_item_id BYTEA NOT NULL, + arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE, + arweave_published_at_ms BIGINT, + arweave_root_tx_id TEXT, edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0), line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0), prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0), @@ -514,12 +512,41 @@ CREATE TABLE IF NOT EXISTS blocks ( CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number ON blocks (bch_name, block_number); +CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id + ON blocks(data_item_id); + +CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending + ON blocks(block_number) + WHERE arweave_publish_pending = TRUE; + CREATE INDEX IF NOT EXISTS idx_blocks_to_target ON blocks (to_login, to_bch_name, to_block_number); CREATE INDEX IF NOT EXISTS idx_blocks_by_line ON blocks (bch_name, line_code, this_line_number); +CREATE TABLE IF NOT EXISTS arweave_block_sync_state ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + last_block_height BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL DEFAULT 0 +); +INSERT INTO arweave_block_sync_state(id, last_block_height, updated_at_ms) +VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS arweave_block_import_queue ( + data_item_id BYTEA PRIMARY KEY, + root_tx_id TEXT NOT NULL, + block_height BIGINT NOT NULL, + raw_data_item BYTEA NOT NULL, + status TEXT NOT NULL, + last_error TEXT NOT NULL DEFAULT '', + first_seen_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending + ON arweave_block_import_queue(block_height,first_seen_at_ms) + WHERE status='PENDING'; + CREATE TABLE IF NOT EXISTS connections_state ( login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login), rel_type INTEGER NOT NULL, @@ -1660,6 +1687,12 @@ AFTER INSERT ON blocks FOR EACH ROW EXECUTE FUNCTION shine_blocks_edit_apply_ai(); +DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current; +CREATE TRIGGER trg_user_stats_state_ai +AFTER INSERT ON solana_user_pda_current +FOR EACH ROW +EXECUTE FUNCTION shine_user_stats_state_ai(); + DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state; CREATE TRIGGER trg_channel_names_state_stats_ai AFTER INSERT ON channel_names_state @@ -2028,52 +2061,6 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state ( PRIMARY KEY (owner_login, category) ); -CREATE TABLE IF NOT EXISTS archive_chain_cursor ( - blockchain_name TEXT PRIMARY KEY, - last_archived_source_block_number BIGINT NOT NULL, - last_archived_source_block_hash BYTEA NOT NULL, - last_archive_big_block_number BIGINT NOT NULL, - last_archive_big_block_hash BYTEA NOT NULL, - last_chunk_offset BIGINT NOT NULL, - last_chunk_size BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL -); - -CREATE TABLE IF NOT EXISTS archive_publish_job ( - id BIGSERIAL PRIMARY KEY, - big_block_number BIGINT NOT NULL, - status TEXT NOT NULL, - created_at_ms BIGINT NOT NULL, - local_archive_path TEXT, - archive_hash BYTEA, - arweave_tx_id BYTEA, - arweave_confirmations INTEGER, - solana_signature TEXT, - error_text TEXT, - updated_at_ms BIGINT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status - ON archive_publish_job(status); -CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number - ON archive_publish_job(big_block_number); - -CREATE TABLE IF NOT EXISTS archive_publish_job_chain ( - job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE, - blockchain_name TEXT NOT NULL, - from_source_block_number BIGINT NOT NULL, - to_source_block_number BIGINT NOT NULL, - previous_source_block_hash BYTEA NOT NULL, - last_source_block_hash BYTEA NOT NULL, - previous_archive_big_block_number BIGINT, - previous_archive_big_block_hash BYTEA, - previous_chunk_offset BIGINT, - previous_chunk_size BIGINT, - new_chunk_offset BIGINT, - new_chunk_size BIGINT, - PRIMARY KEY (job_id, blockchain_name) -); - ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''; ALTER TABLE solana_user_pda_current @@ -2093,16 +2080,8 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending ON solana_user_pda_current(is_server, archive_imported) WHERE archive_head_tx_id <> ''; -CREATE TABLE IF NOT EXISTS archive_blockchain_location ( - blockchain_name TEXT PRIMARY KEY, publisher_login TEXT NOT NULL, arweave_tx_id TEXT NOT NULL, - archive_hash BYTEA NOT NULL, big_block_number BIGINT NOT NULL, chunk_offset BIGINT NOT NULL, - chunk_size BIGINT NOT NULL, source_last_block_number BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher ON archive_blockchain_location(publisher_login); - INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,23,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; COMMIT; - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index a7a9b21a..10406a25 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -42,10 +42,8 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler; -import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetArchiveBlockchainLocation_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetArchiveBlockchainLocation_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; @@ -193,7 +191,6 @@ public final class JsonHandlerRegistry { // --- blockchain --- Map.entry("AddBlock", new Net_AddBlock_Handler()), Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()), - Map.entry("GetArchiveBlockchainLocation", new Net_GetArchiveBlockchainLocation_Handler()), // --- userParams --- Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()), @@ -286,7 +283,6 @@ public final class JsonHandlerRegistry { // --- blockchain --- Map.entry("AddBlock", Net_AddBlock_Request.class), Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class), - Map.entry("GetArchiveBlockchainLocation", Net_GetArchiveBlockchainLocation_Request.class), // --- userParams --- Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 8ccf7b43..18829f3e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -103,6 +103,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { req.getBlockNumber(), // старое поле, пока оставляем req.getPrevBlockHash(), // старое поле, пока оставляем req.getBlockBytesB64(), + true, true ); @@ -163,6 +164,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { case "limit_exceeded" -> "Превышен лимит размера блокчейна"; case "limit_check_failed" -> "Ошибка проверки лимита размера"; case "bad_block_format" -> "Некорректный формат блока"; + case "bad_app_tag" -> "Некорректный тестовый тег ANS-104"; + case "bad_channel_tag" -> "Некорректный тег канала ANS-104"; case "bad_block_body" -> "Некорректное тело блока"; case "bad_channel_name" -> "Некорректное название канала"; case "bad_block_number" -> "Некорректный номер блока"; @@ -190,7 +193,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { int globalNumberFromReq, String prevGlobalHashHexFromReq, String blockBytesB64, - boolean replicateAfterWrite + boolean replicateAfterWrite, + boolean publishToArweave ) { if (blockchainName == null || blockchainName.isBlank()) { log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq); @@ -272,6 +276,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex); } + if (!block.getDataItem().hasTag("App", "test5590")) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_app_tag", serverLastNum, serverLastHashHex); + } + // body.check() try { block.body.check(); @@ -401,6 +409,22 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L); } + // Channel DataItems are indexed by a signed canonical channel slug tag: c=. + try { + String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry); + String actualChannelSlug = block.getDataItem().tagValue("c"); + if (expectedChannelSlug != null) { + if (!expectedChannelSlug.equals(actualChannelSlug)) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex); + } + } else if (actualChannelSlug != null) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex); + } + } catch (Exception e) { + log.warn("AddBlock: cannot validate channel tag blockchainName={} block={}", blockchainName, block.blockNumber, e); + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex); + } + if ((block.type & 0xFFFF) == 1 && (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { try { @@ -458,7 +482,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex); } - // 6) подпись по hash32(preimage) + // 6) ANS-104 Ed25519 подпись и owner boolean sigOk; try { sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32); @@ -516,7 +540,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } } - // 8) сформировать запись и записать (DB + state + файл) + // 8) сформировать запись и атомарно записать в PostgreSQL вместе с новым state try { BlockEntry be = new BlockEntry(); be.setLogin(login); @@ -529,6 +553,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { be.setBlockBytes(block.toBytes()); be.setBlockHash(block.getHash32()); be.setBlockSignature(block.getSignature64()); + be.setDataItemId(block.getDataItemId32()); + be.setArweavePublishPending(publishToArweave); // line columns (optional) be.setLineCode(lineCode); @@ -626,18 +652,26 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { * Архивный импорт использует ту же проверку и запись, что обычный AddBlock, * но не запускает повторную межсерверную репликацию уже импортированного блока. */ - public ArchiveImportResult addBlockFromArchive(String blockchainName, byte[] rawBlockBytes) { + public ArweaveImportResult addBlockFromArweave(String blockchainName, byte[] rawBlockBytes) { if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) { - return new ArchiveImportResult(false, "chain_resync_in_progress", -1, ""); + return new ArweaveImportResult(false, "chain_resync_in_progress", -1, ""); } if (rawBlockBytes == null || rawBlockBytes.length == 0) { - return new ArchiveImportResult(false, "empty_block", -1, ""); + return new ArweaveImportResult(false, "empty_block", -1, ""); + } + try { + BchBlockEntry probe = new BchBlockEntry(rawBlockBytes); + if (blocksDAO.existsByDataItemId(probe.getDataItemId32())) { + return new ArweaveImportResult(true, "already_present", probe.blockNumber, toHex(probe.getHash32())); + } + } catch (Exception ignored) { + // Parsed again below to return the canonical bad_block_format result. } final BchBlockEntry parsed; try { parsed = new BchBlockEntry(rawBlockBytes); } catch (Exception e) { - return new ArchiveImportResult(false, "bad_block_format", -1, ""); + return new ArweaveImportResult(false, "bad_block_format", -1, ""); } ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); lock.lock(); @@ -647,20 +681,40 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { parsed.blockNumber, toHex(parsed.prevHash32), Base64Ws.encode(rawBlockBytes), + false, false ); - return new ArchiveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex); + return new ArweaveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex); } finally { lock.unlock(); } } - public record ArchiveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {} + public record ArweaveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {} /* ===================================================================== */ /* ====================== Helpers ====================================== */ /* ===================================================================== */ + private String expectedChannelSlug(String blockchainName, BchBlockEntry block, ChannelNameStateEntry justCreated) throws Exception { + if (block.body instanceof CreateChannelBody) { + return justCreated == null ? null : justCreated.getSlug(); + } + int type = block.type & 0xffff; + int sub = block.subType & 0xffff; + boolean channelBlock = type == 1 && ( + sub == (MsgSubType.TEXT_POST & 0xffff) + || sub == (MsgSubType.TEXT_REPOST & 0xffff) + || sub == (MsgSubType.TEXT_CHANNEL_META & 0xffff) + || sub == (MsgSubType.TEXT_ENTRYPOINT & 0xffff) + || sub == (MsgSubType.TEXT_EXERCISE & 0xffff) + || sub == (MsgSubType.TEXT_SERVICE & 0xffff) + || sub == (MsgSubType.TEXT_COURSE & 0xffff) + || sub == (MsgSubType.TEXT_EDIT_POST & 0xffff)); + if (!channelBlock || !(block.body instanceof BodyHasLine line) || line.lineCode() <= 0) return null; + return channelNameStateDAO.getSlugByOwnerAndRoot(blockchainName, line.lineCode()); + } + private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) { if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF) && subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index 936b3c1d..90e5a19a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -13,35 +13,17 @@ import shine.db.entities.BlockEntry; import shine.db.entities.ChannelNameStateEntry; import shine.db.entities.UserParamEntry; import shine.db.entities.UserNotificationEntry; -import utils.files.FileStoreUtil; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.sql.Connection; import java.sql.SQLException; -import java.util.HexFormat; import java.util.ArrayList; import java.util.List; -/** - * BlockchainWriter — запись блока в БД и формирование файловой версии цепочки. - * - * Текущая схема: - * 1) собираем .tmp_bch как готовый кандидат на замену основного файла; - * 2) пишем маленький sidecar .write_check с blockNumber/blockHash; - * 3) создаём пустой marker .write_pending; - * 4) выполняем одну SQL-транзакцию; - * 5) после commit атомарно заменяем основной .bch из tmp; - * 6) убираем временные файлы. - * - * Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись: - * - если marker отсутствует, а tmp/check остались, это мусор; - * - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор. +/** Stores the complete ANS-104 DataItem and all derived SHiNE state atomically in PostgreSQL. + * Local .bch/tmp/marker files are intentionally not used. */ public final class BlockchainWriter { - private static final HexFormat HEX = HexFormat.of(); private final BlocksDAO blocksDAO; private final BlockchainStateDAO stateDAO; @@ -49,7 +31,6 @@ public final class BlockchainWriter { private final UserParamsDAO userParamsDAO; private final UserNotificationsStateDAO userNotificationsStateDAO; private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance(); - private final FileStoreUtil fs = FileStoreUtil.getInstance(); public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO, @@ -63,6 +44,10 @@ public final class BlockchainWriter { this.userNotificationsStateDAO = userNotificationsStateDAO; } + /** + * Stores the complete ANS-104 DataItem and all SQL projections in one DB transaction. + * There is intentionally no .bch/tmp/marker file layer anymore. + */ public List appendBlockAndState(String blockchainName, BchBlockEntry block, BlockchainStateEntry st, @@ -73,48 +58,24 @@ public final class BlockchainWriter { UserNotificationEntry notificationEntry) throws SQLException { long nowMs = System.currentTimeMillis(); - byte[] blockBytes = block.toBytes(); - byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes); - String blockHashHex = HEX.formatHex(block.getHash32()); - - prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes); - - boolean committed = false; + byte[] rawDataItem = block.toBytes(); List counterChangedLogins = new ArrayList<>(); try (Connection c = shine.db.DbController.getInstance().getConnection()) { c.setAutoCommit(false); try { - // 1) insert block blocksDAO.insert(c, be); - // 2) update state st.setLastBlockNumber(block.blockNumber); st.setLastBlockHash(block.getHash32()); - st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length); + st.setFileSizeBytes(st.getFileSizeBytes() + rawDataItem.length); st.setUpdatedAtMs(nowMs); - stateDAO.upsert(c, st); - // 2.1) Если блок — USER_PARAM, синхронизируем снимок в users_params в той же транзакции. - if (userParamEntry != null) { - userParamsDAO.upsertIfNewer(c, userParamEntry); - } + if (userParamEntry != null) userParamsDAO.upsertIfNewer(c, userParamEntry); + if (channelNameStateEntry != null) channelNameStateDAO.insert(c, channelNameStateEntry); + if (channelMetaUpdateEntry != null) channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry); + if (notificationEntry != null) userNotificationsStateDAO.upsert(c, notificationEntry); - if (channelNameStateEntry != null) { - channelNameStateDAO.insert(c, channelNameStateEntry); - } - - if (channelMetaUpdateEntry != null) { - channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry); - } - - // Notification projection is part of the same SQL transaction as the block. - // If notification indexing fails, the block/state write is rolled back as well. - if (notificationEntry != null) { - userNotificationsStateDAO.upsert(c, notificationEntry); - } - - // Channel read/unread projection is updated in the same SQL transaction as the block. int msgType = block.type & 0xFFFF; int msgSubType = block.subType & 0xFFFF; Integer lineCode = be.getLineCode(); @@ -126,7 +87,6 @@ public final class BlockchainWriter { } } - // Explicit UNFOLLOW removes the projection row. FOLLOW never creates it. if (msgType == 3 && msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF) && be.getToBchName() != null && !be.getToBchName().isBlank() @@ -138,39 +98,14 @@ public final class BlockchainWriter { } c.commit(); - committed = true; } catch (Exception e) { - try { - c.rollback(); - } catch (Exception ignored) { - } - - if (!committed) { - cleanupWriteArtifactsBestEffort(blockchainName); - } - - if (e instanceof SQLException se) { - throw se; - } + try { c.rollback(); } catch (Exception ignored) {} + if (e instanceof SQLException se) throw se; throw new SQLException("appendBlockAndState failed", e); } finally { - try { - c.setAutoCommit(true); - } catch (Exception ignored) { - } + try { c.setAutoCommit(true); } catch (Exception ignored) {} } } - - // 3) После commit — атомарно подменяем основной файл. - try { - fs.atomicReplaceBlockchainFile(blockchainName); - } catch (RuntimeException e) { - // marker/check/tmp оставляем для startup-recovery - throw e; - } - - // 4) После успешной подмены — чистим временные артефакты. - cleanupWriteArtifactsBestEffort(blockchainName); return counterChangedLogins; } @@ -183,46 +118,4 @@ public final class BlockchainWriter { || msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF) || msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF); } - - private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) { - byte[] base; - try { - if (Files.exists(fs.resolveBlockchainPath(blockchainName))) { - base = fs.readBlockchain(blockchainName); - } else { - base = new byte[0]; - } - } catch (RuntimeException e) { - throw e; - } - - byte[] out = new byte[base.length + blockBytes.length]; - System.arraycopy(base, 0, out, 0, base.length); - System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length); - return out; - } - - private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) { - fs.writeBlockchainTmp(blockchainName, candidateBytes); - fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex); - fs.writeBlockchainWritePendingMarker(blockchainName); - } - - private void cleanupWriteArtifactsBestEffort(String blockchainName) { - deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName)); - deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName)); - deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName)); - } - - private static void deleteQuietly(DeleteAction action) { - try { - action.run(); - } catch (RuntimeException ignored) { - } - } - - @FunctionalInterface - private interface DeleteAction { - void run(); - } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java deleted file mode 100644 index 210b2a6f..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java +++ /dev/null @@ -1,52 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.blockchain; - -import server.logic.ws_protocol.JSON.ConnectionContext; -import server.logic.ws_protocol.JSON.entyties.*; -import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.*; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.archive.ArchiveBlockchainLocation; -import shine.db.archive.ArchiveBigBlockRef; -import shine.db.archive.ArchiveChainCursor; -import shine.db.dao.ArchiveImportDAO; -import shine.db.dao.ArchivePublicationDAO; -import utils.config.AppConfig; - -import java.util.Base64; - -public final class Net_GetArchiveBlockchainLocation_Handler implements JsonMessageHandler { - @Override public Net_Response handle(Net_Request base, ConnectionContext ctx) { - Net_GetArchiveBlockchainLocation_Request req=(Net_GetArchiveBlockchainLocation_Request)base; - String bch=String.valueOf(req.getBlockchainName()==null?"":req.getBlockchainName()).trim(); - if(bch.isEmpty()) return NetExceptionResponseFactory.error(req,WireCodes.Status.BAD_REQUEST,"BAD_FIELDS","blockchainName обязателен"); - try { - ArchiveImportDAO importDao = ArchiveImportDAO.getInstance(); - ArchiveBlockchainLocation e=importDao.getLocation(bch); - if (e == null) { - // Backward-compatible fallback для publisher-а, который уже имел v22 cursors до появления v23 location index. - ArchivePublicationDAO publicationDao = ArchivePublicationDAO.getInstance(); - ArchiveChainCursor cursor = publicationDao.getCursor(bch); - if (cursor != null) { - ArchiveBigBlockRef matching = publicationDao.listFinalizedBigBlocks().stream() - .filter(ref -> ref.bigBlockNumber() == cursor.lastArchiveBigBlockNumber()) - .findFirst().orElse(null); - if (matching != null && matching.arweaveTxId() != null && matching.arweaveTxId().length == 32) { - String publisher = java.util.Objects.toString(AppConfig.getInstance().getParam("server.SHiNE.login"), "").trim(); - String txId = Base64.getUrlEncoder().withoutPadding().encodeToString(matching.arweaveTxId()); - e = new ArchiveBlockchainLocation( - bch, publisher, txId, cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(), - cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), System.currentTimeMillis()); - importDao.upsertLocation(e); - } - } - } - if(e==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.NOT_FOUND,"ARCHIVE_LOCATION_NOT_FOUND","Архивная ссылка пока не известна этому серверу"); - Net_GetArchiveBlockchainLocation_Response r=new Net_GetArchiveBlockchainLocation_Response(); - r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); - r.setBlockchainName(e.blockchainName()); r.setPublisherLogin(e.publisherLogin()); r.setArweaveTxId(e.arweaveTxId()); r.setArchiveHash(hex(e.archiveHash())); - r.setBigBlockNumber(e.bigBlockNumber()); r.setChunkOffset(e.chunkOffset()); r.setChunkSize(e.chunkSize()); r.setSourceLastBlockNumber(e.sourceLastBlockNumber()); return r; - } catch(Exception e) { return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"INTERNAL_ERROR",NetExceptionResponseFactory.detailedMessage("Не удалось получить архивную ссылку",e)); } - } - private static String hex(byte[] b){if(b==null)return"";StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();} -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java deleted file mode 100644 index a8189cb4..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java +++ /dev/null @@ -1,9 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -public final class Net_GetArchiveBlockchainLocation_Request extends Net_Request { - private String blockchainName; - public String getBlockchainName(){return blockchainName;} - public void setBlockchainName(String value){this.blockchainName=value;} -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java deleted file mode 100644 index cbe64495..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java +++ /dev/null @@ -1,16 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -public final class Net_GetArchiveBlockchainLocation_Response extends Net_Response { - private String blockchainName,publisherLogin,arweaveTxId,archiveHash; - private long bigBlockNumber,chunkOffset,chunkSize,sourceLastBlockNumber; - public String getBlockchainName(){return blockchainName;} public void setBlockchainName(String v){blockchainName=v;} - public String getPublisherLogin(){return publisherLogin;} public void setPublisherLogin(String v){publisherLogin=v;} - public String getArweaveTxId(){return arweaveTxId;} public void setArweaveTxId(String v){arweaveTxId=v;} - public String getArchiveHash(){return archiveHash;} public void setArchiveHash(String v){archiveHash=v;} - public long getBigBlockNumber(){return bigBlockNumber;} public void setBigBlockNumber(long v){bigBlockNumber=v;} - public long getChunkOffset(){return chunkOffset;} public void setChunkOffset(long v){chunkOffset=v;} - public long getChunkSize(){return chunkSize;} public void setChunkSize(long v){chunkSize=v;} - public long getSourceLastBlockNumber(){return sourceLastBlockNumber;} public void setSourceLastBlockNumber(long v){sourceLastBlockNumber=v;} -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java index 44fa49cf..22555c14 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java @@ -4,7 +4,6 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import utils.files.FileStoreUtil; /** * In-memory guard для цепочек, которые сейчас находятся в полном resync. @@ -39,10 +38,7 @@ public final class BlockchainResyncGuard { if (isBypassed(key)) { return false; } - if (ACTIVE.contains(key)) { - return true; - } - return FileStoreUtil.getInstance().existsBlockchainResyncMarker(key); + return ACTIVE.contains(key); } public static T withBypass(String blockchainName, ThrowingSupplier supplier) throws Exception { diff --git a/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java b/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java deleted file mode 100644 index 8c79f513..00000000 --- a/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java +++ /dev/null @@ -1,150 +0,0 @@ -package server.sync; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import shine.db.entities.SyncServerEntry; -import utils.files.FileStoreUtil; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.DirectoryStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * Startup-recovery для цепочек, которые были помечены как resync-pending. - * - * Правило простое: - * - marker-file означает, что chain должен быть пересобран заново; - * - мы не пытаемся продолжать с середины; - * - если recovery не завершён, обычная работа сервера не стартует. - */ -public final class BlockchainResyncRecoveryOnStartup { - - private static final Logger log = LoggerFactory.getLogger(BlockchainResyncRecoveryOnStartup.class); - private static final RemoteBlockchainSyncClient REMOTE = new RemoteBlockchainSyncClient(); - - private BlockchainResyncRecoveryOnStartup() {} - - public static void runRecoveryOrThrow() { - Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME); - ensureDirExists(dataDir); - - List markers = listMarkerFiles(dataDir); - if (markers.isEmpty()) { - log.info("🟢 BlockchainResyncRecovery: resync marker-файлы не найдены."); - return; - } - - log.warn("🟡 BlockchainResyncRecovery: найдено marker-файлов: {}", markers.size()); - for (Path marker : markers) { - recoverSingleMarkerOrThrow(marker); - } - log.info("✅ BlockchainResyncRecovery: все marker-файлы обработаны."); - } - - private static void recoverSingleMarkerOrThrow(Path markerPath) { - String fileName = markerPath.getFileName().toString(); - String blockchainName = extractBlockchainName(fileName); - if (blockchainName == null || blockchainName.isBlank()) { - throw new IllegalStateException("Bad resync marker name: " + fileName); - } - - Map meta = parseMarker(markerPath); - String partnerLogin = normalize(meta.get("partnerLogin")); - String partnerAddress = normalize(meta.get("partnerAddress")); - if (partnerAddress == null) { - throw new IllegalStateException("Resync marker has no partnerAddress for blockchainName=" + blockchainName); - } - - log.warn("🔁 BlockchainResyncRecovery: processing marker blockchainName={} partnerLogin={} partnerAddress={}", - blockchainName, partnerLogin, partnerAddress); - - try { - List heads = - REMOTE.listBlockchainHeads(partnerLogin, partnerAddress); - RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream() - .filter(h -> h != null && blockchainName.equals(h.blockchainName())) - .findFirst() - .orElseThrow(() -> new IllegalStateException( - "Partner does not expose blockchainName=" + blockchainName + " address=" + partnerAddress)); - - SyncServerEntry partner = new SyncServerEntry( - partnerLogin == null ? "" : partnerLogin, - partnerAddress, - System.currentTimeMillis() - ); - - PeriodicBlockchainSyncService.resyncFromScratch(partner, remoteHead); - } catch (Exception e) { - throw new IllegalStateException( - "Не удалось восстановить resync-помеченную цепочку blockchainName=" + blockchainName, - e - ); - } - } - - private static List listMarkerFiles(Path dataDir) { - try (DirectoryStream ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) { - return stream(ds).toList(); - } catch (IOException e) { - throw new IllegalStateException("Cannot list resync markers in: " + dataDir, e); - } - } - - private static java.util.stream.Stream stream(DirectoryStream ds) { - return java.util.stream.StreamSupport.stream(ds.spliterator(), false) - .filter(Files::isRegularFile); - } - - private static Map parseMarker(Path path) { - try { - List lines = Files.readAllLines(path, StandardCharsets.UTF_8); - Map result = new HashMap<>(); - for (String line : lines) { - if (line == null) continue; - String s = line.trim(); - if (s.isEmpty() || s.startsWith("#")) continue; - int idx = s.indexOf('='); - if (idx <= 0) continue; - String key = s.substring(0, idx).trim(); - String value = s.substring(idx + 1).trim(); - result.put(key, value); - } - return result; - } catch (IOException e) { - throw new IllegalStateException("Cannot read resync marker: " + path, e); - } - } - - private static void ensureDirExists(Path dir) { - try { - if (!Files.exists(dir)) { - Files.createDirectories(dir); - } - } catch (IOException e) { - throw new IllegalStateException("Cannot create data dir: " + dir, e); - } - } - - private static String extractBlockchainName(String fileName) { - if (fileName == null) return null; - String s = fileName.trim(); - if (!s.endsWith(FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) { - return null; - } - return s.substring(0, s.length() - FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION.length()); - } - - private static String normalize(String value) { - if (value == null) return null; - String s = value.trim(); - return s.isEmpty() ? null : s.toLowerCase(Locale.ROOT); - } -} diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java index a007d58d..3cab2c47 100644 --- a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java +++ b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java @@ -15,7 +15,6 @@ import shine.db.entities.BlockchainStateEntry; import shine.db.entities.SolanaUserPdaCurrentEntry; import shine.db.entities.SyncServerEntry; import server.sync.BlockchainResyncGuard; -import utils.files.FileStoreUtil; import utils.blockchain.BlockchainNameUtil; import java.util.concurrent.locks.ReentrantLock; @@ -52,7 +51,6 @@ public final class PeriodicBlockchainSyncService { private static final BlockchainStateDAO STATE_DAO = BlockchainStateDAO.getInstance(); private static final SyncServersDAO SYNC_SERVERS_DAO = SyncServersDAO.getInstance(); private static final BlockchainResyncCleanupDAO RESYNC_CLEANUP_DAO = BlockchainResyncCleanupDAO.getInstance(); - private static final FileStoreUtil FILE_STORE = FileStoreUtil.getInstance(); private static final SolanaUserPdaCurrentDAO SOLANA_USER_PDA_CURRENT_DAO = SolanaUserPdaCurrentDAO.getInstance(); private PeriodicBlockchainSyncService() {} @@ -214,26 +212,6 @@ public final class PeriodicBlockchainSyncService { return; } - String markerContent = """ - blockchainName=%s - partnerLogin=%s - partnerAddress=%s - remoteLastBlockNumber=%d - remoteLastBlockHash=%s - remoteFileSizeBytes=%d - startedAtMs=%d - """.formatted( - blockchainName, - partnerLogin == null ? "" : partnerLogin, - partner.getServerAddress() == null ? "" : partner.getServerAddress(), - remoteHead.lastBlockNumber(), - remoteHead.lastBlockHash(), - remoteHead.fileSizeBytes(), - System.currentTimeMillis() - ); - - FILE_STORE.writeBlockchainResyncMarker(blockchainName, markerContent); - ReentrantLock chainLock = BlockchainLocks.lockFor(blockchainName); chainLock.lock(); boolean success = false; @@ -248,9 +226,6 @@ public final class PeriodicBlockchainSyncService { cleanup.deletedBlocksRows(), cleanup.deletedBlockchainStateRows()); - FILE_STORE.deleteBlockchainFileIfExists(blockchainName); - FILE_STORE.deleteBlockchainTmpFileIfExists(blockchainName); - if (!ensureLocalChainExists(partner, blockchainName)) { throw new IllegalStateException("failed to recreate local chain state for " + blockchainName); } @@ -260,7 +235,6 @@ public final class PeriodicBlockchainSyncService { throw new IllegalStateException("failed to replay remote chain for " + blockchainName); } - FILE_STORE.deleteBlockchainResyncMarkerIfExists(blockchainName); success = true; log.info("Blockchain resync completed: partner={} blockchainName={} blocks=0..{}", partnerLogin, blockchainName, remoteHead.lastBlockNumber()); diff --git a/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java b/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java index 491bbb1d..1e0ec914 100644 --- a/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java +++ b/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java @@ -58,7 +58,15 @@ public final class SolanaUsersSyncStartupService { ); service.start(); - service.awaitReady(); + try { + service.awaitReady(); + } catch (Exception exception) { + log.warn( + "Solana users sync is not ready yet; continuing server startup and leaving sync service running", + exception + ); + return; + } log.info( "Solana users sync is READY. Server startup may continue." diff --git a/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java b/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java deleted file mode 100644 index 8e063dff..00000000 --- a/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java +++ /dev/null @@ -1,348 +0,0 @@ -package server.ws; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import shine.db.dao.BlocksDAO; -import shine.db.dao.BlockchainStateDAO; -import shine.db.entities.BlockEntry; -import shine.db.entities.BlockchainStateEntry; -import utils.files.FileStoreUtil; -import shine.log.BlockchainAdminNotifier; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.*; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.HexFormat; - -/** - * =============================================================== - * BlockchainTmpRecoveryOnStartup — восстановление консистентности - * файлов обычного AddBlock при старте сервера. - * - * Новая модель обычной записи: - * 1) собирается .tmp_bch как полный кандидат на замену main; - * 2) пишется sidecar .write_check (blockNumber/blockHash); - * 3) создаётся пустой marker .write_pending; - * 4) выполняется SQL-транзакция; - * 5) после commit tmp атомарно ставится на место main; - * 6) marker и sidecar удаляются. - * - * На старте: - * - если найден marker, recovery добивает запись или чистит мусор; - * - если marker нет, а tmp/check остались, это мусор и он удаляется; - * - legacy tmp-файлы без marker тоже считаются мусором. - * - * Принцип: - * - marker означает, что операция вошла в опасную фазу и должна быть доведена до конца или откатана; - * - sidecar нужен только как маленькое описание текущей операции (blockNumber/blockHash); - * - если marker отсутствует, временные файлы не считаются валидными. - * =============================================================== - */ -public final class BlockchainTmpRecoveryOnStartup { - - private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class); - private static final HexFormat HEX = HexFormat.of(); - - private BlockchainTmpRecoveryOnStartup() {} - - public static void runRecoveryOrThrow() { - FileStoreUtil fs = FileStoreUtil.getInstance(); - BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - BlocksDAO blocksDAO = BlocksDAO.getInstance(); - - Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME); - ensureDirExists(dataDir); - - List markers = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION); - if (!markers.isEmpty()) { - log.warn("🟡 BlockchainTmpRecovery: найдено marker-файлов обычного AddBlock: {}", markers.size()); - } else { - log.info("🟢 BlockchainTmpRecovery: marker-файлов обычного AddBlock не найдено."); - } - - for (Path marker : markers) { - recoverSingleWriteMarkerOrThrow(marker, fs, stateDAO, blocksDAO); - } - - cleanupOrphanTempArtifacts(dataDir, fs); - log.info("✅ BlockchainTmpRecovery: обработка временных файлов AddBlock завершена."); - } - - private static void recoverSingleWriteMarkerOrThrow(Path markerPath, - FileStoreUtil fs, - BlockchainStateDAO stateDAO, - BlocksDAO blocksDAO) { - String markerFileName = markerPath.getFileName().toString(); - String blockchainName = extractBlockchainName(markerFileName, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION); - if (blockchainName == null || blockchainName.isBlank()) { - BlockchainAdminNotifier.critical( - "НАЙДЕН write_pending marker С НЕОЖИДАННЫМ ИМЕНЕМ: " + markerFileName, - null - ); - throw new IllegalStateException("Bad write marker name: " + markerFileName); - } - - Path tmpPath = fs.resolveBlockchainTmpPath(blockchainName); - Path checkPath = fs.resolveBlockchainWriteCheckPath(blockchainName); - Path mainPath = fs.resolveBlockchainPath(blockchainName); - - Map meta = parseKeyValueFile(checkPath); - Integer expectedBlockNumber = parseInt(meta.get("blockNumber")); - String expectedBlockHashHex = normalizeHex(meta.get("blockHash")); - - try { - BlockchainStateEntry st = stateDAO.getByBlockchainName(blockchainName); - boolean mainExists = Files.exists(mainPath); - boolean tmpExists = Files.exists(tmpPath); - long mainSize = mainExists ? safeSize(mainPath) : -1L; - long tmpSize = tmpExists ? safeSize(tmpPath) : -1L; - - if (st == null) { - log.warn("🟠 BlockchainTmpRecovery: marker есть, но blockchain_state отсутствует. blockchainName={}. Удаляем временные файлы.", - blockchainName); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - long stateSize = st.getFileSizeBytes(); - - if (expectedBlockNumber == null || expectedBlockHashHex == null) { - log.warn("🟠 BlockchainTmpRecovery: write_check повреждён или пуст. blockchainName={}. Пробуем recovery по размерам.", - blockchainName); - - if (mainExists && mainSize == stateSize) { - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - if (tmpExists && tmpSize == stateSize) { - fs.atomicReplaceBlockchainFile(blockchainName); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - if (tmpExists && mainExists) { - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - BlockEntry block = blocksDAO.getByNumber(blockchainName, expectedBlockNumber); - if (block == null || block.getBlockHash() == null) { - log.warn("🟠 BlockchainTmpRecovery: в blocks нет ожидаемого блока. blockchainName={}, blockNumber={}. Чистим временные файлы.", - blockchainName, expectedBlockNumber); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - String actualHashHex = HEX.formatHex(block.getBlockHash()); - if (!actualHashHex.equalsIgnoreCase(expectedBlockHashHex)) { - log.warn("🟠 BlockchainTmpRecovery: hash в write_check не совпал с DB. blockchainName={}, expected={}, actual={}. Чистим временные файлы.", - blockchainName, expectedBlockHashHex, actualHashHex); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - // Если main уже совпадает со state — tmp/check/marker лишние. - if (mainExists && mainSize == stateSize) { - log.info("🟢 BlockchainTmpRecovery: main уже соответствует state. blockchainName={}, stateSize={}, mainSize={}", - blockchainName, stateSize, mainSize); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - // Если tmp уже готов и совпадает со state — просто ставим его на место main. - if (tmpExists && tmpSize == stateSize) { - log.warn("🟡 BlockchainTmpRecovery: tmp соответствует state, восстанавливаем main. blockchainName={}, stateSize={}, tmpSize={}", - blockchainName, stateSize, tmpSize); - fs.atomicReplaceBlockchainFile(blockchainName); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - // Если tmp нет, но DB уже закоммитила блок — пробуем восстановить tmp из main + block_bytes. - if (!tmpExists && mainExists) { - long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length; - if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) { - log.warn("🟡 BlockchainTmpRecovery: tmp отсутствует, но main+DB дают валидный кандидат. blockchainName={}. Восстанавливаем tmp и main.", - blockchainName); - byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes()); - fs.writeBlockchainTmp(blockchainName, rebuilt); - fs.atomicReplaceBlockchainFile(blockchainName); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - } - - // Если tmp есть, но его размер не совпал, пробуем восстановить из main + block_bytes. - if (mainExists) { - long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length; - if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) { - log.warn("🟡 BlockchainTmpRecovery: tmp/size не совпали, пересобираем tmp из main+block_bytes. blockchainName={}", - blockchainName); - byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes()); - fs.writeBlockchainTmp(blockchainName, rebuilt); - fs.atomicReplaceBlockchainFile(blockchainName); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - } - - // Если ничего не совпало, это уже подозрительное состояние. - BlockchainAdminNotifier.critical( - "НЕСОГЛАСОВАННОСТЬ ОПЕРАЦИИ AddBlock ПРИ СТАРТЕ. blockchainName=" + blockchainName + - ", stateSize=" + stateSize + - ", mainExists=" + mainExists + - ", mainSize=" + mainSize + - ", tmpExists=" + tmpExists + - ", tmpSize=" + tmpSize + - ", expectedBlockNumber=" + expectedBlockNumber + - ", expectedBlockHash=" + expectedBlockHashHex + - ". Требуется ручная проверка.", - null - ); - throw new IllegalStateException("AddBlock recovery mismatch for " + blockchainName); - } catch (SQLException e) { - BlockchainAdminNotifier.critical( - "ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ AddBlock marker: blockchainName=" + blockchainName, - e - ); - throw new IllegalStateException("DB error during AddBlock recovery for " + blockchainName, e); - } - } - - private static void cleanupOrphanTempArtifacts(Path dataDir, FileStoreUtil fs) { - List tmpFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION); - List checkFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION); - - if (tmpFiles.isEmpty() && checkFiles.isEmpty()) { - return; - } - - log.warn("🟡 BlockchainTmpRecovery: найдено orphan tmp/check файлов. tmp={}, check={}", tmpFiles.size(), checkFiles.size()); - for (Path tmp : tmpFiles) { - String blockchainName = extractBlockchainName(tmp.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION); - if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) { - continue; - } - safeDelete(tmp); - } - for (Path check : checkFiles) { - String blockchainName = extractBlockchainName(check.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION); - if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) { - continue; - } - safeDelete(check); - } - } - - private static void cleanupWriteArtifacts(Path markerPath, Path checkPath, Path tmpPath) { - safeDelete(markerPath); - safeDelete(checkPath); - safeDelete(tmpPath); - } - - private static byte[] rebuildTmpFromMainAndBlock(Path mainPath, byte[] blockBytes) { - try { - byte[] mainBytes = Files.exists(mainPath) ? Files.readAllBytes(mainPath) : new byte[0]; - byte[] out = new byte[mainBytes.length + blockBytes.length]; - System.arraycopy(mainBytes, 0, out, 0, mainBytes.length); - System.arraycopy(blockBytes, 0, out, mainBytes.length, blockBytes.length); - return out; - } catch (IOException e) { - throw new IllegalStateException("Cannot rebuild tmp from main: " + mainPath, e); - } - } - - private static Map parseKeyValueFile(Path path) { - Map result = new HashMap<>(); - if (path == null || !Files.exists(path)) { - return result; - } - try { - for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) { - if (line == null) continue; - String s = line.trim(); - if (s.isEmpty() || s.startsWith("#")) continue; - int idx = s.indexOf('='); - if (idx <= 0) continue; - String key = s.substring(0, idx).trim(); - String value = s.substring(idx + 1).trim(); - result.put(key, value); - } - return result; - } catch (IOException e) { - throw new IllegalStateException("Cannot read write_check file: " + path, e); - } - } - - private static Integer parseInt(String value) { - if (value == null || value.isBlank()) return null; - try { - return Integer.parseInt(value.trim()); - } catch (Exception e) { - return null; - } - } - - private static String normalizeHex(String value) { - if (value == null) return null; - String s = value.trim(); - return s.isEmpty() ? null : s; - } - - private static void ensureDirExists(Path dir) { - try { - if (!Files.exists(dir)) { - Files.createDirectories(dir); - } - } catch (IOException e) { - throw new IllegalStateException("Cannot create data dir: " + dir, e); - } - } - - private static List listFilesWithSuffix(Path dataDir, String suffix) { - List out = new ArrayList<>(); - try (DirectoryStream ds = Files.newDirectoryStream(dataDir, "*" + suffix)) { - for (Path p : ds) { - if (Files.isRegularFile(p)) { - out.add(p); - } - } - } catch (IOException e) { - throw new IllegalStateException("Cannot list files in: " + dataDir + " suffix=" + suffix, e); - } - return out; - } - - private static String extractBlockchainName(String fileName, String suffix) { - if (fileName == null) return null; - String s = fileName.trim(); - if (!s.endsWith(suffix)) return null; - String base = s.substring(0, s.length() - suffix.length()); - if (base.isBlank()) return null; - if (base.contains("/") || base.contains("\\") || base.contains("..")) return null; - return base; - } - - private static long safeSize(Path p) { - try { - return Files.size(p); - } catch (IOException e) { - throw new IllegalStateException("Cannot read file size: " + p, e); - } - } - - private static void safeDelete(Path p) { - try { - Files.deleteIfExists(p); - } catch (IOException e) { - throw new IllegalStateException("Cannot delete file: " + p, e); - } - } -} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 76ba4871..9a84e00a 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -5,11 +5,10 @@ import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import server.archive.ArchivePublisherScheduler; -import server.archive.ArchiveImportScheduler; +import server.archive.ArweaveBlockPublisherScheduler; +import server.archive.ArweaveBlockSyncScheduler; import server.debug.DebugApiConfigurator; import server.files.DmFileApiConfigurator; -import server.sync.BlockchainResyncRecoveryOnStartup; import server.sync.PeriodicBlockchainSyncService; import server.sync.PeriodicDmDeliveryService; import server.sync.SolanaUsersSyncStartupService; @@ -22,10 +21,7 @@ import java.time.Duration; /** * WsServer — поднимает Jetty WS на /ws. * - * ВАЖНО: - * - перед стартом сервера выполняем recovery tmp-блокчейнов. - * - если обнаружена несогласованность, которую сервер сам чинить не может — - * recovery бросает исключение и сервер не стартует. + * Пользовательские блоки хранятся в PostgreSQL; локальные .bch-файлы больше не используются. */ public final class WsServer { @@ -33,28 +29,6 @@ public final class WsServer { public static void main(String[] args) throws Exception { - // ============================================================ - // 0) Восстановление консистентности blockchain файлов - // ============================================================ - try { - BlockchainTmpRecoveryOnStartup.runRecoveryOrThrow(); - } catch (Exception e) { - // Уже должно быть “большое” уведомление через BlockchainAdminNotifier, - // но на всякий случай логируем ещё раз. - log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain tmp-файлов.", e); - throw e; // останавливаем запуск - } - - // ============================================================ - // 0.1) Восстановление цепочек, зависших на full resync - // ============================================================ - try { - BlockchainResyncRecoveryOnStartup.runRecoveryOrThrow(); - } catch (Exception e) { - log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain resync-маркеров.", e); - throw e; - } - // ============================================================ // 1.0) Синхронизация пользовательских Solana PDA // ============================================================ @@ -84,10 +58,9 @@ public final class WsServer { // ============================================================ PeriodicBlockchainSyncService.startOrLog(); - // Опциональная серверная публикация больших SHINE-ARCHIVE блоков. - ArchivePublisherScheduler.startOrLog(); - // Импорт больших архивов только от publisher-ов из archive.import.allowedPublishers. - ArchiveImportScheduler.startOrLog(); + // ANS-104: publish locally-created signed user blocks and discover blocks published by other servers. + ArweaveBlockPublisherScheduler.startOrLog(); + ArweaveBlockSyncScheduler.startOrLog(); // ============================================================ // 2) Запуск Jetty WS @@ -117,7 +90,7 @@ public final class WsServer { log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); ServerConnectionPool.getInstance().startOrLog(); Runtime.getRuntime().addShutdownHook(new Thread( - () -> { ServerConnectionPool.getInstance().close(); ArchivePublisherScheduler.close(); ArchiveImportScheduler.close(); }, + () -> { ServerConnectionPool.getInstance().close(); ArweaveBlockPublisherScheduler.close(); ArweaveBlockSyncScheduler.close(); }, "server-connection-pool-shutdown")); PeriodicDmDeliveryService.startOrLog(); server.join(); diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 577684e5..ea79ebe2 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -14,34 +14,6 @@ solana.users.sync.dbUser= solana.users.sync.dbPassword= solana.users.sync.pollIntervalSeconds=300 -# ------------------------------------------------------------ -# SHiNE archive publisher (Arweave + User PDA archive head) -# По умолчанию выключен: большинство серверов только читают/синхронизируют блоки. -# При включении готовые большие файлы сначала сохраняются локально в data/archive, -# затем загружаются в Arweave и только после подтверждения фиксируются в Solana. -# ------------------------------------------------------------ -archive.publish.enabled=false -archive.publish.time=00:00 -# Пусто = системная timezone сервера. Можно задать, например Europe/Warsaw. -archive.publish.zoneId= -archive.maxFileBytes=4000000000 -archive.workDir=data/archive - -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath= -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 - -# Для записи archive head используется уже существующий Solana RPC: -# solana.users.sync.rpcUrl (если задан), иначе solana.rpcUrl. -# Отдельного archive.solana.rpcUrl нет. -archive.solana.rootKeyPath= -archive.solana.clientKeyPath= -archive.solana.confirmPollSeconds=5 -archive.solana.confirmTimeoutMinutes=30 -archive.solana.commitment=finalized - # ------------------------------------------------------------ # Межсерверная синхронизация: как создавать локальную запись пользователя, # если во время sync пришла чужая цепочка, а у нас такого login ещё нет. @@ -156,8 +128,25 @@ test.freeAvatar.maxBytes=131072 test.freeAvatar.walletAddress= test.freeAvatar.walletJwkPath= -# Trusted SHINE-ARCHIVE import. Empty list = do not trust/import archives from anyone. -# Comma-separated SHiNE server logins, case-insensitive. -archive.import.allowedPublishers= -archive.import.intervalMinutes=60 -archive.import.workDir=data/archive-import +# ============================================================ +# Arweave per-user-block transport (ANS-104) +# Test namespace: each user DataItem is signed with App=test5590. +# Channel DataItems additionally contain c=. +# ============================================================ +arweave.blocks.publish.enabled=false +arweave.blocks.publish.intervalMinutes=15 +arweave.blocks.publish.maxItems=10000 +arweave.blocks.publish.maxBundleBytes=134217728 +arweave.blocks.publish.gateway=https://arweave.net +arweave.blocks.publish.walletJwkPath= +arweave.blocks.publish.minConfirmations=0 +arweave.blocks.publish.confirmPollSeconds=30 +arweave.blocks.publish.confirmTimeoutMinutes=180 + +arweave.blocks.sync.enabled=false +arweave.blocks.sync.intervalMinutes=15 +arweave.blocks.sync.gateway=https://turbo-gateway.com +arweave.blocks.sync.pageSize=100 +arweave.blocks.sync.queueBatchSize=10000 +arweave.blocks.sync.startBlockHeight=0 +arweave.blocks.sync.maxRootBundleBytes=268435456 diff --git a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java index f70ffb06..7e555a78 100644 --- a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java +++ b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java @@ -1,5 +1,6 @@ package test.it.blockchain; +import blockchain.Ans104DataItem; import blockchain.BchBlockEntry; import blockchain.body.*; import test.it.utils.TestConfig; @@ -9,20 +10,19 @@ import test.it.utils.log.TestLog; import test.it.utils.ws.WsSession; import java.time.Duration; +import java.util.ArrayList; import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; /** - * AddBlockSender — под новый формат BchBlockEntry (Frame v0): - * - blockBytes = preimage + sigMarker(2) + signature64 - * - preimage начинается с frameCode(2) = 0x0000 - * - hash32 вычисляется как sha256(preimage) - * - signature = Ed25519.sign(hash32) - * - * ВАЖНО: - * - Линии по ТЗ ведём на стороне сервера/БД (триггеры), а в тестах считаем локально. + * AddBlockSender для Frame v1: blockBytesB64 содержит готовый ANS-104 DataItem. + * SHiNE hash цепочки остаётся SHA-256(Frame v1 bytes), а пользователь подписывает + * ANS-104 deep-hash одним Ed25519 ключом. */ public final class AddBlockSender { @@ -34,6 +34,7 @@ public final class AddBlockSender { private final String login; private final String blockchainName; private final byte[] loginPrivKey; + private final Map channelSlugByRoot = new HashMap<>(); public AddBlockSender(WsSession ws, ChainState state, String login, String blockchainName, byte[] loginPrivKey) { this.ws = ws; @@ -75,22 +76,18 @@ public final class AddBlockSender { byte[] bodyBytes = body.toBytes(); - // ВАЖНО: preimage должен быть БАЙТ-В-БАЙТ таким же, как в BchBlockEntry - byte[] preimage = buildPreimage(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes); + byte[] frame = buildFrame(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes); + byte[] owner32 = utils.crypto.Ed25519Util.derivePublicKey(loginPrivKey); - byte[] hash32 = blockchain.BchCryptoVerifier.sha256(preimage); - byte[] signature64 = utils.crypto.Ed25519Util.sign(hash32, loginPrivKey); + List tags = new ArrayList<>(); + tags.add(new Ans104DataItem.Tag("App", "test5590")); + String channelSlug = channelSlugFor(body); + if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c", channelSlug)); - BchBlockEntry entry = new BchBlockEntry( - prevHash32, - blockNumber, - tsSec, - type, - subType, - version, - bodyBytes, - signature64 - ); + byte[] signingMessage = Ans104DataItem.buildSigningMessage(owner32, tags, frame); + byte[] signature64 = utils.crypto.Ed25519Util.sign(signingMessage, loginPrivKey); + byte[] rawDataItem = Ans104DataItem.serializeEd25519(signature64, owner32, tags, frame); + BchBlockEntry entry = new BchBlockEntry(rawDataItem); String prevHashHexForReq = (blockNumber == 0) ? ZERO64 : state.lastBlockHashHex(); @@ -119,6 +116,9 @@ public final class AddBlockSender { assertEquals(localHashHex, serverLastHash, op + ": serverLastBlockHash must match local hash"); state.applyAppendedBlock(blockNumber, entry.getHash32(), isHeader, type, body); + if (body instanceof CreateChannelBody ccb) { + channelSlugByRoot.put(blockNumber, shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName)); + } if (TestConfig.DEBUG()) TestLog.info(op + ": state updated"); } @@ -194,48 +194,47 @@ public final class AddBlockSender { throw new IllegalArgumentException("Unknown body class: " + body.getClass()); } - // ---------- preimage builder (строго по BchBlockEntry Frame v0) ---------- + // ---------- Frame v1 + signed channel tag helpers ---------- - private static byte[] buildPreimage(byte[] prevHash32, - int blockNumber, - long tsSec, - short type, - short subType, - short version, - byte[] bodyBytes) { + private String channelSlugFor(BodyRecord body) { + if (body instanceof CreateChannelBody ccb) { + return shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName); + } + if (body instanceof TextBody tb && tb.isLineMessage() && tb.lineCode > 0) { + String slug = channelSlugByRoot.get(tb.lineCode); + if (slug == null) { + throw new IllegalStateException("Unknown channel slug for lineCode=" + tb.lineCode); + } + return slug; + } + return null; + } + + private static byte[] buildFrame(byte[] prevHash32, + int blockNumber, + long tsSec, + short type, + short subType, + short version, + byte[] bodyBytes) { if (prevHash32 == null || prevHash32.length != 32) { throw new IllegalArgumentException("prevHash32 must be 32 bytes"); } - int bodyLen = (bodyBytes == null ? 0 : bodyBytes.length); - int blockSize = BchBlockEntry.PREIMAGE_HEADER_SIZE + bodyLen; - + int bodyLen = bodyBytes == null ? 0 : bodyBytes.length; + int blockSize = BchBlockEntry.FRAME_HEADER_SIZE + bodyLen; java.nio.ByteBuffer bb = java.nio.ByteBuffer.allocate(blockSize).order(java.nio.ByteOrder.BIG_ENDIAN); - - // [2] frameCode (v0) - bb.putShort((short) (BchBlockEntry.FRAME_CODE_V0 & 0xFFFF)); - - // [32] prevHash32 + bb.putShort((short) BchBlockEntry.FRAME_CODE_V1); bb.put(prevHash32); - - // [4] blockSize (preimage size) bb.putInt(blockSize); - - // [4] blockNumber bb.putInt(blockNumber); - - // [8] timestamp bb.putLong(tsSec); - - // [2][2][2] type/subType/version bb.putShort(type); bb.putShort(subType); bb.putShort(version); - - // [N] bodyBytes if (bodyBytes != null) bb.put(bodyBytes); - return bb.array(); } + } \ No newline at end of file diff --git a/VERSION.properties b/VERSION.properties index 08f9664b..49d7b524 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.19 -server.version=1.10.7 +client.version=1.12.20 +server.version=1.10.8 diff --git a/docs/API/04_Add_Block_to_Blockchain_API.md b/docs/API/04_Add_Block_to_Blockchain_API.md index 576662cf..d6fac193 100644 --- a/docs/API/04_Add_Block_to_Blockchain_API.md +++ b/docs/API/04_Add_Block_to_Blockchain_API.md @@ -1,310 +1,49 @@ -# API для разработчиков: 04 — Запись и чтение блока блокчейна +# AddBlock API -Документ описывает **текущий рабочий формат** сетевых вызовов: +## Назначение -- `AddBlock` — запись любого блока в блокчейн пользователя; -- `GetBlockchainBlock` — публичное чтение одного конкретного блока по имени цепочки и номеру. +Добавляет один готовый подписанный пользовательский SHiNE Frame v1 / ANS-104 DataItem в blockchain. -`GetBlockchainBlock` нужен в том числе для межсерверной синхронизации и для открытого чтения публичного блокчейна по одному блоку. - -> Важный принцип: на уровне JSON API сейчас есть **один универсальный метод** записи — `AddBlock`. -> Конкретный смысл записи задаётся типом самого бинарного блока (`type/subType/version` в заголовке блока). - -## 1. Что делает `AddBlock` - -`AddBlock`: -- принимает имя блокчейна и base64 бинарного блока; -- проверяет непрерывность цепочки (`blockNumber`, `prevHash`); -- проверяет формат и подпись Ed25519; -- валидирует `body` по правилам типа блока; -- сохраняет блок и обновляет состояние цепочки. - -## 2. JSON формат запроса - -`op = "AddBlock"`. +## Request ```json { "op": "AddBlock", - "requestId": "req-1001", + "requestId": "...", "payload": { - "blockchainName": "alice-001", - "blockNumber": 12, - "prevBlockHash": "ab12...ff", - "blockBytesB64": "AAAB..." + "blockchainName": "alice-...", + "blockNumber": 42, + "prevBlockHash": "64 hex chars", + "blockBytesB64": "..." } } ``` -Поля `payload`: -- `blockchainName` — обязательно, формат `login-NNN`. -- `blockNumber` — обязательно (временное legacy-поле для совместимости; должно совпасть с номером внутри бинарного блока). -- `prevBlockHash` — legacy-поле, сейчас сервер использует `prevHash` из бинарного блока и состояние цепочки. -- `blockBytesB64` — обязательно: **полный бинарный блок** (`preimage + sigMarker + signature`) в Base64. +- `blockchainName` — целевая пользовательская chain; +- `blockNumber` — должен совпадать с номером внутри Frame v1 и быть `serverLast + 1`; +- `prevBlockHash` — 32-byte SHiNE hash предыдущего Frame в hex; должен совпадать с `prevHash32` внутри Frame и серверной вершиной; +- `blockBytesB64` — **полный serialized ANS-104 DataItem** в Base64. -## 3. Успешный ответ +Старый формат `preimage + sigMarker + signature` не поддерживается. -```json -{ - "op": "AddBlock", - "requestId": "req-1001", - "status": 200, - "ok": true, - "payload": { - "reasonCode": null, - "serverLastGlobalNumber": 12, - "serverLastGlobalHash": "9f0e...a1" - } -} -``` +## Требования к DataItem -## 4. Ошибка (единый формат) +- generic Ed25519 signature type `2`; +- Ed25519 signature 64 bytes; +- owner 32 bytes и равен текущему blockchain public key; +- обязательный тестовый tag `App=test5590`; +- для channel block — `c=`; +- `data` содержит SHiNE Frame v1 (`frameCode=1`). -При ошибках сервер отдаёт `Net_Exception_Response` со стандартными полями и дополнительно с состоянием сервера для ресинка: +## Основные ошибки -```json -{ - "op": "AddBlock", - "requestId": "req-1001", - "status": 400, - "ok": false, - "error": "bad_prev_hash", - "message": "Некорректный prevHash (цепочка не совпадает)", - "payload": { - "serverLastGlobalNumber": 11, - "serverLastGlobalHash": "c3d4...98" - } -} -``` +- `bad_app_tag` — отсутствует/неверен `App=test5590`; +- `bad_channel_tag` — `c` отсутствует, лишний или не совпадает с canonical slug; +- `bad_signature` / `signature_verify_failed` — DataItem не подписан текущим blockchain key; +- `bad_block_number` — нарушена последовательность; +- `bad_prev_hash` — нарушена SHiNE hash chain; +- `bad_block_bytes` — DataItem/Frame не парсится. -### Основные `reasonCode` +## Storage/publish -- `empty_blockchain_name`, `bad_blockchain_name` -- `blockchain_state_not_found` -- `bad_block_base64`, `bad_block_format`, `bad_block_body` -- `bad_block_number`, `req_global_mismatch`, `bad_prev_hash` -- `bad_signature`, `signature_verify_failed` -- `prev_line_block_not_found`, `bad_prev_line_hash` -- `limit_exceeded` -- `chain_resync_in_progress` — цепочка временно заблокирована полным resync -- `repost_disabled` — репосты временно отключены до будущей реализации -- `entrypoint_edit_forbidden` — `TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST` -- `status_confirmed_target_must_be_status_action` — `STATUS_CONFIRMED` должен ссылаться на статусный блок -- `status_action_target_not_allowed` — выбранный `STATUS_ACTION` нельзя ставить на этот тип материала -- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META` -- `internal_error` - -## 5. Какие блоки реально можно добавлять через `AddBlock` - -Через `AddBlock` можно писать поддержанные форматы, кроме явно отключённых временных фич: - -1. **TECH (type=0)** - - `HEADER_COMPAT (subType=0)` - - `TECH_CREATE_CHANNEL (subType=1)` - -2. **TEXT (type=1)** - - `TEXT_POST (10)` - - `TEXT_EDIT_POST (11)` - - `TEXT_REPLY (20)` - - `TEXT_EDIT_REPLY (21)` - - `TEXT_RATING (30)` — target-based отзыв на конкретный блок - - `TEXT_REPOST (50)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled` - - `TEXT_CHANNEL_META (90)` — скрытый технический снимок профиля канала - - `TEXT_ENTRYPOINT (100)` — входная страница канала - - `TEXT_EXERCISE (110)` — line-based материал упражнения - - `TEXT_SERVICE (120)` — line-based материал услуги / процедуры - - `TEXT_COURSE (130)` — line-based материал курса - -3. **REACTION (type=2)** - - `REACTION_LIKE (1)` - -4. **CONNECTION (type=3)** - - `CONNECTION_FRIEND (10)` - - `CONNECTION_UNFRIEND (11)` - - `CONNECTION_CONTACT (20)` - - `CONNECTION_UNCONTACT (21)` - - `CONNECTION_FOLLOW (30)` - - `CONNECTION_UNFOLLOW (31)` - - `CONNECTION_SPOUSE (40)` - - `CONNECTION_UNSPOUSE (41)` - - `CONNECTION_PARENT (50)` - - `CONNECTION_UNPARENT (51)` - - `CONNECTION_CHILD (52)` - - `CONNECTION_UNCHILD (53)` - - `CONNECTION_SIBLING (54)` - - `CONNECTION_UNSIBLING (55)` - - `CONNECTION_KNOWN_PERSON (60)` - - `CONNECTION_UNKNOWN_PERSON (61)` - - `CONNECTION_SHINE_CONFIRMED (70)` - - `CONNECTION_SHINE_UNCONFIRMED (71)` - - `CONNECTION_SHINE_SEEN (74)` - - `CONNECTION_SHINE_UNSEEN (75)` - -5. **USER_PARAM (type=4)** - - `USER_PARAM_TEXT_TEXT (1)` - -6. **STATUS_ACTION (type=5)** - - `STATUS_DONE_ONCE (10)` - - `STATUS_LEARNED (20)` - - `STATUS_SERVICE_PASSED (30)` - - `STATUS_CONFIRMED (100)` - - `STATUS_INTERESTED (110)` - - `STATUS_STARTED (120)` - - `STATUS_IN_STUDY (130)` - - `STATUS_ABANDONED (140)` - - `STATUS_COMPLETED (150)` - -## 6. Практические payload-форматы для каналов и вложений - -`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата. - -### Вложения в сообщениях - -Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `S:att v=1`. - -Пример текстового содержимого body: - -```text - - -Текст сообщения -``` - -Пример вложения с отдельным preview-файлом: - -```text - -``` - -Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`. - -### Создание публичного канала с профилем - -Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст: - -```text - - -Описание канала -``` - -Ограничение `channelDescription` — до `2048` UTF-8 байт. Новый клиент не пишет отдельный `TEXT_CHANNEL_META` сразу после создания канала: создание канала и начальный профиль должны попадать в один `TECH_CREATE_CHANNEL`. - -### Изменение профиля канала - -Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`. - -Текстовое содержимое body использует тот же формат полного снимка профиля: - -```text - - -Новое описание канала -``` - -Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `S:ava` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`. - -## 7. Хватает ли функций сейчас - -Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**. - -Что есть: -- единый надёжный write-путь `AddBlock`; -- есть `GetFriendsLists` и API по `UserParam`; -- есть унифицированные коды ошибок и поля для ресинхронизации. - -Что пока ограничивает продукт: -- нет полноценного read API для каналов/постов/тредов; -- нет API списка подписок с серверными счётчиками непрочитанного; -- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC. - -## 8. Рекомендации по клиенту при записи блоков - -1. Перед отправкой держать локальный `lastNumber/lastHash`. -2. При `bad_prev_hash` или `bad_block_number`: - - взять `serverLastGlobalNumber/serverLastGlobalHash` из ошибки, - - пересобрать следующий блок на актуальной вершине. -3. Для edit-блоков всегда ссылаться на **оригинальный** блок, а не на предыдущий edit. -4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост. - - -## 9. USER_PARAM для «личных данных» - -Да, на текущем API это можно добавить **без изменения серверного кода**: - -- в `UserParam` поле `param` сейчас не ограничено фиксированным справочником; -- сервер хранит пары `param -> value` как строки (при наличии корректной подписи и `time_ms`); -- чтение уже есть через `GetUserParam` и `ListUserParams`. - -Рекомендуемый стартовый набор ключей для профиля (MVP): - -- `name` -- `last_name` -- `address_physical` -- `address_web` -- `phone` - -Практическая рекомендация: заранее зафиксировать единый словарь ключей в клиенте/документации, чтобы избежать дублей вида `lastname` vs `last_name`, `site` vs `address_web` и т.д. - -Ограничения, которые важно учесть: - -- сейчас нет серверной ACL-политики чтения параметров (в MVP их может читать любой клиент, который знает `login`); -- нет валидации формата значений для конкретных ключей (телефон, URL и т.д. проверяются только на стороне клиента); -- нет отдельного индекса/поиска по этим полям — только точечное чтение и listing по `login`. - ---- - -## 10. `GetBlockchainBlock` - -### Назначение - -Публичное чтение одного конкретного блока из цепочки. - -Нужно для: - -- открытого чтения блокчейна по одному блоку; -- межсерверной синхронизации; -- восстановления/докачки отсутствующего хвоста цепочки. - -### JSON формат запроса - -`op = "GetBlockchainBlock"`. - -```json -{ - "op": "GetBlockchainBlock", - "requestId": "req-2001", - "payload": { - "blockchainName": "alice-001", - "blockNumber": 12 - } -} -``` - -Поля `payload`: - -- `blockchainName` — обязательно, формат `login-NNN`. -- `blockNumber` — обязательно, номер блока в цепочке, `>= 0`. - -### Успешный ответ - -```json -{ - "op": "GetBlockchainBlock", - "requestId": "req-2001", - "status": 200, - "ok": true, - "payload": { - "blockchainName": "alice-001", - "blockNumber": 12, - "blockHash": "9f0eaabbccddeeff00112233445566778899aabbccddeeff0011223344556677", - "blockBytesB64": "AAAB..." - } -} -``` - -### Ошибки - -- `400 / BAD_FIELDS` — некорректные `blockchainName` или `blockNumber`. -- `404 / BLOCK_NOT_FOUND` — такого блока нет. -- `500 / INTERNAL_ERROR` — внутренняя ошибка сервера. +После успешного локального AddBlock полный DataItem хранится в PostgreSQL и ставится в Arweave publish queue. Импорт из Arweave использует ту же проверку, но не ставит блок на повторную публикацию. diff --git a/docs/Archive/01_PROTOCOL_v1.0.md b/docs/Archive/01_PROTOCOL_v1.0.md deleted file mode 100644 index 8bef6d09..00000000 --- a/docs/Archive/01_PROTOCOL_v1.0.md +++ /dev/null @@ -1,1647 +0,0 @@ -# SHiNE Archive Protocol v1.0 - -## Полная спецификация серверной архивации SHiNE-блоков в Arweave и фиксации головы архива в Solana - -**Статус:** согласованная спецификация v1.0 -**Язык:** русский -**Magic:** `SHINE-ARCHIVE` -**Версия протокола:** `1.0` -**Endian:** big-endian для всех фиксированных целых чисел - ---- - -# 1. Назначение - -Этот документ описывает механизм, по которому SHiNE-сервер периодически: - -1. синхронизирует обычные SHiNE-блоки существующим механизмом; -2. определяет новые локальные блоки, ещё не попавшие в архив; -3. фиксирует неизменяемую дельту; -4. группирует все новые записи одной `blockchain_name` в один блок пользователя внутри большого архивного блока; -5. добавляет к каждому такому пользовательскому блоку одну ссылку на предыдущий пользовательский блок этой же `blockchain_name`; -6. формирует один большой бинарный блок `SHINE-ARCHIVE`; -7. загружает его в Arweave; -8. ждёт закрепления/подтверждения Arweave-транзакции; -9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; -10. только после Solana `finalized` переводит локальные курсоры на новую точку. - -На первом этапе предполагается один архивный server, но формат изначально не должен запрещать появление других архиваторов. - ---- - -# 2. Основные понятия - -## 2.1. Исходный SHiNE-блок / запись - -Это существующие в серверной БД оригинальные `block_bytes`, относящиеся к конкретной `blockchain_name`. - -Они уже содержат пользовательские данные и существующую криптографию SHiNE. - -Архиватор: - -- не изменяет их; -- не переподписывает их; -- не переводит их в Base64; -- не пересобирает их содержимое. - -## 2.2. `blockchain_name` - -В архивном формате основной идентификатор пользовательской цепочки — именно `blockchain_name`. - -Отдельно хранить `login` для пользовательского блока не требуется. - -`blockchain_name` уже однозначно идентифицирует конкретную пользовательскую blockchain и логически включает пользовательскую идентичность + номер/вариант цепочки. - -## 2.3. Большой архивный блок - -Один файл/объект `SHINE-ARCHIVE`, который сервер формирует за один цикл архивной публикации. - -Он содержит: - -- header; -- список предыдущих больших архивных блоков; -- по одному `UserBlockchainChunk` для каждой `blockchain_name`, у которой есть новые записи; -- footer; -- SHA-256 большого блока; -- подпись сервера, который этот большой блок сформировал и закрыл. - -Один большой архивный блок после публикации соответствует одной Arweave-транзакции. - -## 2.4. `UserBlockchainChunk` - -В одном большом архивном блоке для одной `blockchain_name` существует максимум один такой chunk. - -Если за текущую дельту у пользователя/цепочки накопилось 1, 3, 5 или 20 записей, все они помещаются внутрь одного `UserBlockchainChunk`. - -Навигационная ссылка одна на весь chunk. - ---- - -# 3. Главная модель пользовательской истории - -Допустим `alice-001` имела записи: - -```text -BigBlock 70: - AliceChunk = 2 записи - -BigBlock 81: - AliceChunk = 5 записей - -BigBlock 95: - AliceChunk = 3 записи - -BigBlock 100: - AliceChunk = 4 записи -``` - -Тогда история `alice-001` выглядит: - -```text -BigBlock 100 - AliceChunk - 4 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 95 - AliceChunk - 3 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 81 - AliceChunk - 5 записей - | - v -BigBlock 70 -``` - -Навигация идёт пачками записей одной blockchain, а не по одной записи за раз. - ---- - -# 4. Сервер сам добавляет навигацию - -Пользователь не обязан знать: - -- в какой большой архивный блок попадут его записи; -- какой будет Arweave TX ID; -- какие будут offsets; -- где находится предыдущий chunk. - -Все эти данные сервер добавляет при формировании большого блока. - -Исходные подписанные пользовательские `block_bytes` остаются неизменными. - -`PreviousBlockchainChunkRef` является внешней серверной архивной метаинформацией и защищается hash + подписью всего большого архивного блока. - ---- - -# 5. Необходимые ключи архивного сервера - -Архивный server хранит: - -## 5.1. SHiNE root private key - -Используется: - -- для подписания новой версии User PDA при обычном `update_user_pda`; -- в v1.0 — для подписи закрытого большого архивного блока. - -## 5.2. SHiNE client private key - -Используется как Solana transaction signer / fee payer в соответствии с текущей логикой `shine_users`. - -## 5.3. Arweave JWK / private wallet key - -Используется для загрузки и оплаты Arweave-транзакции. - -## 5.4. Blockchain private key - -Для архивной публикации не требуется. - -Архиватор не создаёт новые пользовательские blockchain-блоки. - ---- - -# 6. Расписание публикации - -Архивная публикация запускается один раз в сутки в заданное локальное время. Она НЕ использует интервал «каждые N минут». - -Пример: - -```properties -archive.publish.enabled=true -archive.publish.time=00:00 -archive.publish.zoneId= -``` - -Для v1.0 значение по умолчанию: - -```text -00:00 -``` - -То есть новый snapshot и новый большой архивный блок создаются один раз в сутки в полночь. - -Если `archive.publish.zoneId` пуст, используется системная timezone сервера. При необходимости её можно задать явно, например `Europe/Warsaw`. Это сохраняет публикацию ровно в указанное локальное время даже при переходах летнего/зимнего времени. - -Незавершённый archive job после рестарта не ждёт следующей полуночи: сервер продолжает именно его сразу. Новый snapshot при старте вне назначенного времени не создаётся. - -## 6.1. Первая архивная публикация - -Если у данного archive publisher ещё нет подтверждённых архивных курсоров, первая публикация берёт ВСЁ локально известное состояние: - -```text -для каждой blockchain_name: -source block 0 .. current local head -``` - -То есть первый большой архивный блок содержит все SHiNE-блоки, которые сервер успел узнать к моменту первого суточного snapshot. После успешной публикации следующие большие блоки содержат только дельту относительно подтверждённых курсоров. - -## 6.2. Локальная папка и имена файлов - -Перед любой сетевой загрузкой большой блок сначала полностью создаётся на локальном диске. По умолчанию каталог: - -```text -data/archive/ -``` - -До получения Arweave TX ID файл имеет временное имя: - -```text -.<00001>.<дд.мм.гг>.tmp.SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.tmp.SHiNE-archive -``` - -Дата — реальная дата создания/freeze snapshot большого блока в timezone archive publisher-а. Номер имеет минимальную ширину 5 цифр. Пять цифр — форматирование, а не лимит: блок `100000` получает шестизначный номер. - -После успешной загрузки Arweave возвращает реальный TX ID. Сервер сначала надёжно сохраняет TX ID в БД, затем атомарно переименовывает тот же локальный файл в: - -```text -.<00001>.<дд.мм.гг>..SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive -``` - -Поле `` — не слово `trx`, а настоящий Base64URL TX ID загруженного объекта в Arweave. Финальный файл остаётся локально как постоянная копия. - -Crash recovery обязан продолжать работу с этим же файлом. Если TX ID уже сохранён, но процесс упал до rename, при следующем запуске сервер вычисляет финальное имя из сохранённого TX ID и завершает переименование без повторной сборки дельты. - ---- - -# 7. Состояние, относительно которого считается дельта - -Нужно различать: - -1. уже окончательно опубликованное состояние; -2. текущий замороженный job, который ещё проходит Arweave/Solana. - -Нельзя считать дельту от живой головы во время уже запущенной публикации. - ---- - -# 8. Таблица `archive_chain_cursor` - -Хранит окончательно подтверждённое архивное состояние для каждой `blockchain_name`. - -```sql -CREATE TABLE archive_chain_cursor ( - blockchain_name TEXT PRIMARY KEY, - - last_archived_source_block_number BIGINT NOT NULL, - last_archived_source_block_hash BYTEA NOT NULL, - - last_archive_big_block_number BIGINT, - last_archive_big_block_hash BYTEA, - - last_chunk_offset BIGINT, - last_chunk_size BIGINT, - - updated_at_ms BIGINT NOT NULL -); -``` - -Назначение: - -- `last_archived_source_block_number` — до какого исходного SHiNE-блока этой blockchain всё успешно опубликовано; -- `last_archived_source_block_hash` — hash этого исходного блока; -- `last_archive_big_block_number` и `last_archive_big_block_hash` — в каком большом `SHINE-ARCHIVE` находится последний chunk этой blockchain; -- `last_chunk_offset` и `last_chunk_size` — точное положение последнего chunk внутри большого блока. - -Этого достаточно, чтобы следующая публикация создала один backlink на предыдущий chunk. - ---- - -# 9. Отдельный индекс по каждой записи не нужен - -Поскольку все новые записи одной `blockchain_name` агрегируются в один `UserBlockchainChunk`, не нужно хранить offsets каждой отдельной записи в истории. - -Для следующей публикации достаточно помнить: - -```text -предыдущий big block -предыдущий big block hash -chunk offset -chunk size -``` - ---- - -# 10. Таблица `archive_publish_job` - -Хранит crash-safe состояние одной архивной публикации. - -```sql -CREATE TABLE archive_publish_job ( - id BIGSERIAL PRIMARY KEY, - - big_block_number BIGINT NOT NULL, - status TEXT NOT NULL, - - created_at_ms BIGINT NOT NULL, - - local_archive_path TEXT, - - archive_hash BYTEA, - arweave_tx_id BYTEA, - - arweave_confirmations INTEGER, - solana_signature TEXT, - - error_text TEXT, - updated_at_ms BIGINT NOT NULL -); -``` - -Рекомендуемые статусы: - -```text -SNAPSHOT_CREATED -FILE_BUILT -ARWEAVE_UPLOADED -ARWEAVE_CONFIRMED -SOLANA_SUBMITTED -SOLANA_FINALIZED -CURSORS_COMMITTED -FAILED -``` - -Завершённые rows этой таблицы одновременно дают локальную историю больших блоков: - -```text -big_block_number -archive_hash -arweave_tx_id -``` - -По ним строится FULL-список предыдущих больших блоков следующей публикации. - ---- - -# 11. Таблица `archive_publish_job_chain` - -Хранит frozen range каждой blockchain в текущем job и координаты создаваемого chunk. - -```sql -CREATE TABLE archive_publish_job_chain ( - job_id BIGINT NOT NULL, - blockchain_name TEXT NOT NULL, - - from_source_block_number BIGINT NOT NULL, - to_source_block_number BIGINT NOT NULL, - - previous_source_block_hash BYTEA NOT NULL, - last_source_block_hash BYTEA NOT NULL, - - previous_archive_big_block_number BIGINT, - previous_archive_big_block_hash BYTEA, - previous_chunk_offset BIGINT, - previous_chunk_size BIGINT, - - new_chunk_offset BIGINT, - new_chunk_size BIGINT, - - PRIMARY KEY (job_id, blockchain_name) -); -``` - ---- - -# 12. Заморозка дельты - -Пример: - -```text -локально: -alice-001 = source block 180 -bob-001 = source block 94 - -опубликовано: -alice-001 = 160 -bob-001 = 90 -``` - -Новый job фиксирует: - -```text -alice-001: 161..180 -bob-001: 91..94 -``` - -Если во время загрузки появятся: - -```text -alice-001 181..185 -bob-001 95..97 -``` - -они попадут только в следующий job. - ---- - -# 13. Проверка курсора перед публикацией - -Перед созданием frozen range сервер проверяет: - -```text -local hash(last_archived_source_block_number) -== -archive_chain_cursor.last_archived_source_block_hash -``` - -При несовпадении публикация этой chain останавливается до resync/fork handling. - ---- - -# 14. Ограничение размера большого блока - -Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. - -Поэтому один большой archive-файл MUST быть меньше `4 GiB`. - -Рекомендуемый запас: - -```properties -archive.maxFileBytes=4000000000 -``` - -Если собранный frozen job превышает этот лимит, v1.0 останавливает публикацию с явной ошибкой `ArchiveTooLargeException` и не двигает курсоры. Практически лимит очень велик; для такого сервера следует уменьшить объём данных между суточными закрытиями или реализовать деление snapshot на несколько big blocks. Автоматическое деление одного snapshot на несколько big blocks оставлено как совместимое будущее расширение. - ---- - -# 15. Magic и версия - -Файл начинается ASCII: - -```text -SHINE-ARCHIVE -``` - -Размер: 13 байт. - -Далее: - -```text -u8 version_major -u8 version_minor -``` - -Для v1.0: - -```text -1 -0 -``` - ---- - -# 16. Общий layout большого блока - -```text -+------------------------------------------+ -| FIXED HEADER | -+------------------------------------------+ -| creator_login | -+------------------------------------------+ -| PREVIOUS BIG BLOCK REFERENCES | -| ref[0] | -| ref[1] | -| ... | -+------------------------------------------+ -| UserBlockchainChunk #1 | -| UserBlockchainChunk #2 | -| ... | -+------------------------------------------+ -| FOOTER: closer_login | -| block_hash | -| signature | -+------------------------------------------+ -``` - ---- - -# 17. Fixed Header v1.0 - -Все integers — big-endian. - -| Поле | Тип | Размер | -|---|---:|---:| -| magic | bytes[13] | 13 | -| version_major | u8 | 1 | -| version_minor | u8 | 1 | -| header_size | u32 | 4 | -| big_block_number | u32 | 4 | -| created_at_ms | u64 | 8 | -| creator_login_length | u8 | 1 | -| references_mode | u8 | 1 | -| references_count | u32 | 4 | -| reference_entry_size | u16 | 2 | -| parent_reference_index | u32 | 4 | -| blockchain_chunks_count | u32 | 4 | -| total_user_records_count | u32 | 4 | - -После fixed header немедленно идут `creator_login` bytes. - -`header_size` — byte offset первого `PreviousBigBlockReference`. - ---- - -# 18. `creator_login` - -SHiNE login пользователя/сервера, который сформировал большой блок. - -```text -u8 creator_login_length -bytes[N] creator_login UTF-8 -``` - -Этот login находится в начале файла, поэтому его можно узнать без скачивания payload. - ---- - -# 19. `big_block_number` - -```text -u32 -``` - -Рекомендуемая нумерация опубликованных больших блоков: - -```text -first = 1 -next = 2 -next = 3 -... -``` - -Первый реально публикуемый большой блок имеет номер `1`, поэтому его локальное имя содержит `00001`. Неудачная незавершённая попытка не становится частью опубликованной archive-цепочки. - ---- - -# 20. `created_at_ms` - -```text -u64 -``` - -Unix Epoch UTC milliseconds. - -Это время заморозки содержимого big block. - ---- - -# 21. Режим ссылок на предыдущие большие блоки - -```text -references_mode u8 -``` - -Значения: - -```text -0 = FULL -1 = PARTIAL, зарезервировано на будущее -``` - -Writer v1.0 MUST использовать `FULL`. - ---- - -# 22. FULL previous-block table - -Новый big block содержит ссылки на ВСЕ предыдущие большие блоки своей ветки. - -Пример: - -```text -BigBlock #365 - -References: -#1 -#2 -#3 -... -#364 -``` - -При одном big block в день через год таблица занимает примерно 24.8 KiB. - ---- - -# 23. `PreviousBigBlockReference` - -Фиксированный размер: 68 байт. - -| Поле | Тип | Размер | -|---|---:|---:| -| big_block_number | u32 | 4 | -| big_block_hash | bytes[32] | 32 | -| arweave_tx_id | bytes[32] | 32 | - -Семантика: - -```text -big_block_number = логический номер -big_block_hash = криптографическая идентичность -arweave_tx_id = где скачать блок -``` - ---- - -# 24. `references_count` - -Количество entries в таблице ссылок. - -```text -u32 -``` - ---- - -# 25. `reference_entry_size` - -```text -u16 -``` - -Для v1.0: - -```text -68 -``` - -Поле оставлено для будущего расширения entry. - ---- - -# 26. `parent_reference_index` - -```text -u32 -``` - -Индекс непосредственного родителя внутри reference table. - -Для первого большого блока (`#1`), у которого нет родителя: - -```text -0xFFFFFFFF -``` - -В простой FULL-линейной ветке обычно это последний entry. - ---- - -# 27. Быстрое скачивание начала блока - -Клиент сначала скачивает fixed header + creator login. - -Из него узнаёт: - -```text -header_size -references_count -reference_entry_size -``` - -Затем скачивает только reference table и уже имеет адреса/hash всех предыдущих больших блоков. - -Payload пользователей можно пока не скачивать. - ---- - -# 28. `blockchain_chunks_count` - -```text -u32 -``` - -Количество `UserBlockchainChunk` в текущем big block. - -Одна `blockchain_name` встречается максимум один раз. - ---- - -# 29. `total_user_records_count` - -```text -u32 -``` - -Общее количество исходных SHiNE-записей во всех chunks. - ---- - -# 30. Порядок chunks и records - -Для детерминированной сериализации chunks SHOULD сортироваться по `blockchain_name ASC`. - -Внутри chunk исходные записи MUST идти по возрастанию номера исходного SHiNE-блока. - ---- - -# 31. Формат `UserBlockchainChunk` - -```text -u32 chunk_size - -u8 blockchain_name_length -bytes[N] blockchain_name UTF-8 - -u32 records_count - -repeat records_count: - u32 record_size - bytes[M] raw_record_bytes - -u32 previous_big_block_ref -u32 previous_chunk_offset -u32 previous_chunk_size -``` - ---- - -# 32. `chunk_size` - -```text -u32 -``` - -Полный размер chunk от первого байта `chunk_size` до последнего байта `previous_chunk_size` включительно. - -Это позволяет: - -- быстро пропустить chunk; -- скачать его Range-запросом; -- сохранить его точные координаты для следующего backlink. - ---- - -# 33. `blockchain_name` внутри chunk - -Хранится только `blockchain_name`. - -Отдельный `login` не нужен. - ---- - -# 34. `records_count` - -```text -u32 -``` - -Если за период у `alice-001` появилось 7 записей: - -```text -records_count = 7 -``` - -Все 7 находятся в одном chunk. - ---- - -# 35. Raw records - -Каждая исходная запись сериализуется: - -```text -u32 record_size -bytes[record_size] raw_record_bytes -``` - -`raw_record_bytes` — оригинальные данные SHiNE из БД. - -Сервер не меняет их содержимое. - ---- - -# 36. Один backlink на весь chunk - -После всех raw records находится ровно один `PreviousBlockchainChunkRef`. - -Он относится ко всей пачке записей blockchain. - -Он не повторяется у отдельных записей. - ---- - -# 37. `PreviousBlockchainChunkRef` - -| Поле | Тип | Размер | -|---|---:|---:| -| previous_big_block_ref | u32 | 4 | -| previous_chunk_offset | u32 | 4 | -| previous_chunk_size | u32 | 4 | - -Итого 12 байт на весь chunk. - ---- - -# 38. `previous_big_block_ref` - -Индекс в `PreviousBigBlockReferences[]` текущего big block. - -Он указывает на тот предыдущий big block, где находится предыдущий chunk этой же `blockchain_name`. - -Это именно reference index, а не номер блока. - ---- - -# 39. `previous_chunk_offset` - -```text -u32 -``` - -Byte offset от начала предыдущего `SHINE-ARCHIVE` до первого байта предыдущего chunk этой blockchain. - -Первый байт chunk — поле `chunk_size`. - ---- - -# 40. `previous_chunk_size` - -```text -u32 -``` - -Полный размер предыдущего chunk. - -Поэтому клиент может Range-запросом забрать сразу всю предыдущую пачку записей пользователя. - ---- - -# 41. Первая публикация blockchain - -Если у данной `blockchain_name` нет предыдущего архивного chunk: - -```text -previous_big_block_ref = 0xFFFFFFFF -previous_chunk_offset = 0 -previous_chunk_size = 0 -``` - ---- - -# 42. Пример backlink-цепочки - -В `BigBlock #100` есть `alice-001` chunk из 4 записей. - -Его backlink: - -```text -previous_big_block_ref = 94 -previous_chunk_offset = 8123 -previous_chunk_size = 1770 -``` - -`ref[94]` содержит: - -```text -big_block_number = 95 -big_block_hash = ... -arweave_tx_id = ... -``` - -Клиент: - -1. получает TX big block #95; -2. проверяет hash; -3. делает Range на `8123 .. 8123+1770`; -4. получает весь предыдущий `alice-001` chunk; -5. читает его записи; -6. берёт следующий backlink. - ---- - -# 43. Почему TX ID не хранится в каждом chunk - -TX ID и hash предыдущих больших блоков уже один раз записаны в общей reference table. - -Поэтому в chunk достаточно компактного `u32 previous_big_block_ref`. - ---- - -# 44. Footer большого блока - -После последнего chunk: - -```text -u8 closer_login_length -bytes[N] closer_login UTF-8 - -bytes[32] block_hash -bytes[64] closer_signature -``` - ---- - -# 45. `closer_login` - -Login пользователя/сервера, который сформировал и закрыл big block. - -В v1.0: - -```text -closer_login MUST == creator_login -``` - -Login повторяется намеренно: - -- header даёт автора сразу; -- footer явно фиксирует того, кто закрыл блок; -- hash покрывает footer login; -- verifier проверяет совпадение. - ---- - -# 46. `block_hash` - -```text -bytes[32] -SHA-256 -``` - -Хэшируется всё от первого байта magic до последнего байта `closer_login` включительно. - -Не входят: - -```text -block_hash -closer_signature -``` - -Псевдокод: - -```text -hash_input = - fixed_header - + creator_login - + references - + all UserBlockchainChunks - + closer_login_length - + closer_login - -block_hash = SHA256(hash_input) -``` - ---- - -# 47. `closer_signature` - -```text -bytes[64] -Ed25519 -``` - -Подписывается: - -```text -ASCII("SHINE-ARCHIVE-V1") || block_hash -``` - -Для v1.0 используется root private key аккаунта `creator_login`. - ---- - -# 48. Проверка подписи - -Verifier: - -1. читает `creator_login`; -2. проверяет `creator_login == closer_login`; -3. получает root public key аккаунта из User PDA / исторического состояния Solana; -4. пересчитывает `block_hash`; -5. проверяет Ed25519 signature. - -Старые архивы должны оставаться проверяемыми после ротации root key, поэтому исторические PDA-состояния нужно сохранять/уметь получать. - ---- - -# 49. Собственный Arweave TX ID текущего блока - -Текущий файл не может заранее знать свой будущий TX ID. - -Поэтому собственный TX ID: - -- не входит в сам текущий файл; -- сохраняется после upload; -- записывается в Solana PDA; -- появляется как previous-block reference уже в следующем big block. - ---- - -# 50. User PDA block type `100` - -Archive head хранится прямо в существующем User PDA publisher-а. - -Формат: - -```text -u8 block_type = 100 -u8 block_version = 0 -bytes[32] archive_tx_id -bytes[32] archive_hash -``` - -Итого 66 байт. - -`archive_hash` = `block_hash` последнего успешно опубликованного big block. - ---- - -# 51. Обновление Solana - -Отдельная новая Solana instruction не требуется. - -Используется существующий `update_user_pda`. - -Нужно расширить существующие serializer/parser/codec так, чтобы block type 100: - -- читался; -- записывался; -- сохранялся обычными обновлениями; -- мог быть изменён архивным сервером обычным update User PDA. - ---- - -# 52. Полный порядок публикации - -```text -1. Существующая server-to-server sync независимо обновляет локальные blockchains. - -2. Scheduler запускает archive job. - -3. Проверяется отсутствие другого активного archive job. - -4. Читаются archive_chain_cursor. - -5. Читаются текущие локальные blockchain heads. - -6. Для каждой blockchain вычисляется frozen delta. - -7. Создаётся archive_publish_job. - -8. Создаются archive_publish_job_chain rows. - -9. Загружается история всех успешно finalized previous big blocks. - -10. Формируется fixed header. - -11. Записывается creator_login. - -12. Записывается FULL PreviousBigBlockReferences. - -13. Для каждой blockchain_name: - - читается frozen range; - - создаётся ровно один UserBlockchainChunk; - - внутрь кладутся все новые records; - - добавляется один PreviousBlockchainChunkRef; - - сохраняются new_chunk_offset/new_chunk_size. - -14. Записывается closer_login. - -15. Вычисляется SHA-256 block_hash. - -16. Создаётся Ed25519 closer_signature. - -17. Файл закрывается. - -18. Файл загружается в Arweave. - -19. TX ID сохраняется в archive_publish_job. - -20. Сервер ждёт требуемое число Arweave confirmations. - -21. Обычным update_user_pda обновляется block type 100: - archive_tx_id - archive_hash - -22. Сервер ждёт Solana finalized. - -23. В одной DB transaction обновляются archive_chain_cursor. - -24. Job получает CURSORS_COMMITTED. -``` - ---- - -# 53. Cursor commit - -Для каждой blockchain из job: - -```text -last_archived_source_block_number = to_source_block_number -last_archived_source_block_hash = last_source_block_hash - -last_archive_big_block_number = current big_block_number -last_archive_big_block_hash = current block_hash - -last_chunk_offset = new_chunk_offset -last_chunk_size = new_chunk_size -``` - -Следующая публикация сразу знает, куда должен вести backlink. - ---- - -# 54. Arweave service - -Старый `TestFreeAvatarArweaveService` удаляется из активного протокола; archive publisher использует отдельный `ArweaveArchiveService`. - -Его следует переделать/переименовать, например в: - -```text -ArweaveArchiveService -``` - -Он должен: - -- использовать Arweave JWK; -- подписывать и оплачивать upload; -- поддерживать большие/chunked uploads; -- возвращать TX ID; -- ждать подтверждения; -- опрашивать confirmations. - -Рекомендуемые настройки: - -```properties -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 -``` - ---- - -# 55. Solana finalization - -После обычного `update_user_pda` сервер должен дождаться: - -```text -finalized -``` - -Только после этого разрешается сдвинуть локальные курсоры. - ---- - -# 56. Crash recovery - -## После `SNAPSHOT_CREATED` - -Возобновить тот же frozen job. - -## После `FILE_BUILT` - -Использовать тот же локальный файл. - -## После `ARWEAVE_UPLOADED` - -Не загружать второй раз. Продолжить по сохранённому TX ID. - -## После `ARWEAVE_CONFIRMED` - -Продолжить Solana update. - -## После `SOLANA_SUBMITTED` - -Сначала проверить transaction / текущий PDA. - -## После `SOLANA_FINALIZED`, но до cursor commit - -Проверить: - -```text -PDA.archive_tx_id == expected_tx -PDA.archive_hash == expected_hash -``` - -После этого безопасно выполнить cursor commit. - ---- - -# 57. Нет новых данных - -Если новых записей нет ни в одной blockchain: - -```text -не создавать пустой SHINE-ARCHIVE -не загружать Arweave -не обновлять Solana -``` - ---- - -# 58. FULL references как стартовая стратегия - -v1.0 сознательно хранит ВСЕ previous big blocks в каждом новом big block. - -Плюсы: - -- актуальный блок сразу даёт карту всей предыдущей истории; -- не нужно идти parent-by-parent; -- backlink может использовать компактный ref index; -- reader очень простой. - -При одном big block в сутки: - -```text -1 год: ~24.8 KiB -5 лет: ~121 KiB -20 лет: ~485 KiB -``` - ---- - -# 59. PARTIAL references - -`references_mode = 1` зарезервирован на будущее. - -v1.0 writer его не реализует. - -В будущем PARTIAL может означать: - -- только реально используемые previous blocks; -- последние N blocks; -- checkpoint references; -- смешанную стратегию. - ---- - -# 60. Чтение истории одной blockchain - -Имея актуальный chunk `alice-001`, клиент: - -1. читает все records chunk; -2. читает `PreviousBlockchainChunkRef`; -3. если ref = `0xFFFFFFFF`, история закончилась; -4. иначе берёт соответствующий `PreviousBigBlockReference`; -5. получает TX ID + hash previous big block; -6. Range-запросом читает `previous_chunk_offset/previous_chunk_size`; -7. получает сразу всю предыдущую пачку записей `alice-001`; -8. повторяет. - -Так можно читать историю конкретной blockchain, не скачивая чужие chunks. - ---- - -# 61. Дубликаты - -Если исходная SHiNE-запись уже есть у получателя, она пропускается/дедуплицируется стандартной логикой. - ---- - -# 62. Конфликты исходной blockchain - -Архивная ветка сама по себе не является пользовательским конфликтом. - -Конфликт возникает, когда одна `blockchain_name` имеет два несовместимых валидно подписанных продолжения одной предыдущей точки. - -Для v1 безопасно: - -```text -сохранить обе ветки -пометить blockchain CONFLICTED -остановить автоматическое продолжение -``` - -Механизм разрешения fork можно добавить позже. - ---- - -# 63. Авторство большого блока - -Автор указан дважды: - -## Header - -```text -creator_login -``` - -## Footer - -```text -closer_login -block_hash -closer_signature -``` - -Для v1.0: - -```text -creator_login == closer_login -``` - -Это означает: именно этот SHiNE-аккаунт сформировал и криптографически закрыл big block. - ---- - -# 64. Подпись сервера не заменяет подписи пользователей - -`closer_signature` доказывает авторство большого archive block. - -Каждая пользовательская raw record всё равно проверяется существующей криптографией SHiNE. - ---- - -# 65. Независимость от Arweave - -Arweave TX ID — location. - -SHA-256 — identity. - -Поэтому архивы можно перенести на другой storage и всё равно проверять. - ---- - -# 66. Block type 100 в Solana — текущая голова - -User PDA publisher-а хранит только: - -```text -current archive TX ID -current archive SHA-256 -``` - -Детальная история находится внутри big blocks. - ---- - -# 67. Startup preflight - -Перед запуском archive publisher сервер SHOULD проверить: - -```text -derivePublic(root_private) == UserPDA.root_public_key -``` - -и: - -```text -derivePublic(client_private) == UserPDA.client_key -``` - -При несовпадении archive publisher не запускается. - ---- - -# 68. Рекомендуемые настройки - -```properties -archive.publish.enabled=false -archive.publish.time=00:00 -archive.publish.zoneId= -archive.workDir=data/archive - -archive.maxFileBytes=4000000000 - -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 - -archive.solana.rootKeyPath=/opt/shine/secrets/root.key -archive.solana.clientKeyPath=/opt/shine/secrets/client.key -archive.solana.commitment=finalized - -# Отдельного archive.solana.rpcUrl нет. -# Используется solana.users.sync.rpcUrl, а если он пуст — обычный solana.rpcUrl. -``` - ---- - -# 69. Требование к `AppConfig` - -Archive settings должны читаться из: - -- Java system properties; -- environment variables; -- `application.properties`. - -`getInt()` и `getBoolean()` должны использовать общую логику `getParam()`. - ---- - -# 70. Что переиспользуется из текущего сервера - -Существующие механизмы: - -```text -BlockchainStateDAO.listAll() -BlocksDAO.listRangeByNumber(...) -``` - -дают необходимые heads и raw block ranges. - -Используются существующие: - -```text -block_bytes -block_hash -block_signature -blockchain_name -block_number -``` - -Server-to-server sync в v1.0 не меняется. - ---- - -# 71. Блокировки - -Можно использовать существующий blockchain lock только на коротком этапе: - -```text -проверить cursor hash -прочитать frozen range -проверить конечный hash -сохранить/сериализовать frozen data -``` - -Lock не удерживается во время Arweave/Solana ожиданий. - ---- - -# 72. Реализационный checklist - -## Database - -- [x] `archive_chain_cursor` -- [x] `archive_publish_job` -- [x] `archive_publish_job_chain` -- [x] schema migration -- [x] crash recovery - -## Archive writer - -- [x] magic `SHINE-ARCHIVE` -- [x] major/minor version -- [x] fixed header -- [x] creator login -- [x] FULL previous big block table -- [x] one chunk per `blockchain_name` -- [x] many raw records inside one chunk -- [x] one backlink per chunk -- [x] closer login -- [x] SHA-256 -- [x] Ed25519 signature -- [x] max file size < 4 GiB -- [x] local `.tmp.SHiNE-archive -> ..SHiNE-archive` lifecycle in `data/archive` - -## Arweave - -- [x] rename/refactor `TestFreeAvatarArweaveService` -- [x] remove avatar-specific logic -- [x] large/chunked upload -- [x] confirmation polling - -## Solana - -- [x] add PDA block type `100` -- [x] update Rust codec -- [x] update Java codec -- [x] update JS codec/UI writer -- [x] use ordinary `update_user_pda` -- [x] server-side transaction writer -- [x] root signature -- [x] client fee payer -- [x] wait for `finalized` - -## Scheduler - -- [x] `archive.publish.enabled` -- [x] `archive.publish.time` -- [x] `archive.publish.zoneId` -- [x] no concurrent jobs -- [ ] future: автоматическое split > max size (v1.0 сейчас безопасно останавливается без cursor commit) -- [x] skip when no new blocks - ---- - -# 73. Итоговая бинарная схема v1.0 - -```text -SHINE-ARCHIVE -version_major u8 -version_minor u8 - -header_size u32 -big_block_number u32 -created_at_ms u64 - -creator_login_length u8 - -references_mode u8 -references_count u32 -reference_entry_size u16 -parent_reference_index u32 - -blockchain_chunks_count u32 -total_user_records_count u32 - -creator_login bytes - - -PreviousBigBlockReference[references_count]: - - big_block_number u32 - big_block_hash [32] - arweave_tx_id [32] - - -UserBlockchainChunk[blockchain_chunks_count]: - - chunk_size u32 - - blockchain_name_length u8 - blockchain_name bytes - - records_count u32 - - repeat records_count: - record_size u32 - raw_record_bytes - - previous_big_block_ref u32 - previous_chunk_offset u32 - previous_chunk_size u32 - - -FOOTER: - - closer_login_length u8 - closer_login bytes - - block_hash [32] - closer_signature [64] -``` - ---- - -# 74. Основная архитектура одним рисунком - -```text -SOLANA USER PDA - | - | block type 100 - | TX + HASH - v -BigBlock #100 - | - +-- Header - | creator = server-A - | - +-- References - | ref[0] -> BigBlock #1 / hash / TX - | ref[1] -> BigBlock #2 / hash / TX - | ... - | ref[98] -> BigBlock #99 / hash / TX - | - +-- alice-001 chunk - | records x4 - | | - | +--> BigBlock #95 / AliceChunk - | - +-- bob-001 chunk - | records x2 - | | - | +--> BigBlock #98 / BobChunk - | - +-- kate-002 chunk - | records x10 - | | - | +--> BigBlock #74 / KateChunk - | - +-- Footer - closer = server-A - SHA-256 - Ed25519 signature -``` - ---- - -# 75. Итоговые решения v1.0 - -- один большой блок = одна архивная публикация; -- в начале лежит полный список всех предыдущих больших блоков; -- каждый previous block reference содержит `number + SHA-256 + Arweave TX ID`; -- одна `blockchain_name` встречается в большом блоке максимум одним chunk; -- все новые записи этой blockchain объединяются в этот chunk; -- chunk имеет одну ссылку на предыдущий chunk этой же blockchain; -- backlink состоит из `previous big block ref + chunk offset + chunk size`; -- сервер сам создаёт backlink; -- пользовательские raw records не меняются; -- login сервера есть в header и footer; -- большой блок закрывается SHA-256 + Ed25519 signature сервера; -- Solana User PDA block `100` хранит текущий TX ID + archive hash; -- PDA обновляется обычным `update_user_pda`; -- Arweave используется как долговременное хранилище; -- публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. - - ---- - -## Trusted archive reader / importer - -Бинарный формат v1.0 не меняется. Обычный сервер MAY использовать archive head из User PDA для восстановления недостающих SHiNE-блоков, но SHOULD принимать архивы только от явно разрешённых server logins. - -Рекомендуемая политика v1: - -```text -publisher login ∈ local whitelist -AND UserPDA.is_server = true -AND SHA256(file) == UserPDA.archive_head_hash / reference hash -AND creator_login == closer_login == publisher login -AND Ed25519 archive signature valid for publisher root key -AND every raw SHiNE block passes normal AddBlock validation -``` - -FULL reference table head-блока позволяет поздно подключившемуся серверу импортировать всю неизвестную историю publisher-а от старых big blocks к новым. - -Для быстрого пользовательского доступа сервер SHOULD поддерживать локальный индекс: - -```text -blockchain_name -> arweave_tx_id + chunk_offset + chunk_size -``` - -Он указывает на последний известный `UserBlockchainChunk`. Backlink внутри chunk делает эту одну ссылку достаточной для обхода всей архивной истории конкретной blockchain. diff --git a/docs/Archive/02_IMPLEMENTATION_MAP.md b/docs/Archive/02_IMPLEMENTATION_MAP.md deleted file mode 100644 index e671af29..00000000 --- a/docs/Archive/02_IMPLEMENTATION_MAP.md +++ /dev/null @@ -1,137 +0,0 @@ -# Карта реализации SHiNE Archive Publisher v1.0 - -Этот документ связывает протокол с конкретными файлами проекта. Он нужен, чтобы другой агент мог быстро понять, где искать каждую часть реализации. - -## 1. Новый Java-модуль `shine-server-archive` - -Путь: `SHiNE-server/shine-server-archive/`. - -Основные классы: - -- `ArchivePublisherScheduler` — включает publisher только при `archive.publish.enabled=true`, сразу продолжает незавершённый job после рестарта и планирует новый snapshot один раз в сутки в `archive.publish.time`. Следующая дата вычисляется по `ZoneId`, а не как `+24h`, поэтому DST не сдвигает локальную полночь. -- `ArchivePublisherService` — state machine: snapshot → локальный файл → Arweave → confirmations → User PDA → Solana finalized → cursor commit. -- `ArchivePublisherConfig` — читает настройки. Для Solana RPC отдельного archive URL нет: используется `solana.users.sync.rpcUrl`, затем fallback на `solana.rpcUrl`. -- `ShineArchiveWriter` — сериализует бинарный big block `SHINE-ARCHIVE v1.0`, FULL reference table, по одному chunk на `blockchain_name`, footer/hash/signature. -- `ArchiveFileNames` — временное и финальное локальные имена. -- `ArweaveArchiveService` + `ArweaveMerkle` — Arweave v2 transaction + chunk upload + polling confirmations. -- `SolanaArchiveHeadWriter` — обычный `update_user_pda`: root signature + текущая last-block signature + client key fee payer, затем ожидание `finalized`. -- `ArchiveKeyLoader` — читает Ed25519 seed/keypair из raw 32/64 bytes, Solana JSON 32/64 или Base64/PKCS8. - -## 2. Локальное состояние PostgreSQL - -Миграция: `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql`. - -Таблицы: - -### `archive_chain_cursor` -Одна строка на `blockchain_name`. Хранит только **последнее окончательно опубликованное** состояние: - -- последний source block number/hash; -- big block number/hash, где находится последний chunk; -- offset/size последнего chunk. - -Если blockchain не попала в новый big block, эта строка не меняется. - -### `archive_publish_job` -Crash-safe state одной большой публикации и путь к локальному файлу. - -Основные состояния: -`SNAPSHOT_CREATED → FILE_BUILT → ARWEAVE_UPLOADED → ARWEAVE_CONFIRMED → SOLANA_SUBMITTED → SOLANA_FINALIZED → CURSORS_COMMITTED`. - -### `archive_publish_job_chain` -Frozen range каждой blockchain текущего job + старые и новые координаты chunk. Пока job не finalized, `archive_chain_cursor` не двигается. - -`DatabaseInitializer` автоматически применяет migration v22 при старте существующей БД. Новая БД создаётся уже со схемой v22. - -## 3. Как считается первая дельта - -`ArchivePublisherService.createFrozenJob()` проходит по `BlockchainStateDAO.listAll()`. - -- Если cursor для `blockchain_name` отсутствует: `from = 0`, поэтому первый архив содержит всё локально известное состояние `0..head`. -- Если cursor существует: `from = last_archived + 1`. -- Перед продолжением проверяется hash cursor-блока. - -Папка `data/archive` сама по себе **не является источником истины** о том, был ли первый архив. Источник истины — БД cursor/job. Поэтому удаление локального файла не приводит к ошибочной повторной полной публикации. - -## 4. Локальный lifecycle файла - -До появления Arweave TX ID: - -`.<00001>..tmp.SHiNE-archive` - -После полной успешной загрузки transaction header + chunks в Arweave: - -`.<00001>...SHiNE-archive` - -Дата — реальная дата freeze snapshot в timezone archive publisher-а. Номер начинается с `00001`. Пять цифр — минимальная ширина, а не лимит. - -После rename файл остаётся локально. При crash после сохранения TX ID, но до rename, recovery переименует тот же файл и не загрузит его повторно. - -## 5. User PDA block type `100` - -Содержимое: - -```text -u8 block_type = 100 -u8 block_version = 0 -bytes[32] archive_tx_id -bytes[32] archive_hash -``` - -Используется существующий `update_user_pda`; отдельной instruction нет. - -Совместимость: -- legacy update без archive extension должен сохранить старый archive head; -- новый update может заменить/очистить block `100`; -- Java/JS codecs и PostgreSQL Solana sync умеют читать новый блок. - -Ключевой Rust-файл: `shine-solana/shine/programs/shine_users/src/lib.rs`. - -## 6. Startup сервера - -`WsServer` после текущего Solana/users sync и inter-server blockchain sync вызывает `ArchivePublisherScheduler.startOrLog()`. - -При `archive.publish.enabled=false` scheduler пишет лог о выключенной функции и больше ничего не делает. Ключи/Arweave wallet на обычном сервере тогда не требуются. - -## 7. Legacy TestFreeAvatar - -Старый временный `TestFreeAvatarArweaveService` больше не является частью активного WS-протокола. Registry/API документация убраны. При наложении changed-files ZIP поверх старого дерева старые исходники физически останутся, поэтому их список для удаления находится в `05_PATCH_CONTENTS_AND_REMOVALS.md`. - - -## Trusted importer / location index / Viewer - -Server importer: - -```text -shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java -shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java -shine-server-archive/src/main/java/server/archive/ArchiveImportService.java -shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java -``` - -Database: - -```text -shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java -shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java -shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java -shine-server-db/src/main/resources/postgres/migration_v23.sql -shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java -``` - -`PostgresStorageRepository` сохраняет `archive_imported=true` при повторном sync того же head и автоматически сбрасывает флаг в `false`, если `archive_head_tx_id` или `archive_head_hash` изменились. - -WS API: - -```text -GetArchiveBlockchainLocation -``` - -UI: - -```text -shine-UI/js/pages/blockchain-archive-view.js -shine-UI/Blockchain-Viewer.html -``` - -`Blockchain-Viewer.html` получает `tx + offset + size + blockchain`, идёт назад по `PreviousBlockchainChunkRef` и использует существующий parser каналов старого Viewer-а. diff --git a/docs/Archive/03_DEPLOY_TEST_SERVER.md b/docs/Archive/03_DEPLOY_TEST_SERVER.md deleted file mode 100644 index 6b6248f8..00000000 --- a/docs/Archive/03_DEPLOY_TEST_SERVER.md +++ /dev/null @@ -1,466 +0,0 @@ -# Деплой SHiNE Archive Publisher v1.0 на тестовый сервер - -Документ рассчитан на человека или автономного coding/deploy агента. Выполнять шаги по порядку. Не включать publisher до проверки Solana-программы и ключей. - -## 0. Что именно меняется - -Нужны изменения одновременно в: - -1. серверном Java-коде; -2. PostgreSQL schema v23; -3. Solana-программе `shine_users` (PDA block type `100` + backward-compatible update parser); -4. Java/JS User PDA codecs. - -**Критично:** новый серверный writer отправляет расширенный обычный `update_user_pda`. Если в целевом кластере работает старая `shine_users`, включать publisher нельзя. - ---- - -# 1. Применить пакет к исходникам - -ZIP из этой поставки содержит только новые/изменённые файлы с путями относительно корня репозитория. - -Сделать backup текущего проекта, затем распаковать ZIP поверх рабочего дерева. - -После распаковки удалить legacy-файлы из списка `05_PATCH_CONTENTS_AND_REMOVALS.md`. - -Проверить: - -```bash -git status --short -``` - -или, если это не git checkout, сравнить список файлов с manifest из той же документации. - ---- - -# 2. Обязательно обновить `shine_users` в нужном Solana-кластере - -## 2.1. Проверить целевой кластер - -Не деплоить вслепую. Сначала: - -```bash -solana config get -``` - -и проверить RPC/кластер, upgrade authority и Program ID. В проекте `shine_users` использует Program ID: - -```text -SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 -``` - -Если тестовый сервер использует mainnet RPC, обновляется именно mainnet-программа. Если тестовый контур использует devnet — сначала убедиться, что программа с нужным ID действительно существует в devnet. - -## 2.2. Собрать Solana program - -```bash -cd shine-solana/shine -anchor build -``` - -Минимальная host-проверка Rust, если Anchor/SBF toolchain временно недоступен: - -```bash -cargo build -p shine_users -``` - -Но для реального deploy нужен SBF/Anchor build. - -## 2.3. Обновить существующую программу - -Использовать существующий project deploy/upgrade authority. Типовой вариант: - -```bash -solana program deploy target/deploy/shine_users.so \ - --program-id target/deploy/shine_users-keypair.json \ - --upgrade-authority /PATH/TO/UPGRADE_AUTHORITY.json \ - --url -``` - -Если в проекте используется рабочий Anchor deploy workflow, допустимо использовать его вместо прямого `solana program deploy`; главное — сохранить тот же Program ID. - -После обновления: - -```bash -solana program show SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 --url -``` - ---- - -# 3. Собрать серверный JAR - -Из корня репозитория: - -```bash -./gradlew clean shadowJar -``` - -Ожидаемый файл: - -```text -SHiNE-server/build/libs/shine-server.jar -``` - -Если Gradle wrapper не может скачать зависимости, сборку выполнять на машине/CI с доступом к Maven/Gradle или с уже заполненным cache. - ---- - -# 4. Подготовить PostgreSQL backup - -Перед первым стартом версии со schema v23 сделать backup тестовой БД. Например: - -```bash -pg_dump -Fc -d '' -f shine-before-archive-v22.dump -``` - -Точная команда зависит от текущей схемы доступа PostgreSQL. - -При старте сервер последовательно применит `migration_v22.sql` и `migration_v23.sql`, если это требуется текущей версии БД. Вручную migrations выполнять обычно не нужно. - -После старта проверить: - -```sql -SELECT * FROM db_schema_version WHERE id=1; -``` - -Ожидается: - -```text -schema_version = 23 -``` - -И наличие: - -```sql -SELECT to_regclass('public.archive_chain_cursor'); -SELECT to_regclass('public.archive_publish_job'); -SELECT to_regclass('public.archive_publish_job_chain'); -``` - ---- - -# 5. Подготовить секреты на тестовом сервере - -Пример: - -```bash -sudo -u player mkdir -p /home/player/SHiNE/secrets -sudo chmod 700 /home/player/SHiNE/secrets -``` - -Положить: - -```text -/home/player/SHiNE/secrets/archive-arweave-wallet.json -/home/player/SHiNE/secrets/server-root.key -/home/player/SHiNE/secrets/server-client.key -``` - -Права: - -```bash -sudo chown player:player /home/player/SHiNE/secrets/* -sudo chmod 600 /home/player/SHiNE/secrets/* -``` - -### Форматы root/client key - -Поддерживаются: -- raw seed 32 bytes; -- raw keypair 64 bytes; -- Solana JSON array на 32/64 байта; -- Base58 seed на 32 байта или Solana secret key на 64 байта; -- Base64 raw/PKCS8, где seed извлекается из последних 32 bytes. - -### Arweave wallet - -Ожидается RSA JWK с полями `n,e,d,p,q,dp,dq,qi`. Кошелёк должен иметь достаточно AR для размера первого полного архива. - ---- - -# 6. Настроить внешний `application.properties` - -Сервер читает внешний `application.properties` из **WorkingDirectory процесса** и накладывает его поверх встроенного конфига. Сохранять существующие DB/Solana/server параметры и добавить archive-секцию. - -Минимум: - -```properties -archive.publish.enabled=true -archive.publish.time=00:00 -archive.publish.zoneId=Europe/Warsaw -archive.workDir=data/archive -archive.maxFileBytes=4000000000 - -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/home/player/SHiNE/secrets/archive-arweave-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 - -archive.solana.rootKeyPath=/home/player/SHiNE/secrets/server-root.key -archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key -archive.solana.confirmPollSeconds=5 -archive.solana.confirmTimeoutMinutes=30 -archive.solana.commitment=finalized -``` - -`archive.publish.zoneId` выбрать осознанно. Если оставить пустым, используется timezone JVM/машины. Для ежедневного запуска ровно в нужную локальную полночь лучше задать ZoneId явно. - -### Solana RPC - -**Отдельного archive RPC нет.** Writer использует: - -1. `solana.users.sync.rpcUrl`, если он задан; -2. иначе `solana.rpcUrl`. - -Поэтому существующий рабочий RPC не дублировать в archive settings. - ---- - -# 7. Убедиться, что server login соответствует ключам - -`server.SHiNE.login` должен быть тем User PDA, чей archive head будет обновляться. - -На startup `SolanaArchiveHeadWriter` проверяет: - -```text -derive(root private) == UserPDA.root_key -derive(client private) == UserPDA.client_key -``` - -При несовпадении archive publisher не стартует. - ---- - -# 8. Развернуть JAR - -Можно использовать существующий `deploy/scripts/deploy_server.sh`. Он: - -- собирает `shadowJar`; -- копирует JAR; -- создаёт/обновляет systemd unit; -- перезапускает сервис. - -Типовой запуск задаётся уже существующими переменными проекта. Либо вручную заменить `shine-server.jar` в рабочей директории и перезапустить systemd service. - -После deploy убедиться, что WorkingDirectory содержит внешний `application.properties`. - ---- - -# 9. Первый startup - -Смотреть лог: - -```bash -sudo journalctl -u -f -``` - -Ожидаемые события: - -1. DB migration до v22; -2. обычный Solana users sync; -3. обычный server-to-server blockchain sync; -4. archive publisher preflight; -5. строка примерно: - -```text -Archive publisher включён: login=... dir=data/archive dailyAt=00:00 zone=... -Следующая архивная публикация запланирована на ... -``` - -Если остался незавершённый job, он будет продолжен **сразу после старта**, не ожидая полуночи. Новый snapshot создаётся только по расписанию. - ---- - -# 10. Что произойдёт в первую полночь - -Если `archive_chain_cursor` пуст: - -- сервер проходит все локально известные `blockchain_name`; -- для каждой берёт range `0..current_head`; -- создаёт первый big block `#1`; -- для каждой blockchain создаёт максимум один `UserBlockchainChunk`; -- внутри chunk лежат все её raw records из frozen range; -- backlink первого chunk пустой (`previous_big_block_ref = 0xFFFFFFFF`); -- создаёт локальный файл, например: - -```text -data/archive/archive01.00001.12.09.26.tmp.SHiNE-archive -``` - -- загружает его в Arweave; -- после успешной загрузки переименовывает тот же файл, например: - -```text -data/archive/archive01.00001.12.09.26..SHiNE-archive -``` - -- ждёт confirmations; -- обычным `update_user_pda` записывает block type `100`; -- ждёт Solana `finalized`; -- только затем commit-ит cursors. - -Если новых данных нет, пустой big block не создаётся. - ---- - -# 11. Проверка результата - -## Локальные файлы - -```bash -ls -lah data/archive/ -``` - -После успешного upload `.tmp.SHiNE-archive` для завершённого job оставаться не должен; должен быть файл с реальным TX ID в имени. - -## Job DB - -```sql -SELECT id, big_block_number, status, created_at_ms, local_archive_path, - arweave_confirmations, solana_signature, error_text -FROM archive_publish_job -ORDER BY id DESC -LIMIT 10; -``` - -Успех: - -```text -status = CURSORS_COMMITTED -``` - -## Cursors - -```sql -SELECT blockchain_name, - last_archived_source_block_number, - last_archive_big_block_number, - last_chunk_offset, - last_chunk_size -FROM archive_chain_cursor -ORDER BY blockchain_name; -``` - -## Локальная проекция User PDA - -После очередной Solana sync: - -```sql -SELECT login, record_number, archive_head_tx_id, archive_head_hash -FROM solana_user_pda_current -WHERE login = ''; -``` - -`archive_head_tx_id` и `archive_head_hash` должны быть непустыми. - -## Arweave - -TX берётся прямо из имени финального файла. Проверить: - -```bash -curl -sS 'https://arweave.net/tx//status' -``` - ---- - -# 12. Быстрый тест до полуночи - -Если не хочется ждать 00:00, на тестовом сервере временно установить `archive.publish.time` на ближайшие 5–10 минут в будущем в выбранной `archive.publish.zoneId`, затем перезапустить сервис. - -После проверки вернуть: - -```properties -archive.publish.time=00:00 -``` - -Не использовать интервал в минутах: scheduler специально работает по календарному локальному времени один раз в сутки. - ---- - -# 13. Откат / выключение - -Самый безопасный функциональный rollback: - -```properties -archive.publish.enabled=false -``` - -и рестарт сервера. Тогда обычная серверная работа продолжается, archive scheduler ничего не публикует. - -Не удалять `archive_chain_cursor`/job таблицы без причины: они нужны, чтобы после повторного включения publisher продолжил дельту, а не загрузил всю историю заново. - -Уже опубликованные Arweave данные являются постоянными и обычным rollback сервера не удаляются. - ---- - -# 14. Наиболее вероятные ошибки - -### `Root key archive publisher-а не совпадает с User PDA` -Положен неправильный root key или неверный `server.SHiNE.login`. - -### `Client key archive publisher-а не совпадает с User PDA` -Неверный client key. - -### `Недостаточно AR` -Пополнить Arweave wallet. Первый архив может быть существенно больше ежедневных дельт. - -### Arweave upload прошёл, Solana update не прошёл -Не удалять локальный файл/job. После исправления RPC/program/key причины restart продолжит незавершённый job. - -### `archive cursor hash не совпадает` -Локальная blockchain изменилась относительно уже зафиксированного cursor. Не форсировать публикацию; сначала разобраться с resync/fork. - -### Старый `shine_users` -Если новый update payload отклоняется программой, проверить, что целевая Solana `shine_users` действительно обновлена этой версией. - - -## Trusted archive importer - -На обычном тестовом сервере publisher можно оставить выключенным, но разрешить импорт от конкретного архиватора: - -```properties -archive.publish.enabled=false -archive.import.allowedPublishers= -archive.import.intervalMinutes=60 -archive.import.workDir=data/archive-import -``` - -Несколько логинов: - -```properties -archive.import.allowedPublishers=server-a,server-b -``` - -Пустая строка означает, что importer не запускается. - -После старта проверить логи: - -```text -Archive importer включён. approvedPublishers=... -``` - -Если сервер подключается к publisher впервые, importer скачает head, прочитает FULL reference table и обработает все ещё не известные big blocks от старых к новым. - -Проверка БД: - -```sql -SELECT login, archive_head_tx_id, archive_imported, archive_last_imported_tx_id -FROM solana_user_pda_current -WHERE is_server=TRUE AND archive_head_tx_id<>'' -ORDER BY login; - -SELECT blockchain_name, publisher_login, arweave_tx_id, - big_block_number, chunk_offset, chunk_size, source_last_block_number -FROM archive_blockchain_location -ORDER BY updated_at_ms DESC -LIMIT 20; -``` - -После деплоя UI файл должен быть доступен по: - -```text -https:///Blockchain-Viewer.html -``` - -В приложении: `Настройки → Архив блокчейна`. diff --git a/docs/Archive/04_TEST_AND_OPERATIONS.md b/docs/Archive/04_TEST_AND_OPERATIONS.md deleted file mode 100644 index da32de67..00000000 --- a/docs/Archive/04_TEST_AND_OPERATIONS.md +++ /dev/null @@ -1,113 +0,0 @@ -# Проверка и эксплуатация Archive Publisher - -## Перед ночным тестом - -- [ ] Новый `shine_users` уже развёрнут на том Solana-кластере, который использует сервер. -- [ ] Серверный JAR собран из этого пакета. -- [ ] БД забэкаплена. -- [ ] `archive.publish.enabled=true`. -- [ ] `archive.publish.time=00:00`. -- [ ] `archive.publish.zoneId` соответствует желаемой локальной полуночи. -- [ ] `server.SHiNE.login` соответствует root/client keys. -- [ ] Arweave JWK читается пользователем процесса. -- [ ] На Arweave wallet достаточно AR. -- [ ] `data/archive` доступна на запись. -- [ ] В логе есть `Archive publisher включён` и точное время следующего запуска. - -## Во время job - -Нормальная последовательность логов/статусов: - -```text -SNAPSHOT_CREATED -FILE_BUILT -ARWEAVE_UPLOADED -ARWEAVE_CONFIRMED -SOLANA_SUBMITTED -SOLANA_FINALIZED -CURSORS_COMMITTED -``` - -После `FILE_BUILT` существует `.tmp.SHiNE-archive`. -После полного Arweave upload имя уже содержит настоящий TX ID. - -## После успешного первого job - -Проверить: - -```bash -find data/archive -maxdepth 1 -type f -name '*.SHiNE-archive' -ls -``` - -```sql -SELECT big_block_number, status, local_archive_path, arweave_confirmations -FROM archive_publish_job ORDER BY id DESC LIMIT 1; -``` - -```sql -SELECT count(*) AS archived_blockchains FROM archive_chain_cursor; -``` - -```sql -SELECT login, archive_head_tx_id, archive_head_hash -FROM solana_user_pda_current -WHERE login=''; -``` - -## Проверка второй публикации - -До следующей полуночи добавить несколько новых SHiNE records только в часть blockchain. После следующего job: - -- в новый big block должны попасть только изменившиеся blockchain; -- одна blockchain в новом big block должна иметь один chunk независимо от числа новых records; -- cursor blockchain, которая не изменилась, должен остаться на старом big block/chunk; -- backlink изменившегося chunk должен указывать на предыдущий chunk этой же blockchain; -- FULL reference table нового big block должна содержать все предыдущие finalized big blocks. - -## Crash/restart сценарии - -### Restart после FILE_BUILT -Должен использоваться тот же frozen job и тот же локальный файл. - -### Restart после ARWEAVE_UPLOADED -Не должно быть повторной оплаты/upload. Если TX сохранён, но rename не успел произойти, recovery переименует `.tmp` в имя с TX ID. - -### Restart после SOLANA_FINALIZED -При совпадении PDA head с job сервер должен только commit cursors. - -## Обычный сервер без публикации - -Проверить отдельно: - -```properties -archive.publish.enabled=false -``` - -Сервер должен запускаться без Arweave/root/client archive key files и не создавать `archive_publish_job`. - - -## Проверка trusted importer - -1. На принимающем сервере указать только тестовый publisher: - -```properties -archive.import.allowedPublishers= -``` - -2. Перезапустить сервер. -3. Дождаться Solana PDA sync и цикла importer-а. -4. Проверить, что у publisher в `solana_user_pda_current` после успешного цикла `archive_imported=true`, а `archive_last_imported_tx_id=archive_head_tx_id`. -5. Проверить `archive_blockchain_location`. -6. Для blockchain, которой локально не хватало блоков, убедиться, что `blockchain_state.last_block_number` вырос. -7. Для уже существующих блоков importer должен пропускать совпадающий hash, а не создавать дубликат. -8. Временно удалить publisher из whitelist и убедиться, что новые archive heads больше не скачиваются. - -### Проверка Viewer - -Открыть `Настройки → Архив блокчейна`, получить ссылку и проверить: - -- `tx`, `offset`, `size`, `blockchain` присутствуют; -- Viewer собирает несколько chunks по backlink; -- неправильный `blockchain` в URL приводит к ошибке проверки; -- `channel` открывает нужный канал; -- `message` прокручивает к нужному block number. diff --git a/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md b/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md deleted file mode 100644 index fd2c6d5c..00000000 --- a/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md +++ /dev/null @@ -1,38 +0,0 @@ -# Состав текущего patch-пакета - -Этот этап рассчитан **поверх последнего рабочего ZIP**, присланного после успешного запуска archive publisher. - -Пакет этого этапа добавляет: - -- trusted archive importer; -- whitelist publisher-ов через настройки сервера; -- строгую проверку больших `SHINE-ARCHIVE`; -- импорт недостающих raw SHiNE blocks через обычный validator `AddBlock`; -- локальные поля состояния импорта в `solana_user_pda_current` и таблицу `archive_blockchain_location`; -- schema migration v23; -- WS API `GetArchiveBlockchainLocation`; -- экран `Настройки → Архив блокчейна`; -- `shine-UI/Blockchain-Viewer.html`; -- документацию importer/viewer. - -## Удаления - -В **этом** обновлении удалять файлы не требуется. - -Старые test/free-avatar исходники, если они всё ещё физически присутствуют в рабочем дереве, этим patch-пакетом не затрагиваются. Они не относятся к trusted archive importer и не должны удаляться автоматически при наложении этого обновления. - -## Как накладывать ZIP changed-files - -ZIP содержит только новые/изменённые файлы с путями от корня репозитория. - -Распаковать поверх той рабочей версии, из которой сделан пакет, с заменой совпадающих файлов. - -После наложения: - -```bash -./gradlew shadowJar -``` - -Если Gradle wrapper ещё не установлен локально, сначала обеспечить доступ к уже используемой версии Gradle/кэшу. - -При старте сервер сам должен поднять schema с v22 до v23. diff --git a/docs/Archive/06_FILE_MANIFEST.md b/docs/Archive/06_FILE_MANIFEST.md deleted file mode 100644 index 12662e6f..00000000 --- a/docs/Archive/06_FILE_MANIFEST.md +++ /dev/null @@ -1,50 +0,0 @@ -# Manifest changed/new files - -Основа сравнения: последний присланный рабочий ZIP `3d14e34c-4249-4e11-8042-3f3349c8e9fd.zip`. - -- Изменённых файлов: 19 -- Новых файлов: 14 -- Удаляемых файлов: 0 - -## Изменённые файлы - -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java` -- `SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java` -- `SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java` -- `SHiNE-server/src/main/java/server/ws/WsServer.java` -- `SHiNE-server/src/main/resources/application.properties` -- `docs/Archive/01_PROTOCOL_v1.0.md` -- `docs/Archive/02_IMPLEMENTATION_MAP.md` -- `docs/Archive/03_DEPLOY_TEST_SERVER.md` -- `docs/Archive/04_TEST_AND_OPERATIONS.md` -- `docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md` -- `docs/Archive/06_FILE_MANIFEST.md` -- `docs/Archive/README.md` -- `docs/Archive/archive-publisher.example.properties` -- `shine-UI/js/app.js` -- `shine-UI/js/pages/settings-view.js` -- `shine-UI/js/services/auth-service.js` - -## Новые файлы - -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java` -- `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java` -- `docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md` -- `shine-UI/Blockchain-Viewer.html` -- `shine-UI/js/pages/blockchain-archive-view.js` - -## Удаления - -На этом этапе файлов для удаления нет. diff --git a/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md b/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md deleted file mode 100644 index 51275c52..00000000 --- a/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md +++ /dev/null @@ -1,303 +0,0 @@ -# Импорт доверенных SHINE-ARCHIVE и Blockchain Viewer - -Этот документ описывает вторую половину архивной системы: как обычный SHiNE-сервер узнаёт о новых archive head других серверов, кому доверяет, как импортирует недостающие SHiNE-блоки и как UI получает ссылку на историю конкретной `blockchain_name`. - -## 1. Источник archive head - -Archive importer **не делает отдельные Solana RPC-запросы**. - -Уже существующий Solana Users Sync разбирает User PDA block type `100` и копирует его в локальную таблицу: - -```text -solana_user_pda_current.archive_head_tx_id -solana_user_pda_current.archive_head_hash -``` - -Для локального состояния importer v23 добавляет туда же: - -```text -archive_imported BOOLEAN -archive_last_imported_tx_id TEXT -``` - -Это локальные поля сервера, в Solana они не записываются. - -Когда обычный Solana Users Sync видит тот же archive head повторно, `archive_imported` сохраняется как есть. - -Когда `archive_head_tx_id` или `archive_head_hash` изменился: - -```text -archive_imported = false -``` - -а `archive_last_imported_tx_id` сохраняет последнюю успешно обработанную точку и позволяет продолжить после сбоя. - -## 2. Whitelist доверенных publisher-ов - -В `application.properties` задаётся список логинов серверов, архивы которых разрешено принимать: - -```properties -archive.import.allowedPublishers=archive-server-1,archive-server-2 -archive.import.intervalMinutes=60 -archive.import.workDir=data/archive-import -``` - -Правила: - -- логины разделяются запятыми; -- сравнение без учёта регистра; -- пустое `archive.import.allowedPublishers=` полностью выключает importer; -- принимаются только строки `solana_user_pda_current` с `is_server=true`; -- whitelist является только первым фильтром, криптографические проверки всё равно обязательны. - -## 3. Периодическая проверка - -После запуска сервера importer делает первую проверку примерно через 10 секунд, затем по умолчанию раз в 60 минут. - -Каждый цикл — дешёвый запрос только к локальной PostgreSQL: - -```text -approved publisher -AND is_server=true -AND archive_head_tx_id != '' -AND archive_imported=false -``` - -Если таких строк нет, Arweave не вызывается. - -## 4. Проверки archive block - -Для каждого pending publisher сервер проверяет: - -1. publisher находится в whitelist; -2. `archive_head_tx_id/archive_head_hash` уже пришли через обычный User PDA sync; -3. SHA-256 скачанного файла совпадает с `archive_head_hash`; -4. `creator_login == closer_login == publisher login`; -5. Ed25519 archive signature проверяется root key publisher-а из User PDA; -6. каждый вложенный raw SHiNE block проходит обычную SHiNE-проверку через существующий `AddBlock` path. - -Подпись большого архива защищает контейнер и навигацию, а подписи обычных SHiNE blocks защищают сами пользовательские данные. - -## 5. Как определяется, что head новый - -Отдельная таблица обработанных TX для основной логики не нужна. - -Текущий User PDA snapshot уже содержит: - -```text -archive_head_tx_id -archive_head_hash -archive_imported -archive_last_imported_tx_id -``` - -Пример: - -```text -archive_head_tx_id = TX100 -archive_imported = false -archive_last_imported_tx_id = TX97 -``` - -Это означает: Solana уже объявила `TX100` текущей головой publisher-а, но локальный сервер успел импортировать только до `TX97`. - -После полной успешной обработки `TX100`: - -```text -archive_imported = true -archive_last_imported_tx_id = TX100 -``` - -При следующем новом PDA head Users Sync сам сбросит `archive_imported=false`. - -## 6. Догон пропущенных больших блоков - -Если сервер был выключен и вместо `TX97` сразу увидел `TX100`, он скачивает и проверяет `TX100`, читает его FULL reference table и находит `TX97`. - -После этого импортирует только: - -```text -TX98 -TX99 -TX100 -``` - -После каждого полностью импортированного большого блока `archive_last_imported_tx_id` сдвигается вперёд. - -Если сервер впервые видит publisher и `archive_last_imported_tx_id` пустой, импортируются все previous refs от старых к новым, затем текущий head. - -Если непустой `archive_last_imported_tx_id` отсутствует в FULL history текущего head, importer останавливается: это рассматривается как возможная смена/fork archive chain, а не как повод молча забыть старый cursor. - -## 7. Crash recovery - -Если процесс упал после `TX98`, но до `TX100`: - -```text -archive_imported = false -archive_last_imported_tx_id = TX98 -``` - -Следующий часовой цикл продолжит с `TX99`. - -Если процесс успел импортировать head и записать `archive_last_imported_tx_id = TX100`, но упал до установки `archive_imported=true`, следующий цикл просто завершит отметку без повторной загрузки всей цепочки. - -Все cursor updates выполняются условно по ожидаемому `archive_head_tx_id`. Если обычный Solana Users Sync успел заменить head во время импорта, старый процесс не сможет пометить новый head импортированным. - -## 8. Импорт `UserBlockchainChunk` - -Для каждого chunk: - -1. берётся lock этой `blockchain_name`; -2. если локального `blockchain_state` нет, identity создаётся по синхронизированному User PDA; -3. raw records разбираются как обычные `BchBlockEntry`; -4. уже существующий block допускается только при совпадении hash; -5. новый block должен идти строго `localLast + 1`; -6. новый block добавляется существующим validator/write path; -7. конфликт hash или gap останавливает импорт этой archive chain. - -## 9. Индекс последнего archive chunk - -Таблица: - -```text -archive_blockchain_location -``` - -содержит для каждой `blockchain_name`: - -```text -blockchain_name -publisher_login -arweave_tx_id -archive_hash -big_block_number -chunk_offset -chunk_size -source_last_block_number -updated_at_ms -``` - -Если blockchain встретилась в новом archive block, её location обновляется. Если не встретилась — старая ссылка остаётся. - -Эту таблицу заполняют как trusted importer, так и локальный archive publisher. - -## 10. API для UI - -WS operation: - -```text -GetArchiveBlockchainLocation -``` - -Request: - -```json -{ - "op": "GetArchiveBlockchainLocation", - "blockchainName": "alice-001" -} -``` - -Response содержит: - -```text -blockchainName -publisherLogin -arweaveTxId -archiveHash -bigBlockNumber -chunkOffset -chunkSize -sourceLastBlockNumber -``` - -## 11. UI и ссылка Viewer - -В настройках пользователя есть экран `Архив блокчейна`. - -Viewer-файл: - -```text -shine-UI/Blockchain-Viewer.html -``` - -Основные параметры ссылки: - -```text -/Blockchain-Viewer.html - ?tx= - &offset= - &size= - &blockchain= -``` - -Дополнительно: - -```text -&channel= -&message= -``` - -`blockchain` используется также для проверки: если загруженный chunk имеет другое имя blockchain, Viewer прекращает обработку. - -`channel` открывает нужный канал, а `message` прокручивает к указанному сообщению/block number и выделяет его. - -## 12. Как Viewer собирает всю цепочку - -Viewer начинает с последнего `TX + offset + size`: - -```text -последний UserBlockchainChunk - ↓ -PreviousBlockchainChunkRef - ↓ -FULL reference table текущего big block - ↓ -TX предыдущего big block - ↓ -Range предыдущего chunk - ↓ -следующий backlink - ↓ -до NO_REFERENCE -``` - -Чужие chunks скачивать не требуется. - -## 13. Минимальная настройка принимающего сервера - -```properties -archive.publish.enabled=false -archive.import.allowedPublishers=server-a,server-b -archive.import.intervalMinutes=60 -archive.import.workDir=data/archive-import -``` - -Если импорт архивов не нужен: - -```properties -archive.import.allowedPublishers= -``` - -Тогда importer вообще не запускается. - -## 14. Диагностика PostgreSQL - -Pending archive heads: - -```sql -SELECT login, archive_head_tx_id, archive_imported, archive_last_imported_tx_id -FROM solana_user_pda_current -WHERE is_server = TRUE - AND archive_head_tx_id <> '' -ORDER BY login; -``` - -Последние известные пользовательские chunks: - -```sql -SELECT blockchain_name, publisher_login, arweave_tx_id, - big_block_number, chunk_offset, chunk_size, source_last_block_number -FROM archive_blockchain_location -ORDER BY updated_at_ms DESC; -``` diff --git a/docs/Archive/README.md b/docs/Archive/README.md deleted file mode 100644 index edf5b61d..00000000 --- a/docs/Archive/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# SHiNE Archive Publisher — документация - -Эта папка — **актуальная точка входа** для механизма серверной архивации SHiNE в Arweave с фиксацией archive head в Solana User PDA. - -Если задачу выполняет другая нейронка/агент, читать документы нужно в таком порядке: - -1. `01_PROTOCOL_v1.0.md` — бинарный формат `SHINE-ARCHIVE`, big-block references, `UserBlockchainChunk`, подписи, PDA block `100`, crash-safety. -2. `02_IMPLEMENTATION_MAP.md` — как спецификация разложена по Java/Rust/JS/SQL файлам текущего проекта. -3. `03_DEPLOY_TEST_SERVER.md` — полный порядок установки на тестовый сервер, включая обязательный апгрейд `shine_users`, конфиг, ключи, сборку и запуск. -4. `04_TEST_AND_OPERATIONS.md` — что проверять до полуночи, после полуночи и при сбоях. -5. `05_PATCH_CONTENTS_AND_REMOVALS.md` — какие файлы содержит пакет и какие legacy test-free-avatar файлы нужно удалить при наложении ZIP поверх старого исходника. -6. `07_ARCHIVE_IMPORT_AND_VIEWER.md` — whitelist доверенных publisher-ов, импорт archive chain, индекс последнего chunk, UI и `Blockchain-Viewer.html`. -7. `archive-publisher.example.properties` — пример конфигурации publisher + importer. - -## Коротко - -- Архиватор **по умолчанию выключен**: `archive.publish.enabled=false`. -- При включении создаёт новый snapshot **один раз в сутки в заданное локальное время**, по умолчанию `00:00`. -- При первом успешном запуске, когда архивных курсоров ещё нет, в первый big block попадает **всё локально известное состояние всех blockchain, начиная с source block 0**. -- Далее публикуется только дельта. -- Один `blockchain_name` в одном big block представлен максимум одним `UserBlockchainChunk`; внутри него лежат все новые raw SHiNE records этой цепочки. -- В конце chunk одна ссылка на предыдущий chunk этой же blockchain. Если blockchain в текущем big block отсутствует, её cursor/head не меняется. -- Готовый файл сначала существует локально как `.<00001>..tmp.SHiNE-archive`. После успешной загрузки в Arweave он переименовывается в `.<00001>...SHiNE-archive` и остаётся локально. -- После Arweave confirmations обычным `update_user_pda` обновляется PDA block type `100`: `archive_tx_id[32] + archive_hash[32]`. -- Cursor commit выполняется только после Solana `finalized`. - -## Важно перед тестом - -Изменён формат/парсер `shine_users`. **Нельзя просто заменить серверный JAR и включить archive publisher, если целевая Solana-программа `shine_users` ещё не обновлена кодом из этого пакета.** Сначала обновить программу на нужном кластере, затем сервер. - - -## Импорт архивов других серверов - -Импорт по умолчанию также выключен. Настройка: - -```properties -archive.import.allowedPublishers= -``` - -Пустой список означает: не доверять архивам ни одного внешнего сервера. Для разрешения перечислить логины через запятую. Подробности — `07_ARCHIVE_IMPORT_AND_VIEWER.md`. - -Текущая схема БД: **v23**. `v22` добавила publisher, `v23` добавляет локальные `archive_imported/archive_last_imported_tx_id`, trusted importer и универсальный `archive_blockchain_location`. diff --git a/docs/Archive/archive-publisher.example.properties b/docs/Archive/archive-publisher.example.properties deleted file mode 100644 index dd046cf0..00000000 --- a/docs/Archive/archive-publisher.example.properties +++ /dev/null @@ -1,33 +0,0 @@ -# Минимальный пример для archive-capable сервера. -# Добавлять во внешний application.properties; существующие DB/Solana/server настройки не удалять. - -archive.publish.enabled=true -archive.publish.time=00:00 -archive.publish.zoneId=Europe/Warsaw -archive.workDir=data/archive -archive.maxFileBytes=4000000000 - -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/home/player/SHiNE/secrets/archive-arweave-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 - -# Отдельный archive Solana RPC НЕ задаётся. -# Используется solana.users.sync.rpcUrl, иначе solana.rpcUrl. -archive.solana.rootKeyPath=/home/player/SHiNE/secrets/server-root.key -archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key -# Эти файлы могут содержать Base58 seed 32 bytes или Base58 Solana secret key 64 bytes. -archive.solana.confirmPollSeconds=5 -archive.solana.confirmTimeoutMinutes=30 -archive.solana.commitment=finalized - - -# ============================================================= -# Trusted archive import (independent from publisher) -# Empty = do not import archives from any external server. -# Comma-separated SHiNE server logins, case-insensitive. -# ============================================================= -archive.import.allowedPublishers= -archive.import.intervalMinutes=60 -archive.import.workDir=data/archive-import diff --git a/docs/Blockchain/01_Common_Block_Format.md b/docs/Blockchain/01_Common_Block_Format.md index a9fc5644..a9b49bb5 100644 --- a/docs/Blockchain/01_Common_Block_Format.md +++ b/docs/Blockchain/01_Common_Block_Format.md @@ -1,49 +1,116 @@ -# Общий формат добавляемого блока (Frame v0) +# Общий формат пользовательского блока SHiNE — Frame v1 / ANS-104 -Этот файл описывает **единый бинарный формат** блока, который клиент отправляет через `AddBlock` в поле `blockBytesB64`. +Актуальный формат не поддерживает старый Frame v0. Новый пользовательский блок сразу создаётся как готовый подписанный ANS-104 DataItem. -## 1. Полная структура блока +## 1. Два уровня формата -Блок состоит из двух частей: +Полный объект, который клиент отправляет в `AddBlock`, хранится в PostgreSQL и затем архивируется в Arweave: -1. **PREIMAGE** (подписывается) -2. **TAIL** (маркер подписи + подпись) +```text +ANS-104 DataItem + signature_type = 2 (generic Ed25519) + signature = 64 bytes + owner = 32-byte blockchain public key + target = absent + anchor = absent + tags + data = SHiNE Frame v1 +``` -### PREIMAGE +Цепочка SHiNE **не зависит от Arweave**. Arweave DataItem ID хранится отдельно и используется для дедупликации/поиска, но не является `prevHash`. -- `frameCode (uint16)` -- `prevHash32 (32 bytes)` -- `blockSize (int32)` — размер PREIMAGE -- `blockNumber (int32)` -- `timestamp (int64)` -- `type (uint16)` -- `subType (uint16)` -- `version (uint16)` -- `bodyBytes (N)` +## 2. SHiNE Frame v1 -### TAIL +Все целые поля Frame v1 — BigEndian. -- `sigMarker (uint16)` -- `signature64 (64 bytes, Ed25519)` +| Поле | Размер | Описание | +|---|---:|---| +| `frameCode` | 2 | `0x0001` | +| `prevHash32` | 32 | SHA-256 полного Frame v1 предыдущего SHiNE-блока; для блока 0 — нули | +| `blockSize` | 4 | точный размер Frame v1, включая header и body | +| `blockNumber` | 4 | номер блока, начиная с 0 | +| `timestamp` | 8 | Unix time seconds | +| `type` | 2 | тип сообщения | +| `subType` | 2 | подтип | +| `version` | 2 | версия body | +| `body` | N | данные конкретного типа | -## 2. Что проверяет сервер при AddBlock +`FRAME_HEADER_SIZE = 56` bytes. -- `frameCode` должен быть `0x0000`. -- `sigMarker` должен быть `0x0100`. -- `blockNumber` должен идти строго по порядку (`last + 1`). -- `prevHash32` должен совпасть с вершиной цепочки на сервере. -- `body` должен пройти `check()` для конкретного типа. -- подпись должна валидироваться публичным ключом блокчейна. +```text +blockHash32 = SHA256(FrameV1Bytes) +next.prevHash32 = blockHash32 +``` -## 3. Ограничения +Подписи внутри Frame v1 нет. Единственная подпись пользователя — подпись окружающего ANS-104 DataItem. -- максимальный полный размер блока: до 4 MiB; -- timestamp не должен сильно уходить в будущее; -- `bodyBytes` парсится по `type/subType/version` из заголовка блока. +## 3. ANS-104 DataItem -## 4. Почему это важно +Для тестового контура используется generic Ed25519 signature type `2`: подпись 64 bytes, owner 32 bytes. Это отдельный generic Ed25519 signer type; Solana-specific signer в текущем Turbo/arbundles имеет другой type. -Одинаковый общий формат позволяет: -- передавать разные виды записей через один RPC `AddBlock`; -- валидировать блоки единообразно; -- расширять типы `body`, не ломая каркас блока. +Подписывается стандартный ANS-104 deep-hash: + +```text +[ + "dataitem", + "1", + "2", + owner, + target(empty), + anchor(empty), + rawAvroTags, + FrameV1Bytes +] +``` + +`rawAvroTags` — ровно те Avro-serialized bytes тегов, которые лежат внутри DataItem. + +`dataItemId32 = SHA256(signature64)`. + +### Обязательный тестовый тег + +Каждый блок: + +```text +App = test5590 +``` + +Это временное namespace-значение для разработки. Перед реальным запуском оно будет заменено отдельным изменением протокола/кода. + +### Канальный тег + +Если блок относится к конкретному каналу, он дополнительно содержит: + +```text +c = +``` + +Slug входит в подпись DataItem и не может быть изменён сервером после подписи. + +## 4. Что хранится в PostgreSQL + +`blocks.block_bytes` содержит **полный serialized ANS-104 DataItem**, а не только Frame. + +Отдельно индексируются: + +- `block_hash` — SHA-256(Frame v1); +- `block_signature` — 64-byte Ed25519 signature из DataItem; +- `data_item_id` — SHA-256(signature), UNIQUE; +- `block_number`, `bch_name`, message fields; +- состояние публикации в Arweave. + +Локальные `.bch`, `.tmp_bch` и marker-файлы для пользовательских blockchain больше не используются. + +## 5. Проверка AddBlock + +Сервер обязан: + +1. распарсить полный ANS-104 DataItem; +2. проверить `App=test5590`; +3. проверить `c`, если тип блока требует канал; +4. проверить ANS-104 Ed25519 подпись; +5. проверить, что `owner` равен текущему blockchain public key пользователя; +6. распарсить Frame v1 и body; +7. проверить `blockNumber == last + 1`; +8. проверить `prevHash32 == lastBlockHash`; +9. записать DataItem и новое состояние атомарно в PostgreSQL. diff --git a/docs/Blockchain/17_ANS104_Arweave_Transport.md b/docs/Blockchain/17_ANS104_Arweave_Transport.md new file mode 100644 index 00000000..a71759a7 --- /dev/null +++ b/docs/Blockchain/17_ANS104_Arweave_Transport.md @@ -0,0 +1,97 @@ +# ANS-104 / Arweave transport для пользовательских блоков SHiNE + +## Цель + +Каждый пользовательский блок уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер не переподписывает пользовательский контент: он проверяет его, хранит в PostgreSQL и объединяет готовые DataItems в стандартный ANS-104 bundle. + +## Child DataItem tags + +Обязательно для тестового контура: + +```text +App=test5590 +``` + +Дополнительно для блоков конкретного канала: + +```text +c= +``` + +Теги входят в ANS-104 подпись пользователя. + +## Publisher + +По умолчанию цикл — раз в 15 минут. + +```text +blocks.arweave_publish_pending=true + ↓ +готовые serialized DataItems + ↓ +ANS-104 binary bundle + ↓ +обычная Arweave L1 transaction +``` + +Если pending-блоков нет, транзакция не создаётся. + +Root transaction содержит стандартные bundle tags: + +```text +Bundle-Format=binary +Bundle-Version=2.0.0 +Content-Type=application/octet-stream +App=test5590-batch +``` + +`App=test5590-batch` намеренно отличается от child `App=test5590`, чтобы discovery-запрос находил пользовательские блоки, а не root bundles. + +После успешной L1-загрузки сервер ставит child-блокам: + +- `arweave_publish_pending=false`; +- `arweave_published_at_ms`; +- `arweave_root_tx_id`. + +## Importer + +Каждый сервер может независимо искать: + +```text +App=test5590 +``` + +через GraphQL gateway с cursor pagination. + +Для каждого нового DataItem: + +1. взять `id` и `bundledIn.id`; +2. получить root bundle; +3. извлечь точные serialized bytes child DataItem по bundle index; +4. проверить `dataItemId == SHA256(signature)`; +5. проверить ANS-104 Ed25519 подпись; +6. определить пользователя по `owner`; +7. применить обычные проверки `AddBlock`; +8. записать в PostgreSQL с `arweave_publish_pending=false`. + +### Блоки могут прийти не по порядку + +Discovery/import использует persistent queue `arweave_block_import_queue`. Если, например, block 102 увиден раньше block 101, block 102 остаётся `PENDING`; после появления 101 очередь повторно проигрывается. + +## Дедупликация и несколько серверов + +Один и тот же готовый DataItem имеет один `data_item_id = SHA256(signature)`. Если несколько серверов включили его в разные root bundles, локально это всё равно один логический блок: `blocks.data_item_id` уникален. + +Импортированный из Arweave блок **не ставится обратно в publish queue**. Это предотвращает бесконечное переархивирование между серверами. + +## Локальное хранение + +Пользовательские blockchain-файлы на диске больше не используются. Полный serialized DataItem находится в `blocks.block_bytes` PostgreSQL. + +## Настройки + +См. `application.properties` и `CODEX_APPLY_ANS104_TEST5590_PATCH.md`. + +## Что намеренно не входит в этот патч + +Remote/homeserver signing path, связанный с внешним homeserver/ESP32 signer, не мигрируется этим патчем. Каталог `ESP32/` не изменяется. До отдельной миграции новый Frame v1/ANS-104 production path рассчитан на клиент, у которого локально доступен blockchain Ed25519 key. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index 9f1f6577..711cd046 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -236,3 +236,16 @@ - Добавлена поддержка командного префикса `/.` и команды `/.desc` для актуализации описания канала при чтении. - Зафиксированы команды `/.add` и `/.remove` для каналов `type=200` (зарезервировано под расширение участниками). - В `AGENTS.md` добавлено обязательное правило актуализации документации в `docs/Blockchain/`. + +## 2026-09-20 — Frame v1 + ANS-104 DataItem / Arweave per-block transport + +- Основание: пользователь явно подтвердил breaking change и запуск с чистой историей; patch archive, commit hash ещё отсутствует. +- Удалена поддержка старого Frame v0 (`preimage + sigMarker + signature`). +- Новый пользовательский block — SHiNE Frame v1 внутри стандартного ANS-104 DataItem, generic Ed25519 signature type 2. +- Signing message синхронизирован с фактическим arbundles/Turbo DataItem: включает строку signature type и raw Avro tag bytes. +- SHiNE chain hash остаётся независимым: `SHA256(FrameV1Bytes)`; Arweave DataItem ID хранится отдельно. +- В тестовом контуре каждый DataItem подписывает tag `App=test5590`; channel blocks дополнительно `c=`. +- PostgreSQL стал единственным локальным хранилищем пользовательских block bytes; `.bch/.tmp_bch/marker` storage/recovery удалён. +- Старый custom `SHINE-ARCHIVE` pipeline удалён. Добавлены standard ANS-104 bundle publisher через существующий Arweave L1 uploader и GraphQL importer. +- Импортированные из Arweave blocks не публикуются повторно; дедупликация идёт по `data_item_id`. +- ESP32 и связанный device code этим изменением не затронуты. diff --git a/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md new file mode 100644 index 00000000..c08e6016 --- /dev/null +++ b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md @@ -0,0 +1,108 @@ +# Инструкция Codex: применить ANS-104 test5590 patch + +## Цель + +Перевести пользовательский blockchain SHiNE на Frame v1 внутри готовых ANS-104 DataItems и убрать старый SHINE-ARCHIVE/файловое хранение цепочек. + +## Жёсткое ограничение + +**Не изменять ничего в `ESP32/`.** В этом patch нет ни одного файла `ESP32/**`. + +Remote/homeserver signer, завязанный на устройство, намеренно не мигрирован. Не пытаться «заодно исправить» его в рамках этого patch. + +## Применение + +1. Распаковать patch поверх корня репозитория, сохраняя относительные пути. +2. Удалить все пути из корневого `DELETE_FILES.txt`. +3. Проверить, что `git diff -- ESP32` пуст. +4. Использовать чистую/dev test DB. `migration_v24.sql` намеренно откажется мигрировать непустую blockchain DB, потому что backward compatibility со старым block format не требуется. + +## Arweave config + +Минимально для публикации: + +```properties +arweave.blocks.publish.enabled=true +arweave.blocks.publish.intervalMinutes=15 +arweave.blocks.publish.gateway=https://arweave.net +arweave.blocks.publish.walletJwkPath=/ABSOLUTE/SECRET/PATH/arweave-wallet.json +``` + +JWK не коммитить. + +Для discovery/import: + +```properties +arweave.blocks.sync.enabled=true +arweave.blocks.sync.intervalMinutes=15 +arweave.blocks.sync.gateway=https://turbo-gateway.com +arweave.blocks.sync.startBlockHeight=0 +``` + +На тестах желательно установить `startBlockHeight` на высоту начала `test5590`, чтобы не сканировать лишнюю историю. + +## Test namespace + +Child DataItem: + +```text +App=test5590 +``` + +Channel child: + +```text +App=test5590 +c= +``` + +Root bundle: + +```text +Bundle-Format=binary +Bundle-Version=2.0.0 +App=test5590-batch +``` + +Перед production-start test namespace должен быть заменён отдельным осознанным изменением. + +## Проверки после применения + +Из корня репозитория: + +```bash +node --check shine-UI/js/services/ans104-data-item.js +node --check shine-UI/js/services/auth-service.js +node --check shine-UI/js/app.js +node --check shine-UI/js/pages/settings-view.js +``` + +Java/Gradle: + +```bash +./gradlew testClasses +./gradlew test +``` + +Затем локальный smoke test по штатной инструкции проекта, например `./gradlew startLocal`. + +В среде, где готовился patch, Gradle wrapper не смог скачать Gradle 8.14 из-за отсутствия внешнего сетевого доступа к `services.gradle.org`. Поэтому полный Gradle compile/test обязательно прогнать после применения в обычной dev-среде. + +## Smoke scenario + +1. Создать/использовать тестового пользователя с локальным blockchain Ed25519 key. +2. Добавить обычный block и убедиться, что `blocks.block_bytes` начинается с ANS-104 DataItem, а `data_item_id` заполнен. +3. Создать channel и post; проверить `c=`. +4. Включить publisher, дождаться цикла или вызвать сервис тестом; проверить root Arweave tx. +5. На второй чистой test DB включить importer и убедиться, что `App=test5590` blocks восстанавливаются в правильном порядке. +6. Убедиться, что imported blocks имеют `arweave_publish_pending=false`. +7. Проверить, что повторный discovery не создаёт дублей. + +## Не делать в этом patch + +- не добавлять backward compatibility Frame v0; +- не возвращать `.bch` storage; +- не возвращать SHINE-ARCHIVE; +- не менять ESP32; +- не мигрировать remote/homeserver signing без отдельного решения пользователя; +- не заменять `prevHash` на Arweave DataItem ID. diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index d00ba6c2..ac552eb6 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -1,47 +1,40 @@ -# Документация блокчейна SHiNE (MVP) +# SHiNE Blockchain -Этот каталог описывает только текущий рабочий формат протокола для MVP. +Актуальная версия пользовательского blockchain использует **SHiNE Frame v1 внутри подписанного ANS-104 DataItem**. Старый Frame v0 и файловое хранение `.bch` не поддерживаются. ## Основные документы -1. [01_Common_Block_Format.md](./01_Common_Block_Format.md) - Единый бинарный формат блока (Frame v0), подпись, базовые проверки. -2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md) - Виды цепочек и правила line-полей. -3. [10_TECH_Blocks.md](./10_TECH_Blocks.md) - Системные блоки (`msg_type=0`). -4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md) - Текстовые блоки (`msg_type=1`). -5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md) - Реакции (`msg_type=2`). -6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md) - Социальные связи (`msg_type=3`). -7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md) - Параметры пользователя (`msg_type=4`). -8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md) - Статусные действия пользователя (`msg_type=5`). -9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md) - Вложения в TEXT-сообщениях через `S:att v=1`, включая опциональные `preAr/preSha256` для видео и крупных изображений. -10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md) - Скрытый `TEXT_CHANNEL_META` для профиля канала. -11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) - Типы каналов и формат `CreateChannelBody`. -12. [02_Channel_Commands.md](./02_Channel_Commands.md) - Команды в текстовых сообщениях каналов. -13. [CHANGELOG.md](./CHANGELOG.md) - Журнал изменений документации. -## Смежная документация -- [../ИТХ/README.md](../ИТХ/README.md) — ежедневное закрытие блокчейна (ИТХ): краткий обзор. -- [../ИТХ/Спецификация_ИТХ_v1.md](../ИТХ/Спецификация_ИТХ_v1.md) — точная спецификация чекпоинтов (Arweave/Solana/канал закрытий). -- [sync-between-servers.md](./sync-between-servers.md) — живая межсерверная синхронизация блокчейна и доставка DM на единственный сервер получателя. +- [`01_Common_Block_Format.md`](01_Common_Block_Format.md) — точный Frame v1, ANS-104, подпись, хэши и теги. +- [`17_ANS104_Arweave_Transport.md`](17_ANS104_Arweave_Transport.md) — публикация блоков в Arweave и обратный импорт между серверами. +- [`sync-between-servers.md`](sync-between-servers.md) — межсерверная синхронизация и full-resync без файловой копии blockchain. +- [`CHANGELOG.md`](CHANGELOG.md) — история изменений протокола. -## Важные ограничения MVP -- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI. -- Поддерживаемый рабочий сценарий UI на текущем этапе: `stories (type=0)` и `public (type=1)`. +## Короткая схема -## Обязательное сопровождение -- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений. -- Обычный `AddBlock` сейчас пишет через `.tmp_bch`, `.write_check` и `.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery. -- Для runtime-агрегатов статистики `user_stats_state` и `channel_stats_state` действует тот же принцип derived state: они обновляются вместе с `AddBlock` и полностью пересобираются при full resync. -- Если в старых данных есть канал владельца, которого ещё нет в `solana_user_pda_current`, сервер не падает: `channel_stats_state` всё равно обновляется, а `user_stats_state` создаётся только после появления пользователя в Solana PDA. -- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания. +```text +User + -> создаёт Frame v1 + -> tags: App=test5590, при канале c= + -> Ed25519 подписывает ANS-104 deep-hash + -> готовый DataItem + -> AddBlock + +Server + -> verify DataItem + SHiNE chain + -> PostgreSQL + -> каждые ~15 минут ANS-104 bundle + -> Arweave L1 + +Other servers + -> GraphQL App=test5590 + -> скачивают/извлекают DataItem + -> verify + -> PostgreSQL без повторной публикации +``` + +## Важные свойства + +- SHiNE `prevHash` остаётся SHA-256 предыдущего Frame v1 и не зависит от Arweave. +- `data_item_id` нужен для Arweave, поиска и дедупликации. +- PostgreSQL — единственное локальное хранилище пользовательских блоков. +- Тестовый namespace `App=test5590` специально отделён от будущего production namespace. diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md index c839e2b7..c1eff7e7 100644 --- a/docs/Blockchain/sync-between-servers.md +++ b/docs/Blockchain/sync-between-servers.md @@ -1,280 +1,42 @@ -# Синхронизация блокчейнов и доставка DM между серверами SHiNE +# Синхронизация blockchain между SHiNE-серверами -Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE. +## Локальная модель хранения -## 1. Зачем нужна синхронизация +PostgreSQL является единственным локальным хранилищем пользовательских блоков. Файлы `.bch`, `.tmp_bch`, `.write_pending`, `.write_check`, `.resync_pending` не используются. -Пользователи SHiNE могут быть «приписаны» к разным серверам. -Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y): +`blocks.block_bytes` хранит полный подписанный ANS-104 DataItem. `blockchain_state` хранит текущую вершину цепочки. -1. Сервер X принимает сообщение; -2. Сервер X должен переслать DM-блок серверу Y; -3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B. +## Обычный AddBlock -Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться, -чтобы любой партнёрский сервер мог отдать полную историю пользователя. +Все проверки и запись выполняются под lock конкретной chain. После проверки Frame v1, подписи и `prevHash` одна SQL-транзакция записывает block + derived state + новую вершину. -## 2. Список серверов синхронизации (`sync_servers`) +Локально созданный пользовательский блок получает `arweave_publish_pending=true`. -Каждый сервер регистрирует в своей Solana PDA список `sync_servers` — -логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется. +## Периодический peer-to-peer sync -`sync_servers` относится к серверному узлу и не является списком -access-серверов обычного пользователя. У пользователя действует только первый -`access_servers[0]`. +Старый межсерверный P2P sync может получать `GetBlockchainBlock` и применять полученный полный DataItem через `AddBlock`. При divergence full-resync: -- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера. -- Адрес каждого партнёрского сервера читается из его PDA на Solana. -- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`. +1. берёт lock chain; +2. очищает derived rows/blocks/state через `BlockchainResyncCleanupDAO`; +3. пересоздаёт state из актуального пользовательского реестра; +4. последовательно проигрывает удалённую цепочку с блока 0; +5. никаких файловых swap/recovery операций нет. -## 3. Что синхронизируется +## Arweave sync -### 3.1 Личные сообщения (DM) +Дополнительно каждый сервер может независимо включить `ArweaveBlockSyncScheduler`. -- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt). -- Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу. -- Сервер-получатель: сохраняет входящий блок в `signed_messages`, затем доставляет его активным сессиям. -- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`. -- Между access-серверами одного пользователя DM не реплицируются. -- Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`. +Он ищет child DataItems по тестовому тегу `App=test5590`, проверяет их и импортирует через ту же бизнес-проверку AddBlock. Импортированные блоки не публикуются повторно. -### 3.2 Блоки пользовательского блокчейна +Если блоки обнаружены не по порядку, persistent queue оставляет более поздние блоки pending до появления предыдущих. -- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него. -- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`. -- Порядок блоков сохраняется (по глобальному номеру блока и хэшу). -- Дедупликация по глобальному номеру блока и хэшу. +## Источник истины для цепочки -### 3.3 Пользовательские настройки +Arweave не участвует в вычислении SHiNE chain hash: -Пользовательские настройки хранятся локально на единственном access-сервере и -между серверами не синхронизируются. +```text +block_hash = SHA256(FrameV1Bytes) +next.prevHash32 = block_hash +``` -## 4. Текущая реализованная схема - -На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов. - -### 4.1 Что уже сделано - -1. При старте сервер читает свой `server.SHiNE.login`. -2. По этому логину он загружает из Solana свою server PDA. -3. Из неё вытаскивает список `sync_servers`. -4. Для каждого логина партнёра сервер читает его PDA и сохраняет локально: - - `login` - - `server_address` -5. После этого: - - новые локальные `AddBlock` рассылаются партнёрам в фоне; - - при старте запускается periodic sync; - - periodic sync повторяется каждые `12` часов после старта. - -### 4.2 Какие server-to-server API уже используются - -- `ListBlockchainHeads` — список heads всех локальных цепочек партнёра; -- `GetBlockchainBlock` — чтение одного конкретного блока партнёра; -- `GetSyncUserProfile` — минимальный профиль пользователя для локального создания runtime-проекции пользователя и `blockchain_state` без обращения в Solana RPC. - -### 4.3 Как сейчас работает periodic sync - -Для каждого сервера из локальной таблицы `sync_servers`: - -1. запрашивается `ListBlockchainHeads`; -2. для каждой удалённой цепочки сравниваются: - - `lastBlockNumber` - - `lastBlockHash` - - локальное состояние; -3. если локальная цепочка слабее, сервер по одному блоку вызывает `GetBlockchainBlock`; -4. каждый скачанный блок локально применяется через существующий `AddBlock`; -5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальная runtime-проекция пользователя и `blockchain_state`. -6. если во время replay обнаруживается рассинхрон или на одинаковой высоте удалённая цепочка сильнее, запускается полный resync: - - цепочка помечается in-memory как `resync in progress`; - - создаётся marker-file в `data/`; - - в одной SQL-транзакции очищаются локальные данные цепочки и корректируются чужие счётчики; - - удаляются `.bch` и `.tmp_bch`; - - цепочка подтягивается заново с `0` через `GetBlockchainBlock`. - - обычный `AddBlock` на эту цепочку в этот момент возвращает `chain_resync_in_progress`. - -### 4.4 Как именно работает full resync - -Full resync запускается только тогда, когда: - -- локальная chain отстаёт и обычная докачка хвоста упирается в `bad_prev_hash` или `bad_block_number`; -- либо высота цепочек одинаковая, но удалённая версия сильнее по правилу: - - `lastBlockNumber`; - - `fileSizeBytes`; - - `lastBlockHash`. - -Порядок действий: - -1. Ставится in-memory guard на `blockchainName`. -2. Создаётся marker-file `.resync_pending`. -3. Обычный `AddBlock` на эту chain временно получает `chain_resync_in_progress`. -4. Вызывается атомарный SQL cleanup одной chain: - - уменьшаются чужие `likes_count` и `replies_count`; - - удаляются локальные derived-state записи этой chain; - - удаляются `blocks` и `blockchain_state` этой chain. -5. Удаляются файлы `.bch` и `.tmp_bch`. -6. Локальная chain создаётся заново через `GetSyncUserProfile` или через Solana import, если `sync.importUserProfileFromPartner.enabled=false`. -7. Chain replay-ится с `0` через `GetBlockchainBlock`. -8. Если всё прошло успешно, marker-file удаляется. -9. Если на любом шаге произошёл сбой, marker-file остаётся на диске, и сервер добивает эту chain при следующем старте. - -Важно: - -- full resync не делает умный rollback по одному блоку; -- full resync не трогает DM-таблицы и current users слой; -- висячие cross-chain ссылки считаются допустимым поведением системы. - -### 4.5 Как работает обычный `AddBlock` и его recovery - -Обычная запись блока теперь тоже идёт через временные артефакты: - -1. собирается `.tmp_bch` как полный кандидат на замену основного файла; -2. пишется маленький sidecar `.write_check` с `blockNumber` и `blockHash`; -3. только после этого создаётся пустой marker `.write_pending`; -4. выполняется SQL-транзакция; -5. после `commit` tmp атомарно ставится на место основного `.bch`; -6. marker и sidecar удаляются. - -На старте `BlockchainTmpRecoveryOnStartup` смотрит именно на эту пару: - -- если `write_pending` есть, recovery проверяет sidecar и БД, а затем либо завершает swap, либо чистит временные файлы; -- если `write_pending` нет, а `tmp_bch` или `write_check` остались, это мусор и он удаляется; -- `resync_pending` сюда не относится, это отдельный recovery-поток. - -### 4.6 Startup recovery по marker-file - -При старте сервер идёт в таком порядке: - -1. `BlockchainTmpRecoveryOnStartup` для `*.write_pending` и orphan `*.tmp_bch` / `*.write_check`; -2. `BlockchainResyncRecoveryOnStartup` для `*.resync_pending`; -3. только потом поднимается обычный сервер и запускается `PeriodicBlockchainSyncService`. - -Если marker-file существует: - -- сервер не должен начинать обычную работу поверх этой chain; -- recovery снова выполняет cleanup и replay с нуля; -- если recovery не завершился, marker остаётся, и сервер не переходит к обычному режиму для этой chain. - -### 4.7 Зачем понадобился `GetSyncUserProfile` - -Изначально подготовка локальной цепочки делалась через Solana: - -- из `blockchainName` извлекался `login`; -- сервер вызывал import пользователя из Solana PDA; -- по данным PDA локально создавались runtime-проекция пользователя и `blockchain_state`. - -На практике это упёрлось в ограничение внешнего Solana RPC: при чистом старте и массовой подтяжке чужих цепочек сервер мог получать `HTTP 429`. - -Поэтому добавлен отдельный обходной режим: - -- настройка `sync.importUserProfileFromPartner.enabled=true` -- в этом режиме сервер **не ходит в Solana RPC** для создания локальной цепочки во время sync; -- вместо этого он запрашивает у сервера-партнёра `GetSyncUserProfile` и создаёт локальную запись по данным партнёра. -- если локальная runtime-проекция пользователя уже существует, sync восстанавливает только `blockchain_state` и не трогает user-layer. - -Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint. - -### 4.8 Что делает настройка `sync.importUserProfileFromPartner.enabled` - -- `false` — стандартный режим, подготовка локального пользователя идёт через Solana PDA; -- `true` — sync-режим обхода Solana, локальный пользователь создаётся по server-to-server `GetSyncUserProfile`. - -Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync. - -## 5. Реализованный постоянный server-to-server транспорт - -Этот раздел не меняет текущую семантику DM, settings и blockchain. Он описывает -единый постоянный WSS-транспорт, через который выполняются уже существующие операции. - -### 5.1 Межсерверное соединение - -- Серверы устанавливают постоянное исходящее WebSocket-соединение друг с другом. -- Адрес партнёра определяется по `server_address` из его Solana PDA. -- После подключения отправляется `ServerHello` с `serverLogin`, версией протокола и capabilities. -- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена. -- При разрыве выполняется переподключение с jitter/backoff до 60 секунд. -- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд. -- Один физический канал переиспользуют доставка DM и blockchain. - -### 5.2 Доставка новых данных (push) - -- При получении нового блока сервер может немедленно пушить его всем подключённым партнёрам. -- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff. -- DM использует отдельное расписание, описанное в `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`. - -### 5.3 Начальная синхронизация (backfill) - -- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов. -- Сервер с более полной историей досылает недостающее партнёру. -- DM-history backfill между access-серверами отсутствует. - -### 5.4 Разрешение конфликтов - -- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока. - Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `docs/Blockchain/README.md`). -- DM: конфликтов нет, `message_key` уникален. - -## 6. Маршрутизация DM между серверами - -При отправке DM от пользователя A к пользователю B: - -1. Клиент A отправляет пару блоков на свой сервер X. -2. Сервер X валидирует и локально сохраняет пару. -3. До ответа клиенту X отправляет входящую копию на единственный - `access_servers[0]` пользователя B через `ReceiveIncomingMessage`. -4. Успешное сохранение на сервере B означает терминальный `delivered`. -5. При неудаче выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час. -6. После неудачной попытки через час ставится терминальный `failed`. - -Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке. - -## 7. Безопасность - -- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое. -- Серверы не расшифровывают DM-контент; E2EE уже выполняется клиентами. -- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере. -- Межсерверная авторизация DM-операций пока отложена; `sourceServerLogin` временно считается доверенным. - -## 8. Статус реализации - -| Компонент | Статус | -|-----------|--------| -| Регистрация серверной PDA в Solana | ✅ Реализовано | -| Чтение `sync_servers` из PDA | ✅ Реализовано | -| Локальная таблица `sync_servers` | ✅ Реализовано | -| Публичный `ListBlockchainHeads` | ✅ Реализовано | -| Публичный `GetBlockchainBlock` | ✅ Реализовано | -| Публичный `GetSyncUserProfile` | ✅ Реализовано | -| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано | -| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано | -| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано | -| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` | -| Асинхронная доставка DM на единственный access-сервер получателя | ✅ Реализовано | -| Retry DM до 1 часа + UI-state | ✅ Реализовано | -| Репликация DM и настроек между access-серверами | Не используется | -| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул | -| Periodic backfill отсутствующего хвоста | ✅ Реализовано | -| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync | -| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано | -| Маршрутизация DM только через `access_servers[0]` | ✅ Реализовано | -| Криптографическая server-to-server авторизация DM | Нужна реализация | - -Текущая версия сервера использует постоянный WSS-пул для существующих -server-to-server JSON-операций. Отдельной будущей задачей остаётся -криптографическая авторизация server-to-server вызовов. - -Следующие отдельные шаги после текущего этапа: -- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов. - -### 8.1 Практическая проверка на тестовом сервере - -Проверка на `server2.shineup.me` показала, что текущая схема действительно поднимает цепочку при старте: - -- после рестарта сервер сначала проходит `BlockchainTmpRecovery`; -- затем обрабатывает `BlockchainResyncRecovery`; -- после этого сам догружает цепочку `aidartest-001` с `shineup.me`; -- итоговое состояние на тестовом сервере: - - `blockchain_state.last_block_number = 13` - - `blocks` по `aidartest-001` = `14` записей - -Это подтверждает, что startup sync и full-resync flow работают в живом сценарии, а не только в коде. +Поэтому цепочка остаётся проверяемой и переносимой независимо от конкретного storage backend. diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md deleted file mode 100644 index c4a2db63..00000000 --- a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md +++ /dev/null @@ -1,1619 +0,0 @@ -# SHiNE Archive Protocol v1.0 - -## Полная спецификация серверной архивации SHiNE-блоков в Arweave и фиксации головы архива в Solana - -**Статус:** согласованная спецификация v1.0 -**Язык:** русский -**Magic:** `SHINE-ARCHIVE` -**Версия протокола:** `1.0` -**Endian:** big-endian для всех фиксированных целых чисел - ---- - -# 1. Назначение - -Этот документ описывает механизм, по которому SHiNE-сервер периодически: - -1. синхронизирует обычные SHiNE-блоки существующим механизмом; -2. определяет новые локальные блоки, ещё не попавшие в архив; -3. фиксирует неизменяемую дельту; -4. группирует все новые записи одной `blockchain_name` в один блок пользователя внутри большого архивного блока; -5. добавляет к каждому такому пользовательскому блоку одну ссылку на предыдущий пользовательский блок этой же `blockchain_name`; -6. формирует один большой бинарный блок `SHINE-ARCHIVE`; -7. загружает его в Arweave; -8. ждёт закрепления/подтверждения Arweave-транзакции; -9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; -10. только после Solana `finalized` переводит локальные курсоры на новую точку. - -На первом этапе предполагается один архивный server, но формат изначально не должен запрещать появление других архиваторов. - ---- - -# 2. Основные понятия - -## 2.1. Исходный SHiNE-блок / запись - -Это существующие в серверной БД оригинальные `block_bytes`, относящиеся к конкретной `blockchain_name`. - -Они уже содержат пользовательские данные и существующую криптографию SHiNE. - -Архиватор: - -- не изменяет их; -- не переподписывает их; -- не переводит их в Base64; -- не пересобирает их содержимое. - -## 2.2. `blockchain_name` - -В архивном формате основной идентификатор пользовательской цепочки — именно `blockchain_name`. - -Отдельно хранить `login` для пользовательского блока не требуется. - -`blockchain_name` уже однозначно идентифицирует конкретную пользовательскую blockchain и логически включает пользовательскую идентичность + номер/вариант цепочки. - -## 2.3. Большой архивный блок - -Один файл/объект `SHINE-ARCHIVE`, который сервер формирует за один цикл архивной публикации. - -Он содержит: - -- header; -- список предыдущих больших архивных блоков; -- по одному `UserBlockchainChunk` для каждой `blockchain_name`, у которой есть новые записи; -- footer; -- SHA-256 большого блока; -- подпись сервера, который этот большой блок сформировал и закрыл. - -Один большой архивный блок после публикации соответствует одной Arweave-транзакции. - -## 2.4. `UserBlockchainChunk` - -В одном большом архивном блоке для одной `blockchain_name` существует максимум один такой chunk. - -Если за текущую дельту у пользователя/цепочки накопилось 1, 3, 5 или 20 записей, все они помещаются внутрь одного `UserBlockchainChunk`. - -Навигационная ссылка одна на весь chunk. - ---- - -# 3. Главная модель пользовательской истории - -Допустим `alice-001` имела записи: - -```text -BigBlock 70: - AliceChunk = 2 записи - -BigBlock 81: - AliceChunk = 5 записей - -BigBlock 95: - AliceChunk = 3 записи - -BigBlock 100: - AliceChunk = 4 записи -``` - -Тогда история `alice-001` выглядит: - -```text -BigBlock 100 - AliceChunk - 4 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 95 - AliceChunk - 3 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 81 - AliceChunk - 5 записей - | - v -BigBlock 70 -``` - -Навигация идёт пачками записей одной blockchain, а не по одной записи за раз. - ---- - -# 4. Сервер сам добавляет навигацию - -Пользователь не обязан знать: - -- в какой большой архивный блок попадут его записи; -- какой будет Arweave TX ID; -- какие будут offsets; -- где находится предыдущий chunk. - -Все эти данные сервер добавляет при формировании большого блока. - -Исходные подписанные пользовательские `block_bytes` остаются неизменными. - -`PreviousBlockchainChunkRef` является внешней серверной архивной метаинформацией и защищается hash + подписью всего большого архивного блока. - ---- - -# 5. Необходимые ключи архивного сервера - -Архивный server хранит: - -## 5.1. SHiNE root private key - -Используется: - -- для подписания новой версии User PDA при обычном `update_user_pda`; -- в v1.0 — для подписи закрытого большого архивного блока. - -## 5.2. SHiNE client private key - -Используется как Solana transaction signer / fee payer в соответствии с текущей логикой `shine_users`. - -## 5.3. Arweave JWK / private wallet key - -Используется для загрузки и оплаты Arweave-транзакции. - -## 5.4. Blockchain private key - -Для архивной публикации не требуется. - -Архиватор не создаёт новые пользовательские blockchain-блоки. - ---- - -# 6. Расписание публикации - -Архивная публикация запускается один раз в сутки в заданное локальное время. Она НЕ использует интервал «каждые N минут». - -Пример: - -```properties -archive.publish.enabled=true -archive.publish.time=00:00 -archive.publish.zoneId= -``` - -Для v1.0 значение по умолчанию: - -```text -00:00 -``` - -То есть новый snapshot и новый большой архивный блок создаются один раз в сутки в полночь. - -Если `archive.publish.zoneId` пуст, используется системная timezone сервера. При необходимости её можно задать явно, например `Europe/Warsaw`. Это сохраняет публикацию ровно в указанное локальное время даже при переходах летнего/зимнего времени. - -Незавершённый archive job после рестарта не ждёт следующей полуночи: сервер продолжает именно его сразу. Новый snapshot при старте вне назначенного времени не создаётся. - -## 6.1. Первая архивная публикация - -Если у данного archive publisher ещё нет подтверждённых архивных курсоров, первая публикация берёт ВСЁ локально известное состояние: - -```text -для каждой blockchain_name: -source block 0 .. current local head -``` - -То есть первый большой архивный блок содержит все SHiNE-блоки, которые сервер успел узнать к моменту первого суточного snapshot. После успешной публикации следующие большие блоки содержат только дельту относительно подтверждённых курсоров. - -## 6.2. Локальная папка и имена файлов - -Перед любой сетевой загрузкой большой блок сначала полностью создаётся на локальном диске. По умолчанию каталог: - -```text -data/archive/ -``` - -До получения Arweave TX ID файл имеет временное имя: - -```text -.<00001>.<дд.мм.гг>.tmp.SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.tmp.SHiNE-archive -``` - -Дата — реальная дата создания/freeze snapshot большого блока в timezone archive publisher-а. Номер имеет минимальную ширину 5 цифр. Пять цифр — форматирование, а не лимит: блок `100000` получает шестизначный номер. - -После успешной загрузки Arweave возвращает реальный TX ID. Сервер сначала надёжно сохраняет TX ID в БД, затем атомарно переименовывает тот же локальный файл в: - -```text -.<00001>.<дд.мм.гг>..SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive -``` - -Поле `` — не слово `trx`, а настоящий Base64URL TX ID загруженного объекта в Arweave. Финальный файл остаётся локально как постоянная копия. - -Crash recovery обязан продолжать работу с этим же файлом. Если TX ID уже сохранён, но процесс упал до rename, при следующем запуске сервер вычисляет финальное имя из сохранённого TX ID и завершает переименование без повторной сборки дельты. - ---- - -# 7. Состояние, относительно которого считается дельта - -Нужно различать: - -1. уже окончательно опубликованное состояние; -2. текущий замороженный job, который ещё проходит Arweave/Solana. - -Нельзя считать дельту от живой головы во время уже запущенной публикации. - ---- - -# 8. Таблица `archive_chain_cursor` - -Хранит окончательно подтверждённое архивное состояние для каждой `blockchain_name`. - -```sql -CREATE TABLE archive_chain_cursor ( - blockchain_name TEXT PRIMARY KEY, - - last_archived_source_block_number BIGINT NOT NULL, - last_archived_source_block_hash BYTEA NOT NULL, - - last_archive_big_block_number BIGINT, - last_archive_big_block_hash BYTEA, - - last_chunk_offset BIGINT, - last_chunk_size BIGINT, - - updated_at_ms BIGINT NOT NULL -); -``` - -Назначение: - -- `last_archived_source_block_number` — до какого исходного SHiNE-блока этой blockchain всё успешно опубликовано; -- `last_archived_source_block_hash` — hash этого исходного блока; -- `last_archive_big_block_number` и `last_archive_big_block_hash` — в каком большом `SHINE-ARCHIVE` находится последний chunk этой blockchain; -- `last_chunk_offset` и `last_chunk_size` — точное положение последнего chunk внутри большого блока. - -Этого достаточно, чтобы следующая публикация создала один backlink на предыдущий chunk. - ---- - -# 9. Отдельный индекс по каждой записи не нужен - -Поскольку все новые записи одной `blockchain_name` агрегируются в один `UserBlockchainChunk`, не нужно хранить offsets каждой отдельной записи в истории. - -Для следующей публикации достаточно помнить: - -```text -предыдущий big block -предыдущий big block hash -chunk offset -chunk size -``` - ---- - -# 10. Таблица `archive_publish_job` - -Хранит crash-safe состояние одной архивной публикации. - -```sql -CREATE TABLE archive_publish_job ( - id BIGSERIAL PRIMARY KEY, - - big_block_number BIGINT NOT NULL, - status TEXT NOT NULL, - - created_at_ms BIGINT NOT NULL, - - local_archive_path TEXT, - - archive_hash BYTEA, - arweave_tx_id BYTEA, - - arweave_confirmations INTEGER, - solana_signature TEXT, - - error_text TEXT, - updated_at_ms BIGINT NOT NULL -); -``` - -Рекомендуемые статусы: - -```text -SNAPSHOT_CREATED -FILE_BUILT -ARWEAVE_UPLOADED -ARWEAVE_CONFIRMED -SOLANA_SUBMITTED -SOLANA_FINALIZED -CURSORS_COMMITTED -FAILED -``` - -Завершённые rows этой таблицы одновременно дают локальную историю больших блоков: - -```text -big_block_number -archive_hash -arweave_tx_id -``` - -По ним строится FULL-список предыдущих больших блоков следующей публикации. - ---- - -# 11. Таблица `archive_publish_job_chain` - -Хранит frozen range каждой blockchain в текущем job и координаты создаваемого chunk. - -```sql -CREATE TABLE archive_publish_job_chain ( - job_id BIGINT NOT NULL, - blockchain_name TEXT NOT NULL, - - from_source_block_number BIGINT NOT NULL, - to_source_block_number BIGINT NOT NULL, - - previous_source_block_hash BYTEA NOT NULL, - last_source_block_hash BYTEA NOT NULL, - - previous_archive_big_block_number BIGINT, - previous_archive_big_block_hash BYTEA, - previous_chunk_offset BIGINT, - previous_chunk_size BIGINT, - - new_chunk_offset BIGINT, - new_chunk_size BIGINT, - - PRIMARY KEY (job_id, blockchain_name) -); -``` - ---- - -# 12. Заморозка дельты - -Пример: - -```text -локально: -alice-001 = source block 180 -bob-001 = source block 94 - -опубликовано: -alice-001 = 160 -bob-001 = 90 -``` - -Новый job фиксирует: - -```text -alice-001: 161..180 -bob-001: 91..94 -``` - -Если во время загрузки появятся: - -```text -alice-001 181..185 -bob-001 95..97 -``` - -они попадут только в следующий job. - ---- - -# 13. Проверка курсора перед публикацией - -Перед созданием frozen range сервер проверяет: - -```text -local hash(last_archived_source_block_number) -== -archive_chain_cursor.last_archived_source_block_hash -``` - -При несовпадении публикация этой chain останавливается до resync/fork handling. - ---- - -# 14. Ограничение размера большого блока - -Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. - -Поэтому один большой archive-файл MUST быть меньше `4 GiB`. - -Рекомендуемый запас: - -```properties -archive.maxFileBytes=4000000000 -``` - -Если собранный frozen job превышает этот лимит, v1.0 останавливает публикацию с явной ошибкой `ArchiveTooLargeException` и не двигает курсоры. Практически лимит очень велик; для такого сервера следует уменьшить объём данных между суточными закрытиями или реализовать деление snapshot на несколько big blocks. Автоматическое деление одного snapshot на несколько big blocks оставлено как совместимое будущее расширение. - ---- - -# 15. Magic и версия - -Файл начинается ASCII: - -```text -SHINE-ARCHIVE -``` - -Размер: 13 байт. - -Далее: - -```text -u8 version_major -u8 version_minor -``` - -Для v1.0: - -```text -1 -0 -``` - ---- - -# 16. Общий layout большого блока - -```text -+------------------------------------------+ -| FIXED HEADER | -+------------------------------------------+ -| creator_login | -+------------------------------------------+ -| PREVIOUS BIG BLOCK REFERENCES | -| ref[0] | -| ref[1] | -| ... | -+------------------------------------------+ -| UserBlockchainChunk #1 | -| UserBlockchainChunk #2 | -| ... | -+------------------------------------------+ -| FOOTER: closer_login | -| block_hash | -| signature | -+------------------------------------------+ -``` - ---- - -# 17. Fixed Header v1.0 - -Все integers — big-endian. - -| Поле | Тип | Размер | -|---|---:|---:| -| magic | bytes[13] | 13 | -| version_major | u8 | 1 | -| version_minor | u8 | 1 | -| header_size | u32 | 4 | -| big_block_number | u32 | 4 | -| created_at_ms | u64 | 8 | -| creator_login_length | u8 | 1 | -| references_mode | u8 | 1 | -| references_count | u32 | 4 | -| reference_entry_size | u16 | 2 | -| parent_reference_index | u32 | 4 | -| blockchain_chunks_count | u32 | 4 | -| total_user_records_count | u32 | 4 | - -После fixed header немедленно идут `creator_login` bytes. - -`header_size` — byte offset первого `PreviousBigBlockReference`. - ---- - -# 18. `creator_login` - -SHiNE login пользователя/сервера, который сформировал большой блок. - -```text -u8 creator_login_length -bytes[N] creator_login UTF-8 -``` - -Этот login находится в начале файла, поэтому его можно узнать без скачивания payload. - ---- - -# 19. `big_block_number` - -```text -u32 -``` - -Рекомендуемая нумерация опубликованных больших блоков: - -```text -first = 1 -next = 2 -next = 3 -... -``` - -Первый реально публикуемый большой блок имеет номер `1`, поэтому его локальное имя содержит `00001`. Неудачная незавершённая попытка не становится частью опубликованной archive-цепочки. - ---- - -# 20. `created_at_ms` - -```text -u64 -``` - -Unix Epoch UTC milliseconds. - -Это время заморозки содержимого big block. - ---- - -# 21. Режим ссылок на предыдущие большие блоки - -```text -references_mode u8 -``` - -Значения: - -```text -0 = FULL -1 = PARTIAL, зарезервировано на будущее -``` - -Writer v1.0 MUST использовать `FULL`. - ---- - -# 22. FULL previous-block table - -Новый big block содержит ссылки на ВСЕ предыдущие большие блоки своей ветки. - -Пример: - -```text -BigBlock #365 - -References: -#1 -#2 -#3 -... -#364 -``` - -При одном big block в день через год таблица занимает примерно 24.8 KiB. - ---- - -# 23. `PreviousBigBlockReference` - -Фиксированный размер: 68 байт. - -| Поле | Тип | Размер | -|---|---:|---:| -| big_block_number | u32 | 4 | -| big_block_hash | bytes[32] | 32 | -| arweave_tx_id | bytes[32] | 32 | - -Семантика: - -```text -big_block_number = логический номер -big_block_hash = криптографическая идентичность -arweave_tx_id = где скачать блок -``` - ---- - -# 24. `references_count` - -Количество entries в таблице ссылок. - -```text -u32 -``` - ---- - -# 25. `reference_entry_size` - -```text -u16 -``` - -Для v1.0: - -```text -68 -``` - -Поле оставлено для будущего расширения entry. - ---- - -# 26. `parent_reference_index` - -```text -u32 -``` - -Индекс непосредственного родителя внутри reference table. - -Для первого большого блока (`#1`), у которого нет родителя: - -```text -0xFFFFFFFF -``` - -В простой FULL-линейной ветке обычно это последний entry. - ---- - -# 27. Быстрое скачивание начала блока - -Клиент сначала скачивает fixed header + creator login. - -Из него узнаёт: - -```text -header_size -references_count -reference_entry_size -``` - -Затем скачивает только reference table и уже имеет адреса/hash всех предыдущих больших блоков. - -Payload пользователей можно пока не скачивать. - ---- - -# 28. `blockchain_chunks_count` - -```text -u32 -``` - -Количество `UserBlockchainChunk` в текущем big block. - -Одна `blockchain_name` встречается максимум один раз. - ---- - -# 29. `total_user_records_count` - -```text -u32 -``` - -Общее количество исходных SHiNE-записей во всех chunks. - ---- - -# 30. Порядок chunks и records - -Для детерминированной сериализации chunks SHOULD сортироваться по `blockchain_name ASC`. - -Внутри chunk исходные записи MUST идти по возрастанию номера исходного SHiNE-блока. - ---- - -# 31. Формат `UserBlockchainChunk` - -```text -u32 chunk_size - -u8 blockchain_name_length -bytes[N] blockchain_name UTF-8 - -u32 records_count - -repeat records_count: - u32 record_size - bytes[M] raw_record_bytes - -u32 previous_big_block_ref -u32 previous_chunk_offset -u32 previous_chunk_size -``` - ---- - -# 32. `chunk_size` - -```text -u32 -``` - -Полный размер chunk от первого байта `chunk_size` до последнего байта `previous_chunk_size` включительно. - -Это позволяет: - -- быстро пропустить chunk; -- скачать его Range-запросом; -- сохранить его точные координаты для следующего backlink. - ---- - -# 33. `blockchain_name` внутри chunk - -Хранится только `blockchain_name`. - -Отдельный `login` не нужен. - ---- - -# 34. `records_count` - -```text -u32 -``` - -Если за период у `alice-001` появилось 7 записей: - -```text -records_count = 7 -``` - -Все 7 находятся в одном chunk. - ---- - -# 35. Raw records - -Каждая исходная запись сериализуется: - -```text -u32 record_size -bytes[record_size] raw_record_bytes -``` - -`raw_record_bytes` — оригинальные данные SHiNE из БД. - -Сервер не меняет их содержимое. - ---- - -# 36. Один backlink на весь chunk - -После всех raw records находится ровно один `PreviousBlockchainChunkRef`. - -Он относится ко всей пачке записей blockchain. - -Он не повторяется у отдельных записей. - ---- - -# 37. `PreviousBlockchainChunkRef` - -| Поле | Тип | Размер | -|---|---:|---:| -| previous_big_block_ref | u32 | 4 | -| previous_chunk_offset | u32 | 4 | -| previous_chunk_size | u32 | 4 | - -Итого 12 байт на весь chunk. - ---- - -# 38. `previous_big_block_ref` - -Индекс в `PreviousBigBlockReferences[]` текущего big block. - -Он указывает на тот предыдущий big block, где находится предыдущий chunk этой же `blockchain_name`. - -Это именно reference index, а не номер блока. - ---- - -# 39. `previous_chunk_offset` - -```text -u32 -``` - -Byte offset от начала предыдущего `SHINE-ARCHIVE` до первого байта предыдущего chunk этой blockchain. - -Первый байт chunk — поле `chunk_size`. - ---- - -# 40. `previous_chunk_size` - -```text -u32 -``` - -Полный размер предыдущего chunk. - -Поэтому клиент может Range-запросом забрать сразу всю предыдущую пачку записей пользователя. - ---- - -# 41. Первая публикация blockchain - -Если у данной `blockchain_name` нет предыдущего архивного chunk: - -```text -previous_big_block_ref = 0xFFFFFFFF -previous_chunk_offset = 0 -previous_chunk_size = 0 -``` - ---- - -# 42. Пример backlink-цепочки - -В `BigBlock #100` есть `alice-001` chunk из 4 записей. - -Его backlink: - -```text -previous_big_block_ref = 94 -previous_chunk_offset = 8123 -previous_chunk_size = 1770 -``` - -`ref[94]` содержит: - -```text -big_block_number = 95 -big_block_hash = ... -arweave_tx_id = ... -``` - -Клиент: - -1. получает TX big block #95; -2. проверяет hash; -3. делает Range на `8123 .. 8123+1770`; -4. получает весь предыдущий `alice-001` chunk; -5. читает его записи; -6. берёт следующий backlink. - ---- - -# 43. Почему TX ID не хранится в каждом chunk - -TX ID и hash предыдущих больших блоков уже один раз записаны в общей reference table. - -Поэтому в chunk достаточно компактного `u32 previous_big_block_ref`. - ---- - -# 44. Footer большого блока - -После последнего chunk: - -```text -u8 closer_login_length -bytes[N] closer_login UTF-8 - -bytes[32] block_hash -bytes[64] closer_signature -``` - ---- - -# 45. `closer_login` - -Login пользователя/сервера, который сформировал и закрыл big block. - -В v1.0: - -```text -closer_login MUST == creator_login -``` - -Login повторяется намеренно: - -- header даёт автора сразу; -- footer явно фиксирует того, кто закрыл блок; -- hash покрывает footer login; -- verifier проверяет совпадение. - ---- - -# 46. `block_hash` - -```text -bytes[32] -SHA-256 -``` - -Хэшируется всё от первого байта magic до последнего байта `closer_login` включительно. - -Не входят: - -```text -block_hash -closer_signature -``` - -Псевдокод: - -```text -hash_input = - fixed_header - + creator_login - + references - + all UserBlockchainChunks - + closer_login_length - + closer_login - -block_hash = SHA256(hash_input) -``` - ---- - -# 47. `closer_signature` - -```text -bytes[64] -Ed25519 -``` - -Подписывается: - -```text -ASCII("SHINE-ARCHIVE-V1") || block_hash -``` - -Для v1.0 используется root private key аккаунта `creator_login`. - ---- - -# 48. Проверка подписи - -Verifier: - -1. читает `creator_login`; -2. проверяет `creator_login == closer_login`; -3. получает root public key аккаунта из User PDA / исторического состояния Solana; -4. пересчитывает `block_hash`; -5. проверяет Ed25519 signature. - -Старые архивы должны оставаться проверяемыми после ротации root key, поэтому исторические PDA-состояния нужно сохранять/уметь получать. - ---- - -# 49. Собственный Arweave TX ID текущего блока - -Текущий файл не может заранее знать свой будущий TX ID. - -Поэтому собственный TX ID: - -- не входит в сам текущий файл; -- сохраняется после upload; -- записывается в Solana PDA; -- появляется как previous-block reference уже в следующем big block. - ---- - -# 50. User PDA block type `100` - -Archive head хранится прямо в существующем User PDA publisher-а. - -Формат: - -```text -u8 block_type = 100 -u8 block_version = 0 -bytes[32] archive_tx_id -bytes[32] archive_hash -``` - -Итого 66 байт. - -`archive_hash` = `block_hash` последнего успешно опубликованного big block. - ---- - -# 51. Обновление Solana - -Отдельная новая Solana instruction не требуется. - -Используется существующий `update_user_pda`. - -Нужно расширить существующие serializer/parser/codec так, чтобы block type 100: - -- читался; -- записывался; -- сохранялся обычными обновлениями; -- мог быть изменён архивным сервером обычным update User PDA. - ---- - -# 52. Полный порядок публикации - -```text -1. Существующая server-to-server sync независимо обновляет локальные blockchains. - -2. Scheduler запускает archive job. - -3. Проверяется отсутствие другого активного archive job. - -4. Читаются archive_chain_cursor. - -5. Читаются текущие локальные blockchain heads. - -6. Для каждой blockchain вычисляется frozen delta. - -7. Создаётся archive_publish_job. - -8. Создаются archive_publish_job_chain rows. - -9. Загружается история всех успешно finalized previous big blocks. - -10. Формируется fixed header. - -11. Записывается creator_login. - -12. Записывается FULL PreviousBigBlockReferences. - -13. Для каждой blockchain_name: - - читается frozen range; - - создаётся ровно один UserBlockchainChunk; - - внутрь кладутся все новые records; - - добавляется один PreviousBlockchainChunkRef; - - сохраняются new_chunk_offset/new_chunk_size. - -14. Записывается closer_login. - -15. Вычисляется SHA-256 block_hash. - -16. Создаётся Ed25519 closer_signature. - -17. Файл закрывается. - -18. Файл загружается в Arweave. - -19. TX ID сохраняется в archive_publish_job. - -20. Сервер ждёт требуемое число Arweave confirmations. - -21. Обычным update_user_pda обновляется block type 100: - archive_tx_id - archive_hash - -22. Сервер ждёт Solana finalized. - -23. В одной DB transaction обновляются archive_chain_cursor. - -24. Job получает CURSORS_COMMITTED. -``` - ---- - -# 53. Cursor commit - -Для каждой blockchain из job: - -```text -last_archived_source_block_number = to_source_block_number -last_archived_source_block_hash = last_source_block_hash - -last_archive_big_block_number = current big_block_number -last_archive_big_block_hash = current block_hash - -last_chunk_offset = new_chunk_offset -last_chunk_size = new_chunk_size -``` - -Следующая публикация сразу знает, куда должен вести backlink. - ---- - -# 54. Arweave service - -Старый `TestFreeAvatarArweaveService` удаляется из активного протокола; archive publisher использует отдельный `ArweaveArchiveService`. - -Его следует переделать/переименовать, например в: - -```text -ArweaveArchiveService -``` - -Он должен: - -- использовать Arweave JWK; -- подписывать и оплачивать upload; -- поддерживать большие/chunked uploads; -- возвращать TX ID; -- ждать подтверждения; -- опрашивать confirmations. - -Рекомендуемые настройки: - -```properties -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 -``` - ---- - -# 55. Solana finalization - -После обычного `update_user_pda` сервер должен дождаться: - -```text -finalized -``` - -Только после этого разрешается сдвинуть локальные курсоры. - ---- - -# 56. Crash recovery - -## После `SNAPSHOT_CREATED` - -Возобновить тот же frozen job. - -## После `FILE_BUILT` - -Использовать тот же локальный файл. - -## После `ARWEAVE_UPLOADED` - -Не загружать второй раз. Продолжить по сохранённому TX ID. - -## После `ARWEAVE_CONFIRMED` - -Продолжить Solana update. - -## После `SOLANA_SUBMITTED` - -Сначала проверить transaction / текущий PDA. - -## После `SOLANA_FINALIZED`, но до cursor commit - -Проверить: - -```text -PDA.archive_tx_id == expected_tx -PDA.archive_hash == expected_hash -``` - -После этого безопасно выполнить cursor commit. - ---- - -# 57. Нет новых данных - -Если новых записей нет ни в одной blockchain: - -```text -не создавать пустой SHINE-ARCHIVE -не загружать Arweave -не обновлять Solana -``` - ---- - -# 58. FULL references как стартовая стратегия - -v1.0 сознательно хранит ВСЕ previous big blocks в каждом новом big block. - -Плюсы: - -- актуальный блок сразу даёт карту всей предыдущей истории; -- не нужно идти parent-by-parent; -- backlink может использовать компактный ref index; -- reader очень простой. - -При одном big block в сутки: - -```text -1 год: ~24.8 KiB -5 лет: ~121 KiB -20 лет: ~485 KiB -``` - ---- - -# 59. PARTIAL references - -`references_mode = 1` зарезервирован на будущее. - -v1.0 writer его не реализует. - -В будущем PARTIAL может означать: - -- только реально используемые previous blocks; -- последние N blocks; -- checkpoint references; -- смешанную стратегию. - ---- - -# 60. Чтение истории одной blockchain - -Имея актуальный chunk `alice-001`, клиент: - -1. читает все records chunk; -2. читает `PreviousBlockchainChunkRef`; -3. если ref = `0xFFFFFFFF`, история закончилась; -4. иначе берёт соответствующий `PreviousBigBlockReference`; -5. получает TX ID + hash previous big block; -6. Range-запросом читает `previous_chunk_offset/previous_chunk_size`; -7. получает сразу всю предыдущую пачку записей `alice-001`; -8. повторяет. - -Так можно читать историю конкретной blockchain, не скачивая чужие chunks. - ---- - -# 61. Дубликаты - -Если исходная SHiNE-запись уже есть у получателя, она пропускается/дедуплицируется стандартной логикой. - ---- - -# 62. Конфликты исходной blockchain - -Архивная ветка сама по себе не является пользовательским конфликтом. - -Конфликт возникает, когда одна `blockchain_name` имеет два несовместимых валидно подписанных продолжения одной предыдущей точки. - -Для v1 безопасно: - -```text -сохранить обе ветки -пометить blockchain CONFLICTED -остановить автоматическое продолжение -``` - -Механизм разрешения fork можно добавить позже. - ---- - -# 63. Авторство большого блока - -Автор указан дважды: - -## Header - -```text -creator_login -``` - -## Footer - -```text -closer_login -block_hash -closer_signature -``` - -Для v1.0: - -```text -creator_login == closer_login -``` - -Это означает: именно этот SHiNE-аккаунт сформировал и криптографически закрыл big block. - ---- - -# 64. Подпись сервера не заменяет подписи пользователей - -`closer_signature` доказывает авторство большого archive block. - -Каждая пользовательская raw record всё равно проверяется существующей криптографией SHiNE. - ---- - -# 65. Независимость от Arweave - -Arweave TX ID — location. - -SHA-256 — identity. - -Поэтому архивы можно перенести на другой storage и всё равно проверять. - ---- - -# 66. Block type 100 в Solana — текущая голова - -User PDA publisher-а хранит только: - -```text -current archive TX ID -current archive SHA-256 -``` - -Детальная история находится внутри big blocks. - ---- - -# 67. Startup preflight - -Перед запуском archive publisher сервер SHOULD проверить: - -```text -derivePublic(root_private) == UserPDA.root_public_key -``` - -и: - -```text -derivePublic(client_private) == UserPDA.client_key -``` - -При несовпадении archive publisher не запускается. - ---- - -# 68. Рекомендуемые настройки - -```properties -archive.publish.enabled=false -archive.publish.time=00:00 -archive.publish.zoneId= -archive.workDir=data/archive - -archive.maxFileBytes=4000000000 - -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 - -archive.solana.rootKeyPath=/opt/shine/secrets/root.key -archive.solana.clientKeyPath=/opt/shine/secrets/client.key -archive.solana.commitment=finalized - -# Отдельного archive.solana.rpcUrl нет. -# Используется solana.users.sync.rpcUrl, а если он пуст — обычный solana.rpcUrl. -``` - ---- - -# 69. Требование к `AppConfig` - -Archive settings должны читаться из: - -- Java system properties; -- environment variables; -- `application.properties`. - -`getInt()` и `getBoolean()` должны использовать общую логику `getParam()`. - ---- - -# 70. Что переиспользуется из текущего сервера - -Существующие механизмы: - -```text -BlockchainStateDAO.listAll() -BlocksDAO.listRangeByNumber(...) -``` - -дают необходимые heads и raw block ranges. - -Используются существующие: - -```text -block_bytes -block_hash -block_signature -blockchain_name -block_number -``` - -Server-to-server sync в v1.0 не меняется. - ---- - -# 71. Блокировки - -Можно использовать существующий blockchain lock только на коротком этапе: - -```text -проверить cursor hash -прочитать frozen range -проверить конечный hash -сохранить/сериализовать frozen data -``` - -Lock не удерживается во время Arweave/Solana ожиданий. - ---- - -# 72. Реализационный checklist - -## Database - -- [x] `archive_chain_cursor` -- [x] `archive_publish_job` -- [x] `archive_publish_job_chain` -- [x] schema migration -- [x] crash recovery - -## Archive writer - -- [x] magic `SHINE-ARCHIVE` -- [x] major/minor version -- [x] fixed header -- [x] creator login -- [x] FULL previous big block table -- [x] one chunk per `blockchain_name` -- [x] many raw records inside one chunk -- [x] one backlink per chunk -- [x] closer login -- [x] SHA-256 -- [x] Ed25519 signature -- [x] max file size < 4 GiB -- [x] local `.tmp.SHiNE-archive -> ..SHiNE-archive` lifecycle in `data/archive` - -## Arweave - -- [x] rename/refactor `TestFreeAvatarArweaveService` -- [x] remove avatar-specific logic -- [x] large/chunked upload -- [x] confirmation polling - -## Solana - -- [x] add PDA block type `100` -- [x] update Rust codec -- [x] update Java codec -- [x] update JS codec/UI writer -- [x] use ordinary `update_user_pda` -- [x] server-side transaction writer -- [x] root signature -- [x] client fee payer -- [x] wait for `finalized` - -## Scheduler - -- [x] `archive.publish.enabled` -- [x] `archive.publish.time` -- [x] `archive.publish.zoneId` -- [x] no concurrent jobs -- [ ] future: автоматическое split > max size (v1.0 сейчас безопасно останавливается без cursor commit) -- [x] skip when no new blocks - ---- - -# 73. Итоговая бинарная схема v1.0 - -```text -SHINE-ARCHIVE -version_major u8 -version_minor u8 - -header_size u32 -big_block_number u32 -created_at_ms u64 - -creator_login_length u8 - -references_mode u8 -references_count u32 -reference_entry_size u16 -parent_reference_index u32 - -blockchain_chunks_count u32 -total_user_records_count u32 - -creator_login bytes - - -PreviousBigBlockReference[references_count]: - - big_block_number u32 - big_block_hash [32] - arweave_tx_id [32] - - -UserBlockchainChunk[blockchain_chunks_count]: - - chunk_size u32 - - blockchain_name_length u8 - blockchain_name bytes - - records_count u32 - - repeat records_count: - record_size u32 - raw_record_bytes - - previous_big_block_ref u32 - previous_chunk_offset u32 - previous_chunk_size u32 - - -FOOTER: - - closer_login_length u8 - closer_login bytes - - block_hash [32] - closer_signature [64] -``` - ---- - -# 74. Основная архитектура одним рисунком - -```text -SOLANA USER PDA - | - | block type 100 - | TX + HASH - v -BigBlock #100 - | - +-- Header - | creator = server-A - | - +-- References - | ref[0] -> BigBlock #1 / hash / TX - | ref[1] -> BigBlock #2 / hash / TX - | ... - | ref[98] -> BigBlock #99 / hash / TX - | - +-- alice-001 chunk - | records x4 - | | - | +--> BigBlock #95 / AliceChunk - | - +-- bob-001 chunk - | records x2 - | | - | +--> BigBlock #98 / BobChunk - | - +-- kate-002 chunk - | records x10 - | | - | +--> BigBlock #74 / KateChunk - | - +-- Footer - closer = server-A - SHA-256 - Ed25519 signature -``` - ---- - -# 75. Итоговые решения v1.0 - -- один большой блок = одна архивная публикация; -- в начале лежит полный список всех предыдущих больших блоков; -- каждый previous block reference содержит `number + SHA-256 + Arweave TX ID`; -- одна `blockchain_name` встречается в большом блоке максимум одним chunk; -- все новые записи этой blockchain объединяются в этот chunk; -- chunk имеет одну ссылку на предыдущий chunk этой же blockchain; -- backlink состоит из `previous big block ref + chunk offset + chunk size`; -- сервер сам создаёт backlink; -- пользовательские raw records не меняются; -- login сервера есть в header и footer; -- большой блок закрывается SHA-256 + Ed25519 signature сервера; -- Solana User PDA block `100` хранит текущий TX ID + archive hash; -- PDA обновляется обычным `update_user_pda`; -- Arweave используется как долговременное хранилище; -- публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md deleted file mode 100644 index c4a2db63..00000000 --- a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md +++ /dev/null @@ -1,1619 +0,0 @@ -# SHiNE Archive Protocol v1.0 - -## Полная спецификация серверной архивации SHiNE-блоков в Arweave и фиксации головы архива в Solana - -**Статус:** согласованная спецификация v1.0 -**Язык:** русский -**Magic:** `SHINE-ARCHIVE` -**Версия протокола:** `1.0` -**Endian:** big-endian для всех фиксированных целых чисел - ---- - -# 1. Назначение - -Этот документ описывает механизм, по которому SHiNE-сервер периодически: - -1. синхронизирует обычные SHiNE-блоки существующим механизмом; -2. определяет новые локальные блоки, ещё не попавшие в архив; -3. фиксирует неизменяемую дельту; -4. группирует все новые записи одной `blockchain_name` в один блок пользователя внутри большого архивного блока; -5. добавляет к каждому такому пользовательскому блоку одну ссылку на предыдущий пользовательский блок этой же `blockchain_name`; -6. формирует один большой бинарный блок `SHINE-ARCHIVE`; -7. загружает его в Arweave; -8. ждёт закрепления/подтверждения Arweave-транзакции; -9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; -10. только после Solana `finalized` переводит локальные курсоры на новую точку. - -На первом этапе предполагается один архивный server, но формат изначально не должен запрещать появление других архиваторов. - ---- - -# 2. Основные понятия - -## 2.1. Исходный SHiNE-блок / запись - -Это существующие в серверной БД оригинальные `block_bytes`, относящиеся к конкретной `blockchain_name`. - -Они уже содержат пользовательские данные и существующую криптографию SHiNE. - -Архиватор: - -- не изменяет их; -- не переподписывает их; -- не переводит их в Base64; -- не пересобирает их содержимое. - -## 2.2. `blockchain_name` - -В архивном формате основной идентификатор пользовательской цепочки — именно `blockchain_name`. - -Отдельно хранить `login` для пользовательского блока не требуется. - -`blockchain_name` уже однозначно идентифицирует конкретную пользовательскую blockchain и логически включает пользовательскую идентичность + номер/вариант цепочки. - -## 2.3. Большой архивный блок - -Один файл/объект `SHINE-ARCHIVE`, который сервер формирует за один цикл архивной публикации. - -Он содержит: - -- header; -- список предыдущих больших архивных блоков; -- по одному `UserBlockchainChunk` для каждой `blockchain_name`, у которой есть новые записи; -- footer; -- SHA-256 большого блока; -- подпись сервера, который этот большой блок сформировал и закрыл. - -Один большой архивный блок после публикации соответствует одной Arweave-транзакции. - -## 2.4. `UserBlockchainChunk` - -В одном большом архивном блоке для одной `blockchain_name` существует максимум один такой chunk. - -Если за текущую дельту у пользователя/цепочки накопилось 1, 3, 5 или 20 записей, все они помещаются внутрь одного `UserBlockchainChunk`. - -Навигационная ссылка одна на весь chunk. - ---- - -# 3. Главная модель пользовательской истории - -Допустим `alice-001` имела записи: - -```text -BigBlock 70: - AliceChunk = 2 записи - -BigBlock 81: - AliceChunk = 5 записей - -BigBlock 95: - AliceChunk = 3 записи - -BigBlock 100: - AliceChunk = 4 записи -``` - -Тогда история `alice-001` выглядит: - -```text -BigBlock 100 - AliceChunk - 4 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 95 - AliceChunk - 3 записи - | - +--> PreviousBlockchainChunkRef - | - v -BigBlock 81 - AliceChunk - 5 записей - | - v -BigBlock 70 -``` - -Навигация идёт пачками записей одной blockchain, а не по одной записи за раз. - ---- - -# 4. Сервер сам добавляет навигацию - -Пользователь не обязан знать: - -- в какой большой архивный блок попадут его записи; -- какой будет Arweave TX ID; -- какие будут offsets; -- где находится предыдущий chunk. - -Все эти данные сервер добавляет при формировании большого блока. - -Исходные подписанные пользовательские `block_bytes` остаются неизменными. - -`PreviousBlockchainChunkRef` является внешней серверной архивной метаинформацией и защищается hash + подписью всего большого архивного блока. - ---- - -# 5. Необходимые ключи архивного сервера - -Архивный server хранит: - -## 5.1. SHiNE root private key - -Используется: - -- для подписания новой версии User PDA при обычном `update_user_pda`; -- в v1.0 — для подписи закрытого большого архивного блока. - -## 5.2. SHiNE client private key - -Используется как Solana transaction signer / fee payer в соответствии с текущей логикой `shine_users`. - -## 5.3. Arweave JWK / private wallet key - -Используется для загрузки и оплаты Arweave-транзакции. - -## 5.4. Blockchain private key - -Для архивной публикации не требуется. - -Архиватор не создаёт новые пользовательские blockchain-блоки. - ---- - -# 6. Расписание публикации - -Архивная публикация запускается один раз в сутки в заданное локальное время. Она НЕ использует интервал «каждые N минут». - -Пример: - -```properties -archive.publish.enabled=true -archive.publish.time=00:00 -archive.publish.zoneId= -``` - -Для v1.0 значение по умолчанию: - -```text -00:00 -``` - -То есть новый snapshot и новый большой архивный блок создаются один раз в сутки в полночь. - -Если `archive.publish.zoneId` пуст, используется системная timezone сервера. При необходимости её можно задать явно, например `Europe/Warsaw`. Это сохраняет публикацию ровно в указанное локальное время даже при переходах летнего/зимнего времени. - -Незавершённый archive job после рестарта не ждёт следующей полуночи: сервер продолжает именно его сразу. Новый snapshot при старте вне назначенного времени не создаётся. - -## 6.1. Первая архивная публикация - -Если у данного archive publisher ещё нет подтверждённых архивных курсоров, первая публикация берёт ВСЁ локально известное состояние: - -```text -для каждой blockchain_name: -source block 0 .. current local head -``` - -То есть первый большой архивный блок содержит все SHiNE-блоки, которые сервер успел узнать к моменту первого суточного snapshot. После успешной публикации следующие большие блоки содержат только дельту относительно подтверждённых курсоров. - -## 6.2. Локальная папка и имена файлов - -Перед любой сетевой загрузкой большой блок сначала полностью создаётся на локальном диске. По умолчанию каталог: - -```text -data/archive/ -``` - -До получения Arweave TX ID файл имеет временное имя: - -```text -.<00001>.<дд.мм.гг>.tmp.SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.tmp.SHiNE-archive -``` - -Дата — реальная дата создания/freeze snapshot большого блока в timezone archive publisher-а. Номер имеет минимальную ширину 5 цифр. Пять цифр — форматирование, а не лимит: блок `100000` получает шестизначный номер. - -После успешной загрузки Arweave возвращает реальный TX ID. Сервер сначала надёжно сохраняет TX ID в БД, затем атомарно переименовывает тот же локальный файл в: - -```text -.<00001>.<дд.мм.гг>..SHiNE-archive -``` - -Например: - -```text -archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive -``` - -Поле `` — не слово `trx`, а настоящий Base64URL TX ID загруженного объекта в Arweave. Финальный файл остаётся локально как постоянная копия. - -Crash recovery обязан продолжать работу с этим же файлом. Если TX ID уже сохранён, но процесс упал до rename, при следующем запуске сервер вычисляет финальное имя из сохранённого TX ID и завершает переименование без повторной сборки дельты. - ---- - -# 7. Состояние, относительно которого считается дельта - -Нужно различать: - -1. уже окончательно опубликованное состояние; -2. текущий замороженный job, который ещё проходит Arweave/Solana. - -Нельзя считать дельту от живой головы во время уже запущенной публикации. - ---- - -# 8. Таблица `archive_chain_cursor` - -Хранит окончательно подтверждённое архивное состояние для каждой `blockchain_name`. - -```sql -CREATE TABLE archive_chain_cursor ( - blockchain_name TEXT PRIMARY KEY, - - last_archived_source_block_number BIGINT NOT NULL, - last_archived_source_block_hash BYTEA NOT NULL, - - last_archive_big_block_number BIGINT, - last_archive_big_block_hash BYTEA, - - last_chunk_offset BIGINT, - last_chunk_size BIGINT, - - updated_at_ms BIGINT NOT NULL -); -``` - -Назначение: - -- `last_archived_source_block_number` — до какого исходного SHiNE-блока этой blockchain всё успешно опубликовано; -- `last_archived_source_block_hash` — hash этого исходного блока; -- `last_archive_big_block_number` и `last_archive_big_block_hash` — в каком большом `SHINE-ARCHIVE` находится последний chunk этой blockchain; -- `last_chunk_offset` и `last_chunk_size` — точное положение последнего chunk внутри большого блока. - -Этого достаточно, чтобы следующая публикация создала один backlink на предыдущий chunk. - ---- - -# 9. Отдельный индекс по каждой записи не нужен - -Поскольку все новые записи одной `blockchain_name` агрегируются в один `UserBlockchainChunk`, не нужно хранить offsets каждой отдельной записи в истории. - -Для следующей публикации достаточно помнить: - -```text -предыдущий big block -предыдущий big block hash -chunk offset -chunk size -``` - ---- - -# 10. Таблица `archive_publish_job` - -Хранит crash-safe состояние одной архивной публикации. - -```sql -CREATE TABLE archive_publish_job ( - id BIGSERIAL PRIMARY KEY, - - big_block_number BIGINT NOT NULL, - status TEXT NOT NULL, - - created_at_ms BIGINT NOT NULL, - - local_archive_path TEXT, - - archive_hash BYTEA, - arweave_tx_id BYTEA, - - arweave_confirmations INTEGER, - solana_signature TEXT, - - error_text TEXT, - updated_at_ms BIGINT NOT NULL -); -``` - -Рекомендуемые статусы: - -```text -SNAPSHOT_CREATED -FILE_BUILT -ARWEAVE_UPLOADED -ARWEAVE_CONFIRMED -SOLANA_SUBMITTED -SOLANA_FINALIZED -CURSORS_COMMITTED -FAILED -``` - -Завершённые rows этой таблицы одновременно дают локальную историю больших блоков: - -```text -big_block_number -archive_hash -arweave_tx_id -``` - -По ним строится FULL-список предыдущих больших блоков следующей публикации. - ---- - -# 11. Таблица `archive_publish_job_chain` - -Хранит frozen range каждой blockchain в текущем job и координаты создаваемого chunk. - -```sql -CREATE TABLE archive_publish_job_chain ( - job_id BIGINT NOT NULL, - blockchain_name TEXT NOT NULL, - - from_source_block_number BIGINT NOT NULL, - to_source_block_number BIGINT NOT NULL, - - previous_source_block_hash BYTEA NOT NULL, - last_source_block_hash BYTEA NOT NULL, - - previous_archive_big_block_number BIGINT, - previous_archive_big_block_hash BYTEA, - previous_chunk_offset BIGINT, - previous_chunk_size BIGINT, - - new_chunk_offset BIGINT, - new_chunk_size BIGINT, - - PRIMARY KEY (job_id, blockchain_name) -); -``` - ---- - -# 12. Заморозка дельты - -Пример: - -```text -локально: -alice-001 = source block 180 -bob-001 = source block 94 - -опубликовано: -alice-001 = 160 -bob-001 = 90 -``` - -Новый job фиксирует: - -```text -alice-001: 161..180 -bob-001: 91..94 -``` - -Если во время загрузки появятся: - -```text -alice-001 181..185 -bob-001 95..97 -``` - -они попадут только в следующий job. - ---- - -# 13. Проверка курсора перед публикацией - -Перед созданием frozen range сервер проверяет: - -```text -local hash(last_archived_source_block_number) -== -archive_chain_cursor.last_archived_source_block_hash -``` - -При несовпадении публикация этой chain останавливается до resync/fork handling. - ---- - -# 14. Ограничение размера большого блока - -Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. - -Поэтому один большой archive-файл MUST быть меньше `4 GiB`. - -Рекомендуемый запас: - -```properties -archive.maxFileBytes=4000000000 -``` - -Если собранный frozen job превышает этот лимит, v1.0 останавливает публикацию с явной ошибкой `ArchiveTooLargeException` и не двигает курсоры. Практически лимит очень велик; для такого сервера следует уменьшить объём данных между суточными закрытиями или реализовать деление snapshot на несколько big blocks. Автоматическое деление одного snapshot на несколько big blocks оставлено как совместимое будущее расширение. - ---- - -# 15. Magic и версия - -Файл начинается ASCII: - -```text -SHINE-ARCHIVE -``` - -Размер: 13 байт. - -Далее: - -```text -u8 version_major -u8 version_minor -``` - -Для v1.0: - -```text -1 -0 -``` - ---- - -# 16. Общий layout большого блока - -```text -+------------------------------------------+ -| FIXED HEADER | -+------------------------------------------+ -| creator_login | -+------------------------------------------+ -| PREVIOUS BIG BLOCK REFERENCES | -| ref[0] | -| ref[1] | -| ... | -+------------------------------------------+ -| UserBlockchainChunk #1 | -| UserBlockchainChunk #2 | -| ... | -+------------------------------------------+ -| FOOTER: closer_login | -| block_hash | -| signature | -+------------------------------------------+ -``` - ---- - -# 17. Fixed Header v1.0 - -Все integers — big-endian. - -| Поле | Тип | Размер | -|---|---:|---:| -| magic | bytes[13] | 13 | -| version_major | u8 | 1 | -| version_minor | u8 | 1 | -| header_size | u32 | 4 | -| big_block_number | u32 | 4 | -| created_at_ms | u64 | 8 | -| creator_login_length | u8 | 1 | -| references_mode | u8 | 1 | -| references_count | u32 | 4 | -| reference_entry_size | u16 | 2 | -| parent_reference_index | u32 | 4 | -| blockchain_chunks_count | u32 | 4 | -| total_user_records_count | u32 | 4 | - -После fixed header немедленно идут `creator_login` bytes. - -`header_size` — byte offset первого `PreviousBigBlockReference`. - ---- - -# 18. `creator_login` - -SHiNE login пользователя/сервера, который сформировал большой блок. - -```text -u8 creator_login_length -bytes[N] creator_login UTF-8 -``` - -Этот login находится в начале файла, поэтому его можно узнать без скачивания payload. - ---- - -# 19. `big_block_number` - -```text -u32 -``` - -Рекомендуемая нумерация опубликованных больших блоков: - -```text -first = 1 -next = 2 -next = 3 -... -``` - -Первый реально публикуемый большой блок имеет номер `1`, поэтому его локальное имя содержит `00001`. Неудачная незавершённая попытка не становится частью опубликованной archive-цепочки. - ---- - -# 20. `created_at_ms` - -```text -u64 -``` - -Unix Epoch UTC milliseconds. - -Это время заморозки содержимого big block. - ---- - -# 21. Режим ссылок на предыдущие большие блоки - -```text -references_mode u8 -``` - -Значения: - -```text -0 = FULL -1 = PARTIAL, зарезервировано на будущее -``` - -Writer v1.0 MUST использовать `FULL`. - ---- - -# 22. FULL previous-block table - -Новый big block содержит ссылки на ВСЕ предыдущие большие блоки своей ветки. - -Пример: - -```text -BigBlock #365 - -References: -#1 -#2 -#3 -... -#364 -``` - -При одном big block в день через год таблица занимает примерно 24.8 KiB. - ---- - -# 23. `PreviousBigBlockReference` - -Фиксированный размер: 68 байт. - -| Поле | Тип | Размер | -|---|---:|---:| -| big_block_number | u32 | 4 | -| big_block_hash | bytes[32] | 32 | -| arweave_tx_id | bytes[32] | 32 | - -Семантика: - -```text -big_block_number = логический номер -big_block_hash = криптографическая идентичность -arweave_tx_id = где скачать блок -``` - ---- - -# 24. `references_count` - -Количество entries в таблице ссылок. - -```text -u32 -``` - ---- - -# 25. `reference_entry_size` - -```text -u16 -``` - -Для v1.0: - -```text -68 -``` - -Поле оставлено для будущего расширения entry. - ---- - -# 26. `parent_reference_index` - -```text -u32 -``` - -Индекс непосредственного родителя внутри reference table. - -Для первого большого блока (`#1`), у которого нет родителя: - -```text -0xFFFFFFFF -``` - -В простой FULL-линейной ветке обычно это последний entry. - ---- - -# 27. Быстрое скачивание начала блока - -Клиент сначала скачивает fixed header + creator login. - -Из него узнаёт: - -```text -header_size -references_count -reference_entry_size -``` - -Затем скачивает только reference table и уже имеет адреса/hash всех предыдущих больших блоков. - -Payload пользователей можно пока не скачивать. - ---- - -# 28. `blockchain_chunks_count` - -```text -u32 -``` - -Количество `UserBlockchainChunk` в текущем big block. - -Одна `blockchain_name` встречается максимум один раз. - ---- - -# 29. `total_user_records_count` - -```text -u32 -``` - -Общее количество исходных SHiNE-записей во всех chunks. - ---- - -# 30. Порядок chunks и records - -Для детерминированной сериализации chunks SHOULD сортироваться по `blockchain_name ASC`. - -Внутри chunk исходные записи MUST идти по возрастанию номера исходного SHiNE-блока. - ---- - -# 31. Формат `UserBlockchainChunk` - -```text -u32 chunk_size - -u8 blockchain_name_length -bytes[N] blockchain_name UTF-8 - -u32 records_count - -repeat records_count: - u32 record_size - bytes[M] raw_record_bytes - -u32 previous_big_block_ref -u32 previous_chunk_offset -u32 previous_chunk_size -``` - ---- - -# 32. `chunk_size` - -```text -u32 -``` - -Полный размер chunk от первого байта `chunk_size` до последнего байта `previous_chunk_size` включительно. - -Это позволяет: - -- быстро пропустить chunk; -- скачать его Range-запросом; -- сохранить его точные координаты для следующего backlink. - ---- - -# 33. `blockchain_name` внутри chunk - -Хранится только `blockchain_name`. - -Отдельный `login` не нужен. - ---- - -# 34. `records_count` - -```text -u32 -``` - -Если за период у `alice-001` появилось 7 записей: - -```text -records_count = 7 -``` - -Все 7 находятся в одном chunk. - ---- - -# 35. Raw records - -Каждая исходная запись сериализуется: - -```text -u32 record_size -bytes[record_size] raw_record_bytes -``` - -`raw_record_bytes` — оригинальные данные SHiNE из БД. - -Сервер не меняет их содержимое. - ---- - -# 36. Один backlink на весь chunk - -После всех raw records находится ровно один `PreviousBlockchainChunkRef`. - -Он относится ко всей пачке записей blockchain. - -Он не повторяется у отдельных записей. - ---- - -# 37. `PreviousBlockchainChunkRef` - -| Поле | Тип | Размер | -|---|---:|---:| -| previous_big_block_ref | u32 | 4 | -| previous_chunk_offset | u32 | 4 | -| previous_chunk_size | u32 | 4 | - -Итого 12 байт на весь chunk. - ---- - -# 38. `previous_big_block_ref` - -Индекс в `PreviousBigBlockReferences[]` текущего big block. - -Он указывает на тот предыдущий big block, где находится предыдущий chunk этой же `blockchain_name`. - -Это именно reference index, а не номер блока. - ---- - -# 39. `previous_chunk_offset` - -```text -u32 -``` - -Byte offset от начала предыдущего `SHINE-ARCHIVE` до первого байта предыдущего chunk этой blockchain. - -Первый байт chunk — поле `chunk_size`. - ---- - -# 40. `previous_chunk_size` - -```text -u32 -``` - -Полный размер предыдущего chunk. - -Поэтому клиент может Range-запросом забрать сразу всю предыдущую пачку записей пользователя. - ---- - -# 41. Первая публикация blockchain - -Если у данной `blockchain_name` нет предыдущего архивного chunk: - -```text -previous_big_block_ref = 0xFFFFFFFF -previous_chunk_offset = 0 -previous_chunk_size = 0 -``` - ---- - -# 42. Пример backlink-цепочки - -В `BigBlock #100` есть `alice-001` chunk из 4 записей. - -Его backlink: - -```text -previous_big_block_ref = 94 -previous_chunk_offset = 8123 -previous_chunk_size = 1770 -``` - -`ref[94]` содержит: - -```text -big_block_number = 95 -big_block_hash = ... -arweave_tx_id = ... -``` - -Клиент: - -1. получает TX big block #95; -2. проверяет hash; -3. делает Range на `8123 .. 8123+1770`; -4. получает весь предыдущий `alice-001` chunk; -5. читает его записи; -6. берёт следующий backlink. - ---- - -# 43. Почему TX ID не хранится в каждом chunk - -TX ID и hash предыдущих больших блоков уже один раз записаны в общей reference table. - -Поэтому в chunk достаточно компактного `u32 previous_big_block_ref`. - ---- - -# 44. Footer большого блока - -После последнего chunk: - -```text -u8 closer_login_length -bytes[N] closer_login UTF-8 - -bytes[32] block_hash -bytes[64] closer_signature -``` - ---- - -# 45. `closer_login` - -Login пользователя/сервера, который сформировал и закрыл big block. - -В v1.0: - -```text -closer_login MUST == creator_login -``` - -Login повторяется намеренно: - -- header даёт автора сразу; -- footer явно фиксирует того, кто закрыл блок; -- hash покрывает footer login; -- verifier проверяет совпадение. - ---- - -# 46. `block_hash` - -```text -bytes[32] -SHA-256 -``` - -Хэшируется всё от первого байта magic до последнего байта `closer_login` включительно. - -Не входят: - -```text -block_hash -closer_signature -``` - -Псевдокод: - -```text -hash_input = - fixed_header - + creator_login - + references - + all UserBlockchainChunks - + closer_login_length - + closer_login - -block_hash = SHA256(hash_input) -``` - ---- - -# 47. `closer_signature` - -```text -bytes[64] -Ed25519 -``` - -Подписывается: - -```text -ASCII("SHINE-ARCHIVE-V1") || block_hash -``` - -Для v1.0 используется root private key аккаунта `creator_login`. - ---- - -# 48. Проверка подписи - -Verifier: - -1. читает `creator_login`; -2. проверяет `creator_login == closer_login`; -3. получает root public key аккаунта из User PDA / исторического состояния Solana; -4. пересчитывает `block_hash`; -5. проверяет Ed25519 signature. - -Старые архивы должны оставаться проверяемыми после ротации root key, поэтому исторические PDA-состояния нужно сохранять/уметь получать. - ---- - -# 49. Собственный Arweave TX ID текущего блока - -Текущий файл не может заранее знать свой будущий TX ID. - -Поэтому собственный TX ID: - -- не входит в сам текущий файл; -- сохраняется после upload; -- записывается в Solana PDA; -- появляется как previous-block reference уже в следующем big block. - ---- - -# 50. User PDA block type `100` - -Archive head хранится прямо в существующем User PDA publisher-а. - -Формат: - -```text -u8 block_type = 100 -u8 block_version = 0 -bytes[32] archive_tx_id -bytes[32] archive_hash -``` - -Итого 66 байт. - -`archive_hash` = `block_hash` последнего успешно опубликованного big block. - ---- - -# 51. Обновление Solana - -Отдельная новая Solana instruction не требуется. - -Используется существующий `update_user_pda`. - -Нужно расширить существующие serializer/parser/codec так, чтобы block type 100: - -- читался; -- записывался; -- сохранялся обычными обновлениями; -- мог быть изменён архивным сервером обычным update User PDA. - ---- - -# 52. Полный порядок публикации - -```text -1. Существующая server-to-server sync независимо обновляет локальные blockchains. - -2. Scheduler запускает archive job. - -3. Проверяется отсутствие другого активного archive job. - -4. Читаются archive_chain_cursor. - -5. Читаются текущие локальные blockchain heads. - -6. Для каждой blockchain вычисляется frozen delta. - -7. Создаётся archive_publish_job. - -8. Создаются archive_publish_job_chain rows. - -9. Загружается история всех успешно finalized previous big blocks. - -10. Формируется fixed header. - -11. Записывается creator_login. - -12. Записывается FULL PreviousBigBlockReferences. - -13. Для каждой blockchain_name: - - читается frozen range; - - создаётся ровно один UserBlockchainChunk; - - внутрь кладутся все новые records; - - добавляется один PreviousBlockchainChunkRef; - - сохраняются new_chunk_offset/new_chunk_size. - -14. Записывается closer_login. - -15. Вычисляется SHA-256 block_hash. - -16. Создаётся Ed25519 closer_signature. - -17. Файл закрывается. - -18. Файл загружается в Arweave. - -19. TX ID сохраняется в archive_publish_job. - -20. Сервер ждёт требуемое число Arweave confirmations. - -21. Обычным update_user_pda обновляется block type 100: - archive_tx_id - archive_hash - -22. Сервер ждёт Solana finalized. - -23. В одной DB transaction обновляются archive_chain_cursor. - -24. Job получает CURSORS_COMMITTED. -``` - ---- - -# 53. Cursor commit - -Для каждой blockchain из job: - -```text -last_archived_source_block_number = to_source_block_number -last_archived_source_block_hash = last_source_block_hash - -last_archive_big_block_number = current big_block_number -last_archive_big_block_hash = current block_hash - -last_chunk_offset = new_chunk_offset -last_chunk_size = new_chunk_size -``` - -Следующая публикация сразу знает, куда должен вести backlink. - ---- - -# 54. Arweave service - -Старый `TestFreeAvatarArweaveService` удаляется из активного протокола; archive publisher использует отдельный `ArweaveArchiveService`. - -Его следует переделать/переименовать, например в: - -```text -ArweaveArchiveService -``` - -Он должен: - -- использовать Arweave JWK; -- подписывать и оплачивать upload; -- поддерживать большие/chunked uploads; -- возвращать TX ID; -- ждать подтверждения; -- опрашивать confirmations. - -Рекомендуемые настройки: - -```properties -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 -``` - ---- - -# 55. Solana finalization - -После обычного `update_user_pda` сервер должен дождаться: - -```text -finalized -``` - -Только после этого разрешается сдвинуть локальные курсоры. - ---- - -# 56. Crash recovery - -## После `SNAPSHOT_CREATED` - -Возобновить тот же frozen job. - -## После `FILE_BUILT` - -Использовать тот же локальный файл. - -## После `ARWEAVE_UPLOADED` - -Не загружать второй раз. Продолжить по сохранённому TX ID. - -## После `ARWEAVE_CONFIRMED` - -Продолжить Solana update. - -## После `SOLANA_SUBMITTED` - -Сначала проверить transaction / текущий PDA. - -## После `SOLANA_FINALIZED`, но до cursor commit - -Проверить: - -```text -PDA.archive_tx_id == expected_tx -PDA.archive_hash == expected_hash -``` - -После этого безопасно выполнить cursor commit. - ---- - -# 57. Нет новых данных - -Если новых записей нет ни в одной blockchain: - -```text -не создавать пустой SHINE-ARCHIVE -не загружать Arweave -не обновлять Solana -``` - ---- - -# 58. FULL references как стартовая стратегия - -v1.0 сознательно хранит ВСЕ previous big blocks в каждом новом big block. - -Плюсы: - -- актуальный блок сразу даёт карту всей предыдущей истории; -- не нужно идти parent-by-parent; -- backlink может использовать компактный ref index; -- reader очень простой. - -При одном big block в сутки: - -```text -1 год: ~24.8 KiB -5 лет: ~121 KiB -20 лет: ~485 KiB -``` - ---- - -# 59. PARTIAL references - -`references_mode = 1` зарезервирован на будущее. - -v1.0 writer его не реализует. - -В будущем PARTIAL может означать: - -- только реально используемые previous blocks; -- последние N blocks; -- checkpoint references; -- смешанную стратегию. - ---- - -# 60. Чтение истории одной blockchain - -Имея актуальный chunk `alice-001`, клиент: - -1. читает все records chunk; -2. читает `PreviousBlockchainChunkRef`; -3. если ref = `0xFFFFFFFF`, история закончилась; -4. иначе берёт соответствующий `PreviousBigBlockReference`; -5. получает TX ID + hash previous big block; -6. Range-запросом читает `previous_chunk_offset/previous_chunk_size`; -7. получает сразу всю предыдущую пачку записей `alice-001`; -8. повторяет. - -Так можно читать историю конкретной blockchain, не скачивая чужие chunks. - ---- - -# 61. Дубликаты - -Если исходная SHiNE-запись уже есть у получателя, она пропускается/дедуплицируется стандартной логикой. - ---- - -# 62. Конфликты исходной blockchain - -Архивная ветка сама по себе не является пользовательским конфликтом. - -Конфликт возникает, когда одна `blockchain_name` имеет два несовместимых валидно подписанных продолжения одной предыдущей точки. - -Для v1 безопасно: - -```text -сохранить обе ветки -пометить blockchain CONFLICTED -остановить автоматическое продолжение -``` - -Механизм разрешения fork можно добавить позже. - ---- - -# 63. Авторство большого блока - -Автор указан дважды: - -## Header - -```text -creator_login -``` - -## Footer - -```text -closer_login -block_hash -closer_signature -``` - -Для v1.0: - -```text -creator_login == closer_login -``` - -Это означает: именно этот SHiNE-аккаунт сформировал и криптографически закрыл big block. - ---- - -# 64. Подпись сервера не заменяет подписи пользователей - -`closer_signature` доказывает авторство большого archive block. - -Каждая пользовательская raw record всё равно проверяется существующей криптографией SHiNE. - ---- - -# 65. Независимость от Arweave - -Arweave TX ID — location. - -SHA-256 — identity. - -Поэтому архивы можно перенести на другой storage и всё равно проверять. - ---- - -# 66. Block type 100 в Solana — текущая голова - -User PDA publisher-а хранит только: - -```text -current archive TX ID -current archive SHA-256 -``` - -Детальная история находится внутри big blocks. - ---- - -# 67. Startup preflight - -Перед запуском archive publisher сервер SHOULD проверить: - -```text -derivePublic(root_private) == UserPDA.root_public_key -``` - -и: - -```text -derivePublic(client_private) == UserPDA.client_key -``` - -При несовпадении archive publisher не запускается. - ---- - -# 68. Рекомендуемые настройки - -```properties -archive.publish.enabled=false -archive.publish.time=00:00 -archive.publish.zoneId= -archive.workDir=data/archive - -archive.maxFileBytes=4000000000 - -archive.arweave.gateway=https://arweave.net -archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json -archive.arweave.minConfirmations=1 -archive.arweave.confirmPollSeconds=30 -archive.arweave.confirmTimeoutMinutes=180 - -archive.solana.rootKeyPath=/opt/shine/secrets/root.key -archive.solana.clientKeyPath=/opt/shine/secrets/client.key -archive.solana.commitment=finalized - -# Отдельного archive.solana.rpcUrl нет. -# Используется solana.users.sync.rpcUrl, а если он пуст — обычный solana.rpcUrl. -``` - ---- - -# 69. Требование к `AppConfig` - -Archive settings должны читаться из: - -- Java system properties; -- environment variables; -- `application.properties`. - -`getInt()` и `getBoolean()` должны использовать общую логику `getParam()`. - ---- - -# 70. Что переиспользуется из текущего сервера - -Существующие механизмы: - -```text -BlockchainStateDAO.listAll() -BlocksDAO.listRangeByNumber(...) -``` - -дают необходимые heads и raw block ranges. - -Используются существующие: - -```text -block_bytes -block_hash -block_signature -blockchain_name -block_number -``` - -Server-to-server sync в v1.0 не меняется. - ---- - -# 71. Блокировки - -Можно использовать существующий blockchain lock только на коротком этапе: - -```text -проверить cursor hash -прочитать frozen range -проверить конечный hash -сохранить/сериализовать frozen data -``` - -Lock не удерживается во время Arweave/Solana ожиданий. - ---- - -# 72. Реализационный checklist - -## Database - -- [x] `archive_chain_cursor` -- [x] `archive_publish_job` -- [x] `archive_publish_job_chain` -- [x] schema migration -- [x] crash recovery - -## Archive writer - -- [x] magic `SHINE-ARCHIVE` -- [x] major/minor version -- [x] fixed header -- [x] creator login -- [x] FULL previous big block table -- [x] one chunk per `blockchain_name` -- [x] many raw records inside one chunk -- [x] one backlink per chunk -- [x] closer login -- [x] SHA-256 -- [x] Ed25519 signature -- [x] max file size < 4 GiB -- [x] local `.tmp.SHiNE-archive -> ..SHiNE-archive` lifecycle in `data/archive` - -## Arweave - -- [x] rename/refactor `TestFreeAvatarArweaveService` -- [x] remove avatar-specific logic -- [x] large/chunked upload -- [x] confirmation polling - -## Solana - -- [x] add PDA block type `100` -- [x] update Rust codec -- [x] update Java codec -- [x] update JS codec/UI writer -- [x] use ordinary `update_user_pda` -- [x] server-side transaction writer -- [x] root signature -- [x] client fee payer -- [x] wait for `finalized` - -## Scheduler - -- [x] `archive.publish.enabled` -- [x] `archive.publish.time` -- [x] `archive.publish.zoneId` -- [x] no concurrent jobs -- [ ] future: автоматическое split > max size (v1.0 сейчас безопасно останавливается без cursor commit) -- [x] skip when no new blocks - ---- - -# 73. Итоговая бинарная схема v1.0 - -```text -SHINE-ARCHIVE -version_major u8 -version_minor u8 - -header_size u32 -big_block_number u32 -created_at_ms u64 - -creator_login_length u8 - -references_mode u8 -references_count u32 -reference_entry_size u16 -parent_reference_index u32 - -blockchain_chunks_count u32 -total_user_records_count u32 - -creator_login bytes - - -PreviousBigBlockReference[references_count]: - - big_block_number u32 - big_block_hash [32] - arweave_tx_id [32] - - -UserBlockchainChunk[blockchain_chunks_count]: - - chunk_size u32 - - blockchain_name_length u8 - blockchain_name bytes - - records_count u32 - - repeat records_count: - record_size u32 - raw_record_bytes - - previous_big_block_ref u32 - previous_chunk_offset u32 - previous_chunk_size u32 - - -FOOTER: - - closer_login_length u8 - closer_login bytes - - block_hash [32] - closer_signature [64] -``` - ---- - -# 74. Основная архитектура одним рисунком - -```text -SOLANA USER PDA - | - | block type 100 - | TX + HASH - v -BigBlock #100 - | - +-- Header - | creator = server-A - | - +-- References - | ref[0] -> BigBlock #1 / hash / TX - | ref[1] -> BigBlock #2 / hash / TX - | ... - | ref[98] -> BigBlock #99 / hash / TX - | - +-- alice-001 chunk - | records x4 - | | - | +--> BigBlock #95 / AliceChunk - | - +-- bob-001 chunk - | records x2 - | | - | +--> BigBlock #98 / BobChunk - | - +-- kate-002 chunk - | records x10 - | | - | +--> BigBlock #74 / KateChunk - | - +-- Footer - closer = server-A - SHA-256 - Ed25519 signature -``` - ---- - -# 75. Итоговые решения v1.0 - -- один большой блок = одна архивная публикация; -- в начале лежит полный список всех предыдущих больших блоков; -- каждый previous block reference содержит `number + SHA-256 + Arweave TX ID`; -- одна `blockchain_name` встречается в большом блоке максимум одним chunk; -- все новые записи этой blockchain объединяются в этот chunk; -- chunk имеет одну ссылку на предыдущий chunk этой же blockchain; -- backlink состоит из `previous big block ref + chunk offset + chunk size`; -- сервер сам создаёт backlink; -- пользовательские raw records не меняются; -- login сервера есть в header и footer; -- большой блок закрывается SHA-256 + Ed25519 signature сервера; -- Solana User PDA block `100` хранит текущий TX ID + archive hash; -- PDA обновляется обычным `update_user_pda`; -- Arweave используется как долговременное хранилище; -- публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. diff --git a/docs/Solana/user_pda/README.md b/docs/Solana/user_pda/README.md index ac2e64c0..aa1b07b7 100644 --- a/docs/Solana/user_pda/README.md +++ b/docs/Solana/user_pda/README.md @@ -94,7 +94,7 @@ UserPdaRecordV1 | `40` | `AccessServersBlock` | Серверы доступа/relay. | | `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. | | `70` | `TrustedStateBlock` | Счетчик trusted-связей. | -| `100` | `ArchiveHeadBlock` | Текущая голова серверного SHINE-ARCHIVE: Arweave TX ID + SHA-256 архива. | +| `100` | `ArchiveHeadBlock` | Legacy/reserved. Новый per-block ANS-104 transport не использует это поле для пользовательской истории. | | `255` | `ReservedBlock` | Зарезервировано, пока не используется. | Правила: @@ -374,7 +374,7 @@ ArchiveHeadBlock Семантика: -- `archive_tx_id` — raw 32-byte Arweave transaction id последнего опубликованного большого `SHINE-ARCHIVE`; текстовая Base64URL-форма получается вне PDA; +- `archive_tx_id` — legacy/reserved поле старой archive-head схемы; новый per-block ANS-104 transport его не обновляет; - `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`; - отсутствие block `100` означает, что аккаунт ещё не объявлял archive head; - обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**; diff --git a/docs/Solana_Architecture/details/shine_users.md b/docs/Solana_Architecture/details/shine_users.md index aa7f6f4f..cc55f570 100644 --- a/docs/Solana_Architecture/details/shine_users.md +++ b/docs/Solana_Architecture/details/shine_users.md @@ -136,7 +136,7 @@ - upgrade-authority программы после проверки передается DAO; - пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате. -## ArchiveHeadBlock и серверный SHINE-ARCHIVE +## ArchiveHeadBlock (legacy/reserved) Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`): @@ -145,7 +145,7 @@ archive_tx_id [32] archive_hash [32] ``` -Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`. +Поле `ArchiveHeadBlock` осталось в Solana/PDA как legacy/reserved для совместимости формата PDA. Новый transport пользовательских блоков не использует server-level SHINE-ARCHIVE или archive head: каждый пользовательский block публикуется как ANS-104 DataItem внутри стандартных bundles. Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим: diff --git a/docs/libs/shine-server-blockchain/Doc.md b/docs/libs/shine-server-blockchain/Doc.md index 151e05c4..d0d174a7 100644 --- a/docs/libs/shine-server-blockchain/Doc.md +++ b/docs/libs/shine-server-blockchain/Doc.md @@ -1,4 +1,4 @@ -shine-server-blockchain — это библиотека, которая задаёт формат блока, правила парсинга/валидации тела, крипто-проверку (hash+Ed25519) и безопасную работу с файлами блокчейна (data/.bch через временный .tmp_bch). +shine-server-blockchain задаёт Frame v1, ANS-104 DataItem, правила парсинга/валидации body и криптографическую проверку. Пользовательские chain-файлы на диске больше не используются; полные DataItems хранятся в PostgreSQL. Как устроена структура и логика работы @@ -33,7 +33,6 @@ BchCryptoVerifier отвечает за “как получить хэш и к 4) Утилиты вокруг имени и файлов BlockchainNameUtil — извлекает login из blockchainName (отрезает 3 символа суффикса). -FileStoreUtil — безопасное файловое хранилище: 5) Объяснение структуры работы diff --git a/javac.20260920_155256.args b/javac.20260920_155256.args new file mode 100644 index 00000000..5d0cf8ff --- /dev/null +++ b/javac.20260920_155256.args @@ -0,0 +1,30 @@ +-proc:none +-d +/tmp/javac-check +-sourcepath +SHiNE-server/shine-server-geo/src/main/java:SHiNE-server/shine-server-log/src/main/java:SHiNE-server/shine-server-solana-users-sync/src/main/java:SHiNE-server/shine-server-db/src/main/java:SHiNE-server/src/main/java:SHiNE-server/shine-server-archive/src/main/java:SHiNE-server/shine-server-blockchain/src/main/java:SHiNE-server/shine-server-crypto/src/main/java:SHiNE-server/shine-server-net-protocol/src/main/java:SHiNE-server/shine-server-config/src/main/java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java +/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/ws/WsServer.java +/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java +/mnt/data/shine_patch_final_work/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java diff --git a/shine-UI/Blockchain-Viewer.html b/shine-UI/Blockchain-Viewer.html deleted file mode 100644 index 7a54eda5..00000000 --- a/shine-UI/Blockchain-Viewer.html +++ /dev/null @@ -1,3416 +0,0 @@ - - - - - - SHiNE Blockchain Viewer - - - - - -
-
- - - - - -
- -
- - -
- -
Готово к загрузке.
- -
-
-
После загрузки PDA viewer покажет список каналов.
-
- - -
-
- - diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index dafd5c52..bf1eb4e4 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -73,7 +73,6 @@ import * as profileEditView from './pages/profile-edit-view.js'; import * as profilesView from './pages/profiles-view.js'; import * as walletView from './pages/wallet-view.js?v=202606281930'; import * as settingsView from './pages/settings-view.js'; -import * as blockchainArchiveView from './pages/blockchain-archive-view.js'; import * as accessServersView from './pages/access-servers-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as advancedSettingsView from './pages/advanced-settings-view.js'; @@ -142,7 +141,6 @@ const routes = { 'profiles-view': profilesView, 'wallet-view': walletView, 'settings-view': settingsView, - 'blockchain-archive-view': blockchainArchiveView, 'access-servers-view': accessServersView, 'developer-settings-view': developerSettingsView, 'advanced-settings-view': advancedSettingsView, diff --git a/shine-UI/js/pages/blockchain-archive-view.js b/shine-UI/js/pages/blockchain-archive-view.js deleted file mode 100644 index 14c73909..00000000 --- a/shine-UI/js/pages/blockchain-archive-view.js +++ /dev/null @@ -1,144 +0,0 @@ -import { createTopBar } from '../components/topbar.js'; -import { authService, state } from '../state.js'; - -export const pageMeta = { id: 'blockchain-archive-view', title: 'Архив блокчейна' }; - -function text(value) { - return String(value == null ? '' : value).trim(); -} - -function buildViewerUrl(location, blockchainName, channelName, messageNumber) { - const url = new URL('/Blockchain-Viewer.html', window.location.origin); - url.searchParams.set('tx', text(location.arweaveTxId)); - url.searchParams.set('offset', String(location.chunkOffset)); - url.searchParams.set('size', String(location.chunkSize)); - url.searchParams.set('blockchain', blockchainName); - const channel = text(channelName); - if (channel) url.searchParams.set('channel', channel); - const message = text(messageNumber); - if (message) url.searchParams.set('message', message); - return url.toString(); -} - -async function copyText(value) { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(value); - return; - } - const area = document.createElement('textarea'); - area.value = value; - document.body.appendChild(area); - area.select(); - document.execCommand('copy'); - area.remove(); -} - -export function render({ navigate, chrome }) { - const screen = document.createElement('section'); - screen.className = 'stack'; - chrome?.setTopbar(createTopBar({ - title: 'Архив блокчейна', - back: { label: '←', onClick: () => navigate('settings-view') }, - })); - - const card = document.createElement('div'); - card.className = 'card stack'; - card.innerHTML = ` -
- Последний архивный блок -
Загружаю...
-
- - - - -
- - -
-
- Технические данные -

-    
- `; - screen.appendChild(card); - - const status = card.querySelector('#archive-status'); - const blockchainInput = card.querySelector('#archive-blockchain'); - const channelInput = card.querySelector('#archive-channel'); - const messageInput = card.querySelector('#archive-message'); - const linkInput = card.querySelector('#archive-link'); - const copyButton = card.querySelector('#archive-copy'); - const openButton = card.querySelector('#archive-open'); - const tech = card.querySelector('#archive-tech'); - - let location = null; - let blockchainName = ''; - - function refreshLink() { - if (!location || !blockchainName) { - linkInput.value = ''; - copyButton.disabled = true; - openButton.disabled = true; - return; - } - linkInput.value = buildViewerUrl(location, blockchainName, channelInput.value, messageInput.value); - copyButton.disabled = false; - openButton.disabled = false; - } - - channelInput.addEventListener('input', refreshLink); - messageInput.addEventListener('input', refreshLink); - copyButton.addEventListener('click', async () => { - if (!linkInput.value) return; - await copyText(linkInput.value); - status.textContent = 'Ссылка скопирована.'; - }); - openButton.addEventListener('click', () => { - if (linkInput.value) window.open(linkInput.value, '_blank', 'noopener,noreferrer'); - }); - - void (async () => { - try { - const login = text(state.session.login); - if (!login) throw new Error('Нет активного пользователя'); - const user = await authService.getUser(login); - blockchainName = text(user.blockchainName); - if (!blockchainName) throw new Error('У пользователя не найден blockchainName'); - blockchainInput.value = blockchainName; - location = await authService.getArchiveBlockchainLocation(blockchainName); - if (!text(location.arweaveTxId) || !Number.isFinite(Number(location.chunkOffset)) || !Number.isFinite(Number(location.chunkSize))) { - throw new Error('Сервер вернул неполную архивную ссылку'); - } - status.textContent = `Известна архивная история до блока ${location.sourceLastBlockNumber ?? '—'}.`; - tech.textContent = [ - `publisher: ${text(location.publisherLogin) || '—'}`, - `archive big block: ${location.bigBlockNumber ?? '—'}`, - `Arweave TX: ${text(location.arweaveTxId)}`, - `archive hash: ${text(location.archiveHash) || '—'}`, - `chunk offset: ${location.chunkOffset}`, - `chunk size: ${location.chunkSize}`, - `source last block: ${location.sourceLastBlockNumber ?? '—'}`, - ].join('\n'); - refreshLink(); - } catch (error) { - status.textContent = `Архивная ссылка пока недоступна: ${error?.message || error}`; - tech.textContent = ''; - refreshLink(); - } - })(); - - return screen; -} diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index 44614949..20ad280e 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -71,12 +71,6 @@ export function render({navigate, chrome}) { Solana, SHiNE и Arweave для публичных данных - + + +
+ + +
+ Проверка доступности +
+ + + + +
+
+ + +
Готово к проверке и загрузке.
+ + +
+
+

Текущее состояние

+

Короткая сводка по найденной записи пользователя.

+
+
+
+ Логин + — +
+
+ PDA + — +
+
+ Версия записи + — +
+
+ Блокчейн + — +
+
+ Arweave TX + — +
+
+ Последний блок + — +
+
+
+ +
+
+

Полезные ссылки

+

Ссылки формируются из текущих настроек и содержимого PDA.

+
+ +
+ +
+
+

Содержимое PDA

+

Распарсенные данные записи пользователя из программы shine_users.

+
+
Пока ничего не загружено.
+
+ +
+
+

Содержимое блокчейна

+

Viewer скачивает закреплённый блокчейн пользователя из Arweave и локально собирает каналы без сервера SHiNE.

+
+
Сначала загрузите PDA пользователя, затем нажмите «Показать содержимое».
+ +
+ + + + diff --git a/shine-solana-arweave-viewer-v2/history/v1/КАК_ЭТО_РАБОТАЕТ.md b/shine-solana-arweave-viewer-v2/history/v1/КАК_ЭТО_РАБОТАЕТ.md new file mode 100644 index 00000000..0fb69106 --- /dev/null +++ b/shine-solana-arweave-viewer-v2/history/v1/КАК_ЭТО_РАБОТАЕТ.md @@ -0,0 +1,114 @@ +# SHiNE Solana Arweave Viewer: как это работает + +## Что лежит в папке + +Основной и единственный рабочий файл viewer: + +- `index.html` + +Это автономный single-file viewer: + +- весь HTML внутри одного файла; +- все стили внутри этого же файла; +- весь JavaScript внутри этого же файла; +- локальная копия `solana-web3` тоже встроена внутрь этого же файла. + +Для запуска viewer достаточно открыть один `index.html`. + +## Что делает viewer + +Viewer открывается как обычная HTML-страница и работает полностью в браузере. + +Он умеет: + +- принимать логин пользователя SHiNE; +- принимать `Solana RPC` через форму или через URL-параметр; +- принимать `Arweave gateway` через форму или через URL-параметр; +- сохранять эти значения в `localStorage`; +- вычислять `user_pda` по логину для программы `shine_users`; +- читать `PDA` напрямую из `Solana`; +- распарсивать содержимое записи `shine_users`; +- показывать ключи, лимиты, номер записи, блокчейн пользователя и `Arweave tx id`; +- строить прямую ссылку на `Arweave`, если `tx id` есть в записи. + +## Какие URL-параметры поддерживаются + +Viewer понимает такие параметры: + +- `login` +- `solanaRpc` +- `arweaveGateway` + +Пример: + +```text +index.html?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com&arweaveGateway=https%3A%2F%2Farweave.net +``` + +## Предустановленные адреса + +### Solana RPC + +- `https://solana-rpc.publicnode.com` +- `https://public.rpc.solanavibestation.com/` + +### Arweave gateway + +- `https://arweave.net` + +## Почему одна из RPC может не работать + +Адрес: + +- `https://public.rpc.solanavibestation.com/` + +может возвращать браузерную ошибку `403` с текстом про `Origin is not whitelisted`. + +Это означает, что проблема обычно не в SHiNE viewer, а в CORS-политике самого RPC. +Для HTML-страницы, открытой прямо в браузере, это обычно не лечится со стороны клиента. + +Практически для viewer лучше использовать: + +- `https://solana-rpc.publicnode.com` + +## Ссылки на загруженный файл в Arweave + +Основная ссылка: + +- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI` + +Та же ссылка с тестовым логином `aidartest`: + +- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest` + +Та же ссылка с тестовым логином `aidartest` и явным рабочим RPC: + +- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com` + +## Что будет, если поставить devnet RPC + +Официальный публичный `Solana devnet RPC`: + +- `https://api.devnet.solana.com` + +Если подставить `devnet` RPC, viewer начнёт читать именно сеть `devnet`, а не `mainnet`. + +Но это не означает автоматически, что вся система SHiNE "стала тестовой". +Для реальной работы в `devnet` должны совпасть сразу несколько условий: + +- программа `shine_users` должна быть задеплоена в `devnet`; +- в `devnet` должен существовать нужный `PDA` для логина; +- формат записи должен совпадать; +- данные пользователя должны реально лежать в этой тестовой сети. + +Если этого нет, viewer просто не найдёт запись. + +## Источник по devnet RPC + +Официальная документация Solana: + +- `https://solana.com/docs/references/clusters` + +Там указан devnet endpoint: + +- `https://api.devnet.solana.com` diff --git a/shine-solana-arweave-viewer-v2/index.html b/shine-solana-arweave-viewer-v2/index.html new file mode 100644 index 00000000..9a598cc7 --- /dev/null +++ b/shine-solana-arweave-viewer-v2/index.html @@ -0,0 +1,3231 @@ + + + + + + SHiNE Arweave Viewer v2 — Frame v1 + + + + + +
+
+ + + + + +
+ +
+ + +
+ +
Готово к загрузке.
+ +
+
+
Viewer читает новый формат Arweave: ?login=...&channel=... или ?key=...&channel=...
+
+ + +
+
+ + diff --git a/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md b/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md new file mode 100644 index 00000000..652117f7 --- /dev/null +++ b/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md @@ -0,0 +1,39 @@ +# Как работает Viewer v2 + +Viewer рассчитан на новый архив SHiNE: **один пользовательский блок = один ANS-104 DataItem**. + +### Режим 1: login + channel + +`?login=&channel=` + +Viewer читает Solana PDA пользователя, получает `blockchainKey`, затем ищет Arweave DataItem этого владельца с тегами `App=test5590` и `c=`. + +### Режим 2: key + channel + +`?key=&channel=` + +Viewer пропускает Solana и сразу выполняет поиск в Arweave. + +### Режим без channel + +`?login=` или `?key=` + +Viewer получает все доступные DataItem владельца с `App=test5590`, разбирает их и показывает восстановленные каналы. + +### Новый блок + +Payload DataItem: + +```text +uint16be frameCode = 0x0001 +bytes[32] prevHash = SHA-256(previous Frame v1) +int32be blockSize +int32be blockNumber +int64be timestamp seconds +uint16be type +uint16be subType +uint16be version +bytes[N] body +``` + +Подпись пользователя, owner и tags находятся в оболочке ANS-104 DataItem, а не в SHiNE Frame v1. From 8b12760dde3dd6df7d67968ee23a3e55d8e074b062775d1870b5cb9f51fa9f6b Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 22 Sep 2026 14:09:02 +0300 Subject: [PATCH 28/30] =?UTF-8?q?=D0=94=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD=20?= =?UTF-8?q?=D0=90=D1=80=D1=82=D1=91=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- docs/UI-Design/DESIGN.md | 290 +++ docs/UI-Design/IMPLEMENTATION.md | 46 + docs/UI-Design/README.md | 41 + docs/UI-Design/VALIDATION.md | 35 + docs/UI-Design/channels-v1/index.html | 34 + docs/UI-Design/channels-v1/mockups.css | 212 ++ docs/UI-Design/channels-v1/mockups.js | 246 +++ shine-UI/channel-design-check.mjs | 314 +++ shine-UI/index.html | 19 +- shine-UI/js/app.js | 5 + .../components/arweave-attachment-manager.js | 5 + shine-UI/js/components/channel-editor.js | 363 ++++ shine-UI/js/components/dropdown-menu.js | 18 +- shine-UI/js/components/message-menu.js | 12 + shine-UI/js/components/toolbar.js | 23 +- shine-UI/js/components/ui-icon.js | 14 + shine-UI/js/pages/channel-thread-view.js | 311 ++- shine-UI/js/pages/channel-view.js | 432 ++--- shine-UI/js/pages/channels-list.js | 149 +- shine-UI/js/pages/settings-view.js | 14 + shine-UI/js/services/channel-view-state.js | 10 + shine-UI/js/services/theme-service.js | 49 + shine-UI/styles/app-shell.css | 17 +- shine-UI/styles/buttons-white.css | 141 +- shine-UI/styles/components/attachments.css | 351 +++- shine-UI/styles/components/avatar.css | 7 + shine-UI/styles/components/channel-editor.css | 219 +++ shine-UI/styles/components/dropdown-menu.css | 157 +- shine-UI/styles/components/modal.css | 192 +- shine-UI/styles/components/primitives.css | 96 +- shine-UI/styles/components/tabs.css | 14 +- shine-UI/styles/components/toolbar.css | 330 +--- shine-UI/styles/components/topbar.css | 245 +-- shine-UI/styles/features/channel-thread.css | 254 +-- shine-UI/styles/features/channel.css | 907 +-------- shine-UI/styles/features/channels-common.css | 475 +---- shine-UI/styles/features/channels-list.css | 1724 +++-------------- shine-UI/styles/features/chat.css | 281 --- shine-UI/styles/main.css | 68 +- 40 files changed, 3114 insertions(+), 5008 deletions(-) create mode 100644 docs/UI-Design/DESIGN.md create mode 100644 docs/UI-Design/IMPLEMENTATION.md create mode 100644 docs/UI-Design/README.md create mode 100644 docs/UI-Design/VALIDATION.md create mode 100644 docs/UI-Design/channels-v1/index.html create mode 100644 docs/UI-Design/channels-v1/mockups.css create mode 100644 docs/UI-Design/channels-v1/mockups.js create mode 100644 shine-UI/channel-design-check.mjs create mode 100644 shine-UI/js/components/channel-editor.js create mode 100644 shine-UI/js/components/message-menu.js create mode 100644 shine-UI/js/components/ui-icon.js create mode 100644 shine-UI/js/services/channel-view-state.js create mode 100644 shine-UI/js/services/theme-service.js create mode 100644 shine-UI/styles/components/channel-editor.css diff --git a/VERSION.properties b/VERSION.properties index b922a061..d022c6f1 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.21 +client.version=1.12.22 server.version=1.10.8 diff --git a/docs/UI-Design/DESIGN.md b/docs/UI-Design/DESIGN.md new file mode 100644 index 00000000..8ef6eca7 --- /dev/null +++ b/docs/UI-Design/DESIGN.md @@ -0,0 +1,290 @@ +# SHiNE — дизайн приложения + +Версия 0.2 · 22 сентября 2026. + +Статус: первый перенос списка каналов, ленты, ветки и общего редактора выполнен в рабочем дереве. Состав изменений, автоматические проверки и оставшаяся визуальная приёмка описаны в [IMPLEMENTATION.md](IMPLEMENTATION.md). Точные размеры и поведение на устройствах остаются целями приёмки, а не утверждением о выполненном тестировании на телефонах. + +Этот файл — актуальный источник решений по дизайну. Галерея `channels-v1` остаётся историей четырёх исходных вариантов: она ещё не показывает итоговое сочетание двух тем, компактной шапки и полноэкранного ответа. При расхождении руководствоваться этим документом. + +## 1. Задача и границы + +Сделать общение понятным с первого открытия. Начинаем со списка каналов, ленты канала, ветки обсуждения и мобильного редактора ответа. Затем распространяем выбранную систему на личные сообщения, связи, уведомления, профиль, поиск, создание каналов и настройки. + +Основа — предоставленные пользователем скриншоты Threads и SHiNE. Заимствуем знакомые расположения и сценарии, сохраняя особенности продукта. Этот документ не утверждает новые возможности API и не меняет правила записи, реакции или блокчейна. + +Исходные направления представлены в [HTML-галерее](channels-v1/index.html). Выбрана одна система интерфейса с двумя темами: композиция Ночного SHiNE, ночная палитра из него же, дневная палитра из Тёплого SHiNE. Шапка канала компактная по образцу Компактного клуба; редактор ответа полноэкранный, как у Компактного клуба / Чистого монохрома. + +Тема меняет цвета, но не геометрию, навигацию, типографику или порядок действий. Из Тёплого SHiNE берём палитру, а не карточную компоновку, увеличенные заголовки Georgia и редактор-лист. + +## 2. Принципы для всего приложения + +1. Содержимое важнее оформления: текст без свечения, минимум декоративных рамок, спокойные поверхности. +2. Один акцентный цвет обозначает активный раздел, главное действие и выбранное состояние. +3. Главное действие доступно без прокрутки до конца. Его содержание зависит от роли и текущего экрана. +4. Всегда видно, где пользователь находится и кому отвечает. Возврат сохраняет позицию списка или ленты. +5. Редкие и технические действия помещаются в меню, но остаются доступными. +6. Пустота, загрузка и ошибка — полноценные состояния. Ошибка не уничтожает введённый текст. +7. Метки, иконки и расположение действий повторяются во всех разделах. +8. Не выдавать отсутствующую функцию за существующую. В частности, не добавлять репост, GIF-поиск или сортировку по популярности только потому, что они есть в референсе. + +## 3. Принятые решения + +| Решение пользователя | Правило для реализации | +| --- | --- | +| Основа — Ночной SHiNE | Плоская широкая лента, сдержанные поверхности, единые линейные иконки | +| Дневной и ночной режим сразу | Оба режима обязательны для каждого переносимого компонента и состояния | +| Дневные цвета — из Тёплого | Кремовые поверхности, тёмный тёплый текст, янтарно-коричневый акцент | +| Шапка сообщений — компактнее, как в Клубе | Панель 56 px, без большого описания и декоративного баннера перед лентой | +| Ответ — как у Клуба / Монохрома | Полноэкранный мобильный редактор, короткий контекст, основное место отдано вводу | +| Лайки — общее количество | Один счётчик. Типы лайков и их статистика не входят в первый перенос | +| Статистика позднее, возможно долгим тапом | Возможное будущее взаимодействие, не обязательство реализовать жест сейчас | +| Оглавление используется редко | Только в меню канала, без отдельной кнопки в шапке или под описанием | +| Плотность мокапов подходит | Сохраняем ритм Ночного SHiNE; уплотняем шапку, не всю ленту до варианта Клуба | + +Эти решения приняты 22.09.2026 в обсуждении с пользователем. Повторно выбирать направление не требуется. + +## 4. Базовые параметры + +Ниже единые размеры для обеих тем. Референс плотности — Ночной SHiNE. Не переносить мелкие служебные подписи галереи буквально: текст остаётся читаемым на телефоне. Размеры задавать через общие токены с поддержкой системного увеличения шрифта; фиксированные высоты ниже являются минимальными, если тексту требуется больше места. + +| Параметр | Целевое значение | +| --- | --- | +| Основной текст публикации / ответа | 15 px / 23 px; редактор ввода 16 px / 24 px | +| Имя автора | 14 px / 20 px, вес 600 | +| Вторичная информация | 12 px / 18 px | +| Заголовок корневого экрана | 24 px / 30 px, вес 600 | +| Название канала в компактной панели | 17 px / 22 px, вес 600 | +| Заголовок обычной модальной панели / редактора | 16 px / 24 px, вес 600 | +| Шкала отступов | 4 / 8 / 12 / 16 / 24 / 32 px | +| Горизонтальные поля телефона | 16 px; между аватаром и текстом 10–12 px | +| Вертикальный ритм | Публикация: 16 px сверху, 8 px снизу; абзацы: 8–12 px; строка канала: 14 px сверху и снизу | +| Аватар | 36 px в ленте; 48 px в списке; 32 px у ответа; 40 px в редакторе | +| Область нажатия | Не менее 44 × 44 px для всех действий, включая меню, закрытие, лайки и вкладки | +| Иконки | Единая линейная система, 20–24 px, одинаковая толщина штриха | +| Скругления | Поле/файл/кнопка 12 px; медиа 14 px; меню-лист 20 px; полноэкранный ответ без скругления внешнего края | +| Контраст текста | Цель WCAG AA; проверить утверждённую палитру | +| Анимация | 150–200 мс, отключение при reduced motion | + +### 4.1. Семантические цвета двух тем + +Использовать роли, а не прямые цвета внутри страниц. Имена ниже описывают контракт дизайна; при реализации сопоставить их существующей системе переменных, не создавать параллельную систему токенов. Основная палитра взята из выбранных мокапов; статусные цвета — дополнение спецификации. + +| Роль | Ночь | День | Где используется | +| --- | --- | --- | --- | +| `background` | `#101B20` | `#FAF7F0` | Основной фон, шапка и навигация | +| `surface` | `#17272D` | `#FFFDF8` | Поле поиска, меню, карточка файла | +| `surface-selected` | `#213B3A` | `#EFE6D6` | Выбранная вкладка, лёгкое выделение | +| `text-primary` | `#E8F2F1` | `#302C25` | Основной текст | +| `text-secondary` | `#98ADAF` | `#706658` | Время, авторские идентификаторы, подсказки | +| `border-subtle` | `#2A3B40` | `#E6DFD1` | Декоративное разделение строк | +| `border-control` | `#71888A` | `#978B79` | Граница поля, если нужна для распознавания контроля | +| `accent` | `#94E1CE` | `#93511E` | Главное действие, ссылки, активная навигация | +| `on-accent` | `#102C27` | `#FFFAF3` | Текст на залитой акцентом кнопке | +| `focus-ring` | `#94E1CE` | `#93511E` | Контур клавиатурного фокуса | +| `reaction-active` | `#F29AAB` | `#A43350` | Сердце выбранного лайка, не системная ошибка | +| `danger` | `#FFABA8` | `#B13135` | Ошибка, подтверждение удаления | +| `success` | `#94E1CE` | `#346A48` | Успешное действие, с текстовым пояснением | +| `warning` | `#E6C184` | `#845B14` | Предупреждение, с текстовым пояснением | +| `scrim` | `#00000099` | `#302C2566` | Затемнение под меню/диалогом | + +Шрифт системный sans-serif в обеих темах: `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`. Georgia из тёплого концепта не переносить. Не использовать свечение текста и цветные тени кнопок. Допустима лёгкая тень у всплывающего меню, но не у каждой публикации. + +Небольшая коррекция исходной дневной палитры: вторичный текст затемнён с `#7B7264` до `#706658`. Расчёт контраста исходного цвета на основном фоне дал 4.43:1, на выбранной поверхности — 3.83:1. Новый цвет даёт соответственно 5.26:1 и 4.55:1, сохраняя тёплый характер. Это уточнение для читаемости, а не смена выбранной палитры. + +`border-subtle` не служит единственным указателем поля или выбранного состояния. Контраст проверять для конкретных пар: обычный текст ≥ 4.5:1; значимые иконки/границы и фокус ≥ 3:1. Не снижать контраст вторичного текста общей прозрачностью контейнера. Disabled обозначать одновременно неактивностью и стилем; ожидаемое действие пояснять при необходимости. + +### 4.2. Выбор темы + +Рабочее решение для реализации: настройка «Оформление» с пунктами «Как на устройстве», «Дневное», «Ночное». По умолчанию — как на устройстве. Явный выбор сохраняется локально и имеет приоритет; изменение системной темы применяется только в режиме «Как на устройстве». Это проектное допущение, пользователь отдельно не выбирал способ переключения. + +Обновление темы не перезагружает экран, не закрывает редактор и не сбрасывает черновик или прокрутку. Выбранный режим применяется до первой видимой отрисовки, без вспышки противоположного фона. Все модальные окна, меню, системные поля и состояния используют ту же тему. Фотографии и аватары не инвертируются. Не нужна постоянная кнопка солнца/луны в тесной шапке канала. + +## 5. Список каналов + +Верх: заголовок «Каналы», видимая кнопка создания, поиск. Фильтры «Все», «Подписки», «Мои» вынесены из выпадающего заголовка на поверхность. + +Строка: аватар, название, автор/идентификатор при необходимости, однострочное превью, время, число непрочитанных. Длинное название сокращается в строке, полное доступно внутри канала. На карточке не нужно одновременно показывать описание, технический путь, общее число сообщений и длинное превью. + +Поиск в прототипе фильтрует примеры по названию и автору. В текущем приложении серверный поиск начинается с логина/начала логина автора: полноценный глобальный поиск по названию здесь является предложением, а не обещанием текущего API. При реализации можно сохранить существующую семантику с подсказкой «Найти по @автору». + +Нижняя навигация сохраняет пять существующих разделов: «Личные», «Каналы», «Связи», «Уведомления», «Профиль». Иконки приводятся к одной системе. Высота рабочей зоны 64 px плюс нижний safe area; иконка 22 px, подпись 11 px. Активный пункт отмечается акцентом и мягкой подложкой под иконкой. При крупном системном шрифте панель может увеличивать высоту; нельзя уменьшать шрифт, скрывать смысловые подписи или обрезать «Уведомления» ради фиксированной высоты. + +Не выделять первую строку автоматически только потому, что она первая. Непрочитанное отмечается счётчиком и более заметным превью, выбранное — фактическим состоянием выбора. Просмотр канала и подписка — разные действия; открытие строки не подписывает пользователя автоматически. + +## 6. Канал и сообщения + +### 6.1. Компактная шапка + +Верхняя панель высотой 56 px плюс верхний safe area: возврат 44 px, растягивающийся блок названия, уведомления 44 px, меню 44 px. Внутри блока — название 17/22 px и вторичная строка `@автор` 12/16 px. Название в одну строку с многоточием; полное название, идентификатор и описание доступны в «О канале». На очень узком экране и при крупном шрифте уведомления переносятся в меню, чтобы сохранить место названию. Название канала открывает информацию о канале. + +Под панелью сразу начинается лента. Убрать большой блок описания, декоративный градиентный баннер и постоянно видимый статус «Вы подписаны». Описание и управление подпиской — в меню/информации о канале. Для неподписанного читателя допустима компактная строка «Подписаться» над лентой; после подписки она исчезает. Метка «Новые сообщения» занимает одну строку и появляется только при наличии непрочитанного. + +Порядок меню канала: «О канале», «Оглавление», настройка уведомлений, действие подписки/отписки, затем доступные владельцу действия. Пункты показываются по реальным правам. Оглавление не дублируется в шапке, под описанием или в строке действий публикации. При пустом оглавлении показать «В этом канале пока нет оглавления»; создание доступно владельцу только через существующий сценарий. + +### 6.2. Публикация + +Публикация: автор, время, меню, текст, вложение и действия. Порядок действий — лайк, открыть обсуждение, поделиться; явное «Ответить» открывает редактор. Это разделяет чтение ветки и написание ответа, сейчас они завязаны на разные области карточки. + +У лайка сейчас только общий счётчик `likesCount`: не суммировать его повторно с подкатегориями. Один тап ставит/снимает лайк; выбранное состояние — заполненное сердце и `reaction-active`. Повторное действие во время запроса блокируется, при ошибке состояние и число восстанавливаются с коротким пояснением. Нулевой счётчик можно показывать как `0`, одинаково во всех разделах. У кнопки есть доступное название и состояние, например «Нравится, 24» и `aria-pressed`. + +Статистику типов не показывать ни на панели, ни в меню первого переноса. Долгий тап по лайкам — отложенная идея пользователя; порог жеста, содержимое и доступную альтернативу согласовать позднее. Если жест появится, ему понадобится явный пункт меню для клавиатуры и пользователей, которые не знают о долгом тапе. Сейчас не добавлять неработающую подсказку или заглушку для этого жеста. + +«Данные блокчейна», доступная история, редактирование и удаление помещаются в меню. Редактирование и удаление доступны только для собственных сообщений; удаление требует понятного подтверждения с описанием реального эффекта существующего протокола. Отдельные оценки не возвращаются на панель действий: в коде они намеренно скрыты. + +Кнопка публикации закреплена снизу у владельца. У читателя в ленте нет поля, которое обещало бы писать непосредственно в чужой канал. Ответ доступен под публикацией, а на экране ветки — также в нижнем поле. Для неподписанного пользователя нужна заметная подписка, для гостя — корректное приглашение войти перед действием. + +Вложения: компактная карточка файла с названием, типом и размером; изображения — в ширину текста с ограничением высоты. У галереи нужен индикатор количества. Реальные превью, загрузка и ошибки файлов проектируются отдельно; изображение в мокапе — встроенная SVG-иллюстрация без внешних ресурсов. + +## 7. Ветка обсуждения + +Исходное сообщение остаётся наверху, ниже — счётчик и ответы в последовательном порядке. Не предлагать «Популярные» до подтверждения алгоритма и источника данных. + +Для вложенных ответов показывать «В ответ …» и короткий контекст. Не уменьшать ширину текстовой колонки бесконечными отступами: после одного уровня показывать связь подписью или открывать подветку, используя имеющуюся маршрутизацию. Тонкая линия допустима только для обозначения реальной связи, не вокруг каждого комментария. Плотность ответов сохраняется близкой к Ночному SHiNE; выбор полноэкранного редактора из Клуба не означает переноса всей его плотности. + +Возврат ведёт в тот же канал и на прежнюю позицию. После отправки новый ответ появляется в ветке; при ошибке остаются текст и вложения. Длинные ветки потребуют отдельной проверки пагинации, состояния загрузки и перехода к конкретному ответу. + +## 8. Мобильный редактор ответа + +Полноэкранный мобильный редактор в обеих темах — принятое решение. Он закрывает нижнюю навигацию и занимает доступную область приложения; не является маленькой центральной модалкой или частично открытым листом. Нет ручки перетаскивания, переключателя разворачивания и декоративного верхнего отступа. + +Верхняя панель 56 px: закрытие 44 px слева, заголовок «Ответ» по центру; место справа не заполнять лишними действиями. Ниже — короткий контекст исходного сообщения, собственный аватар, поле и вложения. Нижняя панель: «Прикрепить», счётчик и кнопка «Ответить». Для нового сообщения тот же редактор с заголовком «Новое сообщение» и кнопкой «Опубликовать», без цитаты. + +При первом открытии показать адресата и цитату максимум в две строки плюс «Показать целиком» при обрезке. Не повторять весь исходный пост с изображением в редакторе. Для ответа на файл без текста показать название/тип вложения. Раскрытая цитата находится в прокручиваемой области и не вытесняет навсегда поле. Адресат берётся из выбранного сообщения, а не всегда из автора канала. + +Поле ввода занимает всё оставшееся свободное место, растёт вместе с текстом в общей прокручиваемой области. Шапка и нижняя панель остаются видимыми. При открытии по нажатию фокус переходит в поле; курсор при восстановлении черновика остаётся в тексте. Системная клавиатура не должна перекрывать отправку: учитывать visual viewport, поворот устройства и безопасные отступы. Не рисовать свою клавиатуру в приложении — иллюстрация есть только в галерее. + +Крестик, системный возврат и Escape закрывают редактор с одинаковой политикой черновика. Возврат восстанавливает место в обсуждении и фокус инициирующей кнопки. На desktop редактор может быть центрированной панелью шириной до 640 px и высотой до доступного viewport с теми же тремя зонами; на телефоне всегда полный экран. + +Enter создаёт новую строку. Отправка — по явной кнопке; Ctrl/Cmd+Enter можно поддержать как дополнительное desktop-действие с подсказкой. Не отправлять во время IME/composition. В текущем редакторе есть обработчик plain Enter, поэтому это намеренное изменение поведения, которое нужно отдельно учитывать при переносе. + +Состояния отправки: + +- Пустой текст без вложений: кнопка неактивна. +- Есть текст или вложение: отправка доступна; лимит текста 2000 символов сохранён из текущего редактора ответа. +- Загрузка вложения: прогресс, возможность отмены, запрет отправлять незавершённое вложение. +- Отправка: индикатор, блокировка повторного нажатия. +- Ошибка: понятная причина, повтор, сохранённый текст. +- Успех: новый ответ и восстановленный контекст обсуждения. +- Закрытие: черновик остаётся; очистка по отдельному явному действию, не по Escape. + +В исходной HTML-галерее воспроизведены пустое/заполненное поле, лимит, выбранное вложение, закрытие с черновиком и локальная успешная отправка. Загрузка/ошибка сети и прогресс — требования будущей реализации. Счётчик 2000 относится к ответу согласно просмотренному коду; лимиты публикаций и вложений брать из соответствующих существующих валидаторов, не вводить общий лимит для всех сообщений только по этому документу. + +Черновик в приложении изолировать ключом «аккаунт + канал + идентификатор исходного сообщения + режим редактора». Никогда не переносить текст ответа на другой пост молча. В первом переносе достаточно сохранения при закрытии/возврате в рамках текущей сессии интерфейса. Переживание перезагрузки/закрытия приложения не обещать, пока не выбраны хранение и политика очистки. При выходе из аккаунта его черновики не показываются другому пользователю. + +## 9. Что подтверждено кодом + +| Возможность | Где прочитано | Решение макета | +| --- | --- | --- | +| Все / свои / подписки; поиск; создание | `shine-UI/js/pages/channels-list.js`, `render`, `openChannelFinderModal` | Показать поиск и фильтры явно | +| Непрочитанные, превью, автор и время | Там же, построение строк списка | Сохранить иерархию без перегрузки | +| Публикация владельцем | `shine-UI/js/pages/channel-view.js`, ветка `isOwnChannel` | Закреплённое действие только для роли владельца | +| Подписка и оглавление | `channel-view.js` | Сделать доступными в информации о канале | +| Лайки трёх категорий, ответ, отправка ссылки | `channel-view.js`, построение действий сообщения | Только общий счётчик; детализация отложена; ветка отдельно от ответа | +| Технические данные и редактирование | `channel-view.js` | Перенести в меню, учитывать авторство | +| Оценки временно скрыты | Комментарий возле действий сообщения в `channel-view.js` | Не возвращать без решения продукта | +| Ответы и вложения; 2000 символов | `openReplyModal` в `channel-view.js` и `channel-thread-view.js` | Мобильный редактор с контекстом | + +Просмотр кода был целевым, не полным аудитом. Права всех типов каналов, статусы пользователей, истории, специальные подтипы и поведение гостя нужно проверить перед реализацией. Переключатель роли в галерее предназначен для оценки размещения действий, не моделирует авторизацию. + +## 10. Состояния для следующей итерации + +| Область | Нужные дополнительные макеты | +| --- | --- | +| Список | Пустые подписки; первый запуск; загрузка; ошибка; нет результатов | +| Канал | Гость; неподписанный; владелец; пустой канал; недоступный канал; очень длинное название | +| Сообщение | Длинный текст; несколько изображений; файл без текста; удалённое; изменённое; специальный подтип | +| Ветка | Нет ответов; много ответов; вложенная ветка; переход по ссылке | +| Ответ | Длинная цитата; ошибка отправки; загрузка файла; черновик; клавиатура; возврат фокуса | +| Доступность | Клавиатура; screen reader; крупный шрифт; контраст; reduced motion | + +## 11. Общие компоненты для единообразия + +| Компонент | Единое правило | +| --- | --- | +| TopBar | Общие высота, поля, возврат и меню. Большой заголовок только у корневого экрана, компактный у вложенного | +| Primary button | Заливка `accent`, текст `on-accent`, высота 44 px, радиус 12 px; одно главное действие в области | +| Secondary button | Поверхность/контур, без конкуренции с primary; тот же размер текста и зона нажатия | +| Icon button | Иконка 22 px внутри зоны 44 px, доступное название; одинаковые pressed/focus/disabled | +| Field | Метка по смыслу, текст 16 px, вспомогательная подпись и ошибка под полем; placeholder не заменяет метку | +| Search | Небольшое поле с лупой; очистка видна при непустом запросе; отдельно загрузка, отсутствие результатов и ошибка | +| Tabs | Одинаковый selected-стиль, минимум 44 px по высоте; не создавать страницу со своей системой вкладок | +| Avatar | Круглый, устойчивый размер; fallback с инициалами; выбранная тема не перекрашивает фотографию | +| List row | Аватар/иконка → основной и вторичный текст → время/счётчик; вся строка открывает объект | +| Overflow menu | Те же отступы и строки 44 px; на телефоне допустим общий лист, на desktop привязка к кнопке | +| Attachment | Единая карточка имени/типа/размера, отдельные состояния загрузки и ошибки; медиа не растягивает страницу по ширине | +| Empty state | Короткий заголовок, причина/следующий шаг, одно уместное действие; без большого декоративного баннера | +| Inline error | Возле неудавшегося действия, понятный текст и повтор; технические подробности раскрываются отдельно | +| Toast | Короткое подтверждение без перехвата фокуса; не единственный способ сообщить о блокирующей ошибке | +| Confirmation | Называет действие и объект; основной акцент для обычного действия, `danger` для удаления | +| Fullscreen editor | Один визуальный контракт публикации/ответа/редактирования с вариантами заголовка и контекста | + +Каждый компонент определяет normal, hover для указателя, pressed, focus-visible, disabled, loading и error, если они применимы. Selected и pressed различать: selected сохраняется после нажатия, pressed — краткий отклик. Выбор и ошибку не кодировать только цветом. + +Для клавиатуры соблюдать логичный порядок фокуса; у диалогов — семантика dialog, фокус внутри и возврат инициатору. Screen reader получает названия и состояния переключателей, общий счётчик лайков и информацию об отправке. Обновление ленты не должно каждый раз перехватывать фокус или зачитывать весь экран. + +### 11.1. Общая адаптация + +Телефоны 320–430 CSS px: одна колонка, боковые поля 16 px, никакого горизонтального скролла страницы. Горизонтальная прокрутка допустима только внутри явно обозначенной галереи вложений. Длинные слова/ссылки переносятся; имена и заголовки сокращаются только там, где предусмотрен доступ к полному тексту. + +При ширине 600 px и выше текстовая колонка не шире 640 px, центрируется; верхняя панель, лента и composer выровнены по её краям. Desktop-навигация использует существующий shell. Новую многоколоночную архитектуру приложения этот документ не требует. + +Safe area учитывается ровно один раз владельцем оболочки. При открытом редакторе нет двойной нижней панели. При увеличении текста до 200% допускается рост высот; кнопки не накладываются, действия не исчезают. Анимация открытия 150–200 мс; при reduced motion переход без движения. + +### 11.2. Применение к остальным разделам + +| Раздел | Что наследует | Что требует отдельного проектирования | +| --- | --- | --- | +| Личные сообщения | Палитры, текст, аватары, строки списка, вложения, поля и меню | Геометрия диалога, входящие/исходящие, статусы доставки/прочтения, звонки | +| Связи | Строки людей, профильные аватары, кнопки, пустые состояния | Запросы, типы отношений и правила доступности | +| Уведомления | Строки, типографика, счётчики, непрочитанное | Группировка, переход к объекту, состояния удалённого объекта | +| Профиль | TopBar, разделители, поля, кнопки и обе темы | Состав профиля, редактирование, настройки оформления | +| Вход/регистрация | Цвета, поля, ошибки и focus-стили | Чувствительные сценарии, последовательность шагов, тексты восстановления | +| Создание/настройка канала | Общие формы, редакторы, меню и подтверждения | Права, типы канала и специфические настройки | + +Наследование визуальной системы не означает изменения протоколов или применения ленты каналов к личному диалогу. Каждый следующий раздел добавляет свои сценарии в отдельный документ со ссылкой на эту основу, а не копирует собственные палитры и наборы кнопок. + +## 12. Допущения и отложенные решения + +Блокирующих вопросов для документа нет. Рабочие решения: тема по системе с ручным выбором, размеры из раздела 4, сохранение черновика внутри текущей сессии, единый редактор с адаптацией для desktop. + +До переноса проверить по коду матрицу прав всех типов каналов, доступность ответа для гостя/неподписанного, ограничения вложений и существующие переменные темы. Если для предложения нужны новые серверные данные, явно отметить зависимость и сохранить доступный сценарий; не расширять API автоматически. + +Позднее: статистика типов лайков и долгий тап; глобальный поиск по названию; постоянные черновики; подробные сценарии остальных разделов. Эти пункты не являются задачами текущего переноса. + +## 13. Перенос в приложение и критерии готовности + +На следующем этапе использовать существующие AppShell/TopBar/Dropdown/Avatar/Composer. Не копировать автономный JS из мокапа в production: он работает на демонстрационных данных и не реализует lifecycle приложения. + +Последовательность: сопоставить текущие токены и владельцев компонентов → подготовить обе палитры → реализовать общие варианты компонентов → перенести список/канал/ветку/ответ → закрыть состояния из раздела 10 → проверить устройства и темы → расширять на другие разделы по отдельным задачам. Перед реализацией удобно обновить автономный мокап выбранного сочетания; исходную галерею сохранять как историю выбора. + +Матрица приёмки для первого переноса: + +| Проверка | Ожидаемый результат | +| --- | --- | +| День ↔ ночь на каждом экране | Геометрия совпадает; нет чужих тёмных/светлых элементов и вспышки при старте | +| Переключение темы с открытым ответом | Текст, вложения, адресат, фокус и позиция не сбрасываются | +| Шапка канала | Компактная; нет баннера описания и кнопки оглавления вне меню | +| Роль читателя/владельца/гостя | Только доступные действия; нет ложного обещания писать в чужой канал | +| Лайки | Один общий счётчик; постановка/снятие; защита от повторного запроса; восстановление после ошибки | +| Оглавление | Доступно через меню, включая пустое состояние | +| Ответ на пост и ответ на комментарий | Верный адресат и цитата; полноэкранный редактор; отправка видна над клавиатурой | +| Длинный ответ и Enter | Перенос строки без отправки; поле и курсор доступны при прокрутке | +| Черновик | Закрытие/возврат сохраняет текст в текущей сессии; другой адресат не получает чужой текст | +| Вложения | Текст или вложение; прогресс/ошибка; отправка незавершённого файла недоступна | +| Навигация | Возврат сохраняет позицию; отправленный ответ виден; обновление не пересоздаёт страницу целиком | +| Загрузка/ошибка/пустота | Понятные отдельные состояния; введённые данные не теряются | +| 320 / 390 / 430 px, большой экран, 200% текст | Нет наложений и горизонтальной прокрутки страницы; все действия достижимы | +| Android / iOS, экранная клавиатура, поворот | Поле и отправка доступны; safe area не дублируется | +| Клавиатура / screen reader / reduced motion | Фокус видим и возвращается; семантика доступна; движение отключается | + +Все четыре экрана проверяются в обеих темах, включая меню, редактор, ошибку и пустое состояние. Тесты поведения не заменяют визуальную проверку на телефоне. + +Документ служит общим справочником приложения. Новые разделы описывают сценарии, владельцев компонентов, состояния и принятые решения, сохраняя принципы разделов 2, 4 и 11. Обнаруженные при реализации ограничения записывать как явные отклонения с причиной, а не незаметно создавать новый стиль страницы. + +## 14. Журнал решений + +| Дата | Версия | Решение | +| --- | --- | --- | +| 22.09.2026 | 0.1 | Четыре самостоятельных HTML-направления для обсуждения | +| 22.09.2026 | 0.2 | Пользователь выбрал Ночной SHiNE, дневную тёплую палитру, компактную шапку, полноэкранный ответ; общее число лайков, оглавление в меню; плотность принята | +| 22.09.2026 | 0.2 | Специфицированы семантические цвета двух тем, общие компоненты, состояния, адаптация, правила расширения и матрица приёмки. Это документация будущего переноса, не выполненное изменение приложения | diff --git a/docs/UI-Design/IMPLEMENTATION.md b/docs/UI-Design/IMPLEMENTATION.md new file mode 100644 index 00000000..dd69ed92 --- /dev/null +++ b/docs/UI-Design/IMPLEMENTATION.md @@ -0,0 +1,46 @@ +# Первый перенос дизайна каналов + +22 сентября 2026. Изменения в рабочем дереве; без коммита и деплоя. Основа — [DESIGN.md](DESIGN.md), версия 0.2. + +## Реализовано + +- Ночная и дневная семантические палитры; системный режим по умолчанию, ручной выбор в настройках, применение до первой отрисовки. Смена темы не перемонтирует страницу. +- Список каналов: плоские строки, превью, время и непрочитанное; видимые фильтры «Все / Подписки / Мои», создание и поиск. Поле фильтрует загруженный список; «По @автору» открывает существующий серверный поиск. +- Лента: компактная шапка, оглавление в меню, плоские публикации, общий лайк без промежуточного диалога, отдельные обсуждение и ответ. Технические данные, история и собственные редактирование/удаление — в меню. Удаление подтверждается с предупреждением о сохранении истории в блокчейне. +- Ветка: единый вид публикаций и ответов, контекст вложенного ответа без растущих горизонтальных отступов. Порядок данных и существующие пределы загрузки не менялись. +- Общий редактор публикации/ответа/редактирования: полный мобильный экран, desktop-панель до 640 px, три зоны, VisualViewport и safe area. Контекст ответа, растущее поле, вложения, счётчик, отправка, ошибка и очистка черновика. Enter — новая строка, Ctrl/Cmd+Enter — отправка, IME защищён. +- Черновики в памяти текущей вкладки, изолированные по аккаунту, цели и режиму. Для ответа цель — полная ссылка на сообщение (blockchain/номер/hash); один и тот же ответ восстанавливается из ленты и ветки. Тип новой публикации сохраняется вместе с текстом. После перезагрузки черновики не обещаются. +- Закреплённое действие публикации у владельца; закреплённый ответ в ветке. Гостевые действия обозначают необходимость входа. Позиции чтения сохраняются внутри текущей вкладки отдельно по аккаунту и экрану. +- Общие TopBar, Dropdown, Tabs, Avatar и Toolbar приведены к выбранному направлению. Стили общей галереи вложений перенесены из `features/chat.css` к владельцу `components/attachments.css`; логика DM и формат вложений не менялись. + +## Проверено автоматически + +Проверка: `shine-UI/channel-design-check.mjs`. Она исполняет настоящий DOM producer страниц и компонентов в JSDOM; транспорт/API изолированы, тест ничего не публикует. + +- Редактор: пустое состояние, Enter и Ctrl+Enter, IME, фокус/Tab/возврат инициатору, восстановление черновика, разделение аккаунтов, очистка после успеха, сохранение после ошибки, вложение, системный возврат и гостевой вход. +- Карточки канала/ветки: текст, общий лайк, контекст ответа, меню по авторству, повторный cleanup. +- Список/канал/ветка: успешная пустая загрузка, сохранение корневого элемента при refresh, идемпотентный cleanup, отсутствие DOM-изменений от позднего ответа API после dispose. +- Темы, сохранение позиции чтения, навигация Dropdown клавишами и возврат фокуса. +- `node --check` изменённых production JS, разрешение относительных импортов всех 135 JS, парсинг 17 изменённых CSS, отсутствие новых повторов селекторов внутри одного CSS-контекста и роста `!important`, корректность CSS manifest, `git diff --check`. +- Расчёт контраста токенов: основной текст/фон 15.34:1 ночью и 12.98:1 днём; вторичный текст/фон 7.45:1 и 5.26:1; вторичный текст/поверхность 6.55:1 и 5.54:1; текст основной кнопки/акцент 9.86:1 и 5.88:1. Это расчёт палитры, не полный аудит итогового CSS-cascade. + +Воспроизводимый запуск из корня продукта (Node 24): + +```bash +check_dir=$(mktemp -d) +npm install --prefix "$check_dir" --no-audit --no-fund --ignore-scripts jsdom postcss +SHINE_UI_TEST_DEPS="$check_dir/node_modules" node --experimental-vm-modules shine-UI/channel-design-check.mjs +git diff --check +``` + +Тестовые зависимости устанавливаются вне репозитория, в runtime приложения не добавляются. + +## Границы и оставшаяся приёмка + +API, формат блоков и операции записи не изменены. Категории лайков не выведены в интерфейс. Глобальный поиск по названию и фиктивный переключатель уведомлений канала не добавлены: рабочий серверный контракт для них не введён. + +Используется существующий менеджер загрузок Arweave с его статусами/ошибками. Пока менеджер открыт, редактор блокирует отправку незавершённого вложения. Закрытие редактора закрывает менеджер, но не гарантирует отмену уже переданного транспортному слою upload-запроса; новая транспортная отмена и процент прогресса не заявляются. + +Подключённого браузера в данной сессии нет: встроенный browser-инструмент вернул пустой список. Поэтому **визуальная приёмка не выполнена**. DOM-тесты не подтверждают реальные размеры, CSS-cascade, экранную клавиатуру, screen reader или сетевую публикацию. До выпуска необходимо пройти матрицу раздела 13 DESIGN.md: обе темы, 320/390/430 px и desktop, 200% текст, длинные сообщения/имена, вложения, Android/iOS, поворот, safe area, ошибки реального сервера и восстановление фокуса после фактического обновления ленты. Также проверить соседние экраны, использующие изменённые общие компоненты. + +Локальный запуск приложения по правилам проекта: `./gradlew startLocal`. Production не затрагивался. diff --git a/docs/UI-Design/README.md b/docs/UI-Design/README.md new file mode 100644 index 00000000..acc40aa4 --- /dev/null +++ b/docs/UI-Design/README.md @@ -0,0 +1,41 @@ +# Дизайн SHiNE + +Статус: направление выбрано, спецификация 0.2 от 22.09.2026. Основа — Ночной SHiNE; дневная тема — тёплая палитра; компактная шапка канала; полноэкранный ответ. У лайков только общий счётчик, оглавление в меню. + +- [Общий дизайн-документ](DESIGN.md) — актуальная спецификация выбранного стиля, обе темы, общие компоненты, сценарии и критерии приёмки. +- [Открыть исходную HTML-галерею](channels-v1/index.html) — четыре варианта, четыре экрана в каждом; история выбора. + +Галерея сохранена без изменений и ещё не отражает итоговое сочетание решений. Для последующего переноса источником истины служит `DESIGN.md`, а не детали одного старого варианта. Код приложения не менялся. + +## Как посмотреть + +Откройте `channels-v1/index.html` в обычном браузере. Сервер, сборка, вход в аккаунт и интернет не нужны. Стили и JavaScript лежат рядом, поэтому при передаче макетов сохраняйте всю папку `channels-v1`. + +Вверху переключаются экраны всех вариантов одновременно. Стрелка возле названия открывает один вариант. На узком экране галерея выстраивается вертикально. + +Отдельные варианты: + +1. [Чистый монохром](channels-v1/index.html?variant=mono). +2. [Тёплый SHiNE](channels-v1/index.html?variant=warm). +3. [Ночной SHiNE](channels-v1/index.html?variant=night). +4. [Компактный клуб](channels-v1/index.html?variant=club). + +## Что попробовать + +1. Нажать на канал и открыть обсуждение через значок комментариев. +2. Ответить на исходное сообщение или конкретному собеседнику. +3. Набрать ответ, закрыть редактор, открыть его снова: черновик сохраняется в памяти страницы. +4. Прикрепить файл, удалить вложение, отправить текст или только вложение. +5. Включить «Макет клавиатуры», чтобы оценить оставшееся место. Это иллюстрация, настоящая клавиатура телефона работает отдельно. +6. Переключить роль на владельца: в канале появляется постоянная кнопка публикации. +7. Проверить поиск и фильтры списка; открыть оглавление и меню сообщения. + +Все действия демонстрационные. Выбранный файл не читается и никуда не загружается: отображается только имя. После перезагрузки страницы демонстрационные сообщения, реакции и черновики исчезают. Уведомления, подписка и поиск не обращаются к API. Другие разделы нижней навигации показывают пояснение о границах макета. + +## Границы + +Макеты намеренно изолированы от `shine-UI`: они не импортируют приложение и не участвуют в его сборке. Здесь допускаются экспериментальные компоненты; при переносе утверждённого дизайна нужно использовать существующие владельцы AppShell, TopBar, Dropdown, Avatar и Composer. + +## Проверка + +Результаты проверки и ограничения приведены в [VALIDATION.md](VALIDATION.md). Визуальная проверка в браузере в текущей сессии недоступна: подключённых браузеров нет. Это рабочие HTML-макеты для обсуждения, не проверенная на устройствах реализация приложения. diff --git a/docs/UI-Design/VALIDATION.md b/docs/UI-Design/VALIDATION.md new file mode 100644 index 00000000..8a548db4 --- /dev/null +++ b/docs/UI-Design/VALIDATION.md @@ -0,0 +1,35 @@ +# Проверка HTML-мокапов + +Дата: 22.09.2026. + +## Выполнено + +- `node --check channels-v1/mockups.js` — синтаксис JavaScript корректен. +- CSS разобран PostCSS без ошибок синтаксиса. +- В изолированном jsdom проверены все четыре направления: начальная отрисовка, поиск и отсутствие результатов, фильтр «Мои», открытие канала, роли читателя/владельца, переключение лайка, открытие ветки, ответ конкретному участнику, неактивная пустая отправка, сохранение черновика при закрытии, успешная отправка, независимый контекст второй публикации. +- Проверено, что HTML-подобный текст ответа отображается как текст, без создания пользовательских HTML-элементов. +- Проверены выбор/удаление вложения и возможность отправить вложение без текста, переключение макета клавиатуры, Escape и снятие `inert` с основного содержимого. +- Проверены публикация владельца и её сохранение при переключении роли, отдельное открытие каждого из четырёх вариантов через `?variant=`. +- После последних изменений повторены синтаксическая проверка и проверки взаимодействий — успешно. +- Git показывает только новую папку `docs/UI-Design/`. Существующие файлы приложения не изменялись. + +Инструменты DOM-проверки и временный сценарий установлены в `/tmp/shine-ui-review-gporxy`, зависимости приложения не менялись. + +## Ограничения проверки + +Подключённый Browser недоступен: runtime сообщил `No browser is available`, список браузеров пуст. Скриншоты не получены. jsdom проверяет структуру и поведение, но не рассчитывает реальную геометрию и не заменяет визуальную проверку браузером. + +Перед переносом в приложение обязательна проверка в реальном браузере: ширины 320/390/430 px, увеличение текста, длинные названия, прокрутка и восстановление позиции, экранная клавиатура Android/iOS, фокус и screen reader, контраст, состояния загрузки и ошибки. Эмуляция клавиатуры в галерее показывает только занятое ею место. + +Никакие серверные операции, публикации, загрузки файлов или изменения production не выполнялись. + +## Уточнение документа 0.2 + +22.09.2026, после выбора пользователем направления: + +- Обновлены `DESIGN.md` и `README.md`; HTML-галерея оставлена историческим референсом, это явно отмечено в обоих документах. +- Убраны устаревшие вопросы о выборе темы/редактора и указания показывать статистику лайков или оглавление вне меню. +- Добавлены единые компоненты, цвета двух тем, компактная шапка, полноэкранный редактор, состояния и матрица приёмки будущего переноса. +- Локально рассчитан контраст основных пар цветов. Основной текст/фон: ночь 15.34:1, день 12.98:1. Вторичный текст/фон: ночь 7.45:1, день после коррекции 5.26:1. Вторичный текст/выбранная поверхность: ночь 5.10:1, день 4.55:1. Текст акцентной кнопки: ночь 9.86:1, день 5.88:1. Граница контрола/поверхность: ночь 4.10:1, день 3.29:1. +- Эти числа относятся к непрозрачным цветам токенов. Проверка реальных CSS-состояний, прозрачностей, наложений, всех статусных цветов и геометрии потребуется при реализации; полного аудита доступности пока нет. +- Изменения документа не означают обновления приложения или визуальной проверки итогового сочетания. diff --git a/docs/UI-Design/channels-v1/index.html b/docs/UI-Design/channels-v1/index.html new file mode 100644 index 00000000..f74d5db0 --- /dev/null +++ b/docs/UI-Design/channels-v1/index.html @@ -0,0 +1,34 @@ + + + + + + SHiNE — четыре направления UI + + + + +
+ ✳ SHiNEDESIGN STUDY / 01 + Концепции · сентябрь 2026 +
+
+
+
КАНАЛЫ / СООБЩЕНИЯ / ОТВЕТЫ

Ближе к разговору.

Четыре характера. Один привычный путь от канала к ответу.
Выберите экран и сравните — или нажмите на элементы внутри макета.

+
01—04

Меньше визуального шума.
Больше места людям и мыслям.

+
+
+
+ + + + +
+
Все варианты ↗
+
+ +
С ЧЕГО НАЧАТЬ ОБСУЖДЕНИЕ

Сначала удобство. Затем характер.

01 / ЧитаемостьГде комфортнее читать длинный пост и замечать новый ответ?

02 / ПлотностьНужны просторные сообщения или больше содержимого на одном экране?

03 / Окно ответаПолный экран или лист поверх ветки? Проверьте с макетом клавиатуры.

Демонстрационные данные. Отправка и реакции работают только внутри этой страницы. Поиск фильтрует примеры. Другие разделы приложения не проектировались. Дизайн-документ ↗

+
+
SHiNE / пространство для общенияРабочий материал · не финальный дизайн
+ + diff --git a/docs/UI-Design/channels-v1/mockups.css b/docs/UI-Design/channels-v1/mockups.css new file mode 100644 index 00000000..c55409c9 --- /dev/null +++ b/docs/UI-Design/channels-v1/mockups.css @@ -0,0 +1,212 @@ +* { box-sizing: border-box; } +:root { color-scheme: light; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #242622; background: #f1f0eb; } +body { margin: 0; } +button, input, textarea, select { font: inherit; } +button, a, select { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +button:disabled { cursor: default; opacity: .4; } +button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid #728de3; outline-offset: 3px; } +a { color: inherit; } +.studio-header { margin: 0 42px; padding: 26px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #d5d6ce; gap: 16px; } +.brand { display: flex; align-items: center; gap: 9px; font-size: 25px; font-weight: 760; text-decoration: none; letter-spacing: -1px; } +.brand-spark { font-size: 36px; font-weight: 400; } +.brand-caption { margin-left: 22px; font-size: 10px; letter-spacing: 2px; font-weight: 600; } +.draft-label { font-size: 12px; color: #63675f; } +main { padding: 0 42px; max-width: 1900px; margin: auto; } +.intro { display: flex; justify-content: space-between; align-items: end; padding: 48px 0 34px; gap: 30px; } +.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 1.8px; } +h1 { font-size: clamp(34px, 4vw, 60px); line-height: 1.1; letter-spacing: -2.8px; font-weight: 550; margin: 16px 0; } +.intro p { color: #62665d; font-size: 14px; line-height: 1.7; margin: 0; } +.intro-note { border-left: 1px solid #c6c8bd; padding-left: 24px; min-width: 250px; } +.intro-note > span { font-size: 38px; letter-spacing: -2px; } +.intro-note p { font-size: 12px; margin-top: 10px; } +.review-bar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; padding: 16px 0; border-top: 1px solid #d5d6ce; border-bottom: 1px solid #d5d6ce; } +.screen-switch { display: flex; flex-wrap: wrap; gap: 4px; } +.review-button { border: 0; padding: 11px 14px; background: transparent; border-radius: 7px; font-size: 12px; color: #62665d; min-height: 42px; } +.review-button.selected { background: #292d27; color: white; } +.review-options { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; font-size: 12px; color: #62665d; } +.review-options label { display: flex; align-items: center; gap: 6px; } +.review-options select { padding: 8px; border: 1px solid #d5d6ce; border-radius: 7px; background: transparent; color: #242622; } +.compare-link { display: none; } +.gallery { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; padding: 30px 0 36px; align-items: start; } +.concept { min-width: 0; } +.concept-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.concept-number { font-size: 11px; border: 1px solid #c9cdc2; border-radius: 50%; width: 27px; height: 27px; display: grid; place-items: center; } +.concept-heading h2 { font-size: 17px; font-weight: 600; margin: 0; letter-spacing: -.5px; } +.open-concept { margin-left: auto; padding: 10px; text-decoration: none; font-size: 18px; } +.concept-description { font-size: 12px; line-height: 1.6; color: #6b7065; min-height: 40px; margin: 0 0 18px; } +.phone { --bg: #111212; --surface: #1c1d1d; --text: #f3f3f1; --muted: #a0a2a0; --line: #2c2d2d; --accent: #f3f3f1; --on-accent: #161717; --soft: #252727; position: relative; display: flex; flex-direction: column; height: 760px; width: 100%; max-width: 410px; margin: auto; color: var(--text); background: var(--bg); border: 1px solid #bfc2b8; border-radius: 32px; overflow: hidden; box-shadow: 0 14px 35px #252d2210; font-size: 14px; line-height: 1.45; isolation: isolate; } +.phone[data-theme="warm"] { --bg: #faf7f0; --surface: #fffdf8; --text: #302c25; --muted: #7b7264; --line: #e6dfd1; --accent: #93511e; --on-accent: #fffaf3; --soft: #efe6d6; } +.phone[data-theme="night"] { --bg: #101b20; --surface: #17272d; --text: #e8f2f1; --muted: #98adaf; --line: #2a3b40; --accent: #94e1ce; --on-accent: #102c27; --soft: #213b3a; } +.phone[data-theme="club"] { --bg: #f9fafb; --surface: #fff; --text: #202b3c; --muted: #6b7688; --line: #e2e7ee; --accent: #4c5caa; --on-accent: #fff; --soft: #eaeefa; } +.status-bar { display: flex; justify-content: space-between; padding: 15px 24px 6px; font-size: 11px; font-weight: 700; flex: 0 0 38px; } +.status-icons { display: flex; gap: 5px; align-items: center; font-size: 10px; } +.icon { height: 21px; width: 21px; display: inline-block; vertical-align: middle; flex-shrink: 0; } +.status-icons .icon { height: 16px; width: 16px; } +.phone button { color: inherit; } +.icon-btn { background: transparent; border: 0; width: 44px; height: 44px; flex: 0 0 44px; display: inline-grid; place-items: center; border-radius: 50%; padding: 0; } +.icon-btn:hover { background: var(--soft); } +.app-header { display: flex; align-items: center; padding: 4px 12px 10px; gap: 6px; min-height: 66px; border-bottom: 1px solid var(--line); } +.header-text { flex: 1; min-width: 0; } +.header-text h3 { font-size: 17px; letter-spacing: -.4px; margin: 0; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.header-text small { color: var(--muted); font-size: 10px; } +.list-header { padding-left: 22px; } +.list-header h3 { font-size: 25px; letter-spacing: -.9px; } +.mini-brand { color: var(--accent); font-size: 26px; line-height: 1; margin-right: 3px; } +.view { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; overscroll-behavior: contain; } +.search-field { margin: 15px 18px 10px; display: flex; gap: 9px; align-items: center; background: var(--surface); border-radius: 12px; padding: 0 12px; border: 1px solid var(--line); color: var(--muted); } +.search-field input { width: 100%; min-width: 0; border: 0; background: transparent; height: 42px; outline-offset: 0; color: var(--text); font-size: 13px; } +.search-field input::placeholder { color: var(--muted); } +.filter-tabs { display: flex; padding: 0 18px 12px; gap: 7px; } +.filter { font-size: 11px; padding: 0 11px; min-height: 36px; background: transparent; border: 1px solid transparent; border-radius: 20px; color: var(--muted); } +.filter[aria-pressed="true"] { color: var(--text); background: var(--soft); } +.section-label { padding: 10px 20px 7px; display: flex; justify-content: space-between; text-transform: uppercase; letter-spacing: 1.3px; font-size: 9px; font-weight: 700; color: var(--muted); } +.channel-row { display: flex; width: 100%; align-items: center; gap: 12px; padding: 17px 18px; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; } +.channel-row:hover { background: var(--soft); } +.avatar { width: 40px; height: 40px; border-radius: 50%; background: #bdc5a8; color: #283124; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; font-size: 13px; font-weight: 650; letter-spacing: -.5px; } +.avatar.city { background: #ded3bb; color: #635039; } +.avatar.blue { background: #bac5d9; color: #354663; } +.avatar.rose { background: #d9bcbb; color: #704444; } +.avatar.green { background: #b9cdc0; color: #345747; } +.avatar.violet { background: #cbc2d9; color: #56476c; } +.avatar.self { background: var(--soft); color: var(--accent); } +.channel-row .avatar { width: 48px; height: 48px; font-size: 23px; } +.channel-copy { min-width: 0; flex: 1; } +.channel-copy strong { display: block; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.channel-copy small { display: block; color: var(--muted); font-size: 10px; margin: 2px 0; } +.channel-copy p { margin: 3px 0 0; font-size: 12px; color: var(--muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.channel-tail { display: flex; align-items: end; flex-direction: column; gap: 11px; align-self: stretch; padding-top: 2px; color: var(--muted); font-size: 10px; } +.badge { min-width: 20px; height: 20px; background: var(--accent); color: var(--on-accent); padding: 2px 6px; border-radius: 10px; text-align: center; font-size: 10px; font-weight: 700; } +.list-tip { padding: 24px 24px 16px; color: var(--muted); font-size: 11px; text-align: center; } +.bottom-nav { border-top: 1px solid var(--line); display: flex; justify-content: space-around; flex: 0 0 73px; align-items: start; padding: 9px 6px 12px; background: var(--bg); } +.nav-item { border: 0; background: transparent; display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 8px; width: 20%; min-height: 44px; opacity: .65; } +.nav-item[aria-current="page"] { opacity: 1; color: var(--accent); } +.nav-item[aria-current="page"] .icon { background: var(--soft); border-radius: 7px; box-shadow: 0 0 0 5px var(--soft); } +.channel-intro { margin: 0; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); } +.channel-intro p { margin: 0 0 12px; color: var(--muted); font-size: 12px; line-height: 1.6; } +.channel-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.text-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; padding: 0 4px; min-height: 36px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 600; } +.text-button .icon { width: 16px; height: 16px; } +.pill { border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 8px 11px; font-size: 10px; min-height: 36px; } +.unread-line { display: flex; align-items: center; gap: 10px; color: var(--accent); font-size: 9px; padding: 13px 20px 0; } +.unread-line::before, .unread-line::after { content: ""; height: 1px; flex: 1; background: var(--line); } +.post { padding: 18px 18px 10px; border-bottom: 1px solid var(--line); } +.post-header { display: flex; align-items: center; gap: 9px; } +.post-header .avatar { width: 34px; height: 34px; font-size: 11px; } +.author { min-width: 0; flex: 1; font-size: 12px; } +.author strong { font-weight: 650; } +.author small { color: var(--muted); font-size: 10px; display: block; } +.post .more { width: 36px; height: 36px; flex-basis: 36px; color: var(--muted); } +.post p { font-size: 14px; line-height: 1.6; margin: 10px 0 12px; overflow-wrap: anywhere; } +.post-title { font-weight: 600; } +.landscape { height: 142px; overflow: hidden; border-radius: 12px; position: relative; background: #c9d6cf; margin: 10px 0 8px; } +.landscape svg { width: 100%; height: 100%; display: block; } +.media-caption { position: absolute; bottom: 10px; left: 12px; font-size: 9px; letter-spacing: 1px; color: #fff; text-transform: uppercase; } +.post-actions { display: flex; gap: 14px; align-items: center; color: var(--muted); } +.action { border: 0; background: transparent; min-height: 44px; display: flex; align-items: center; gap: 6px; padding: 0 2px; font-size: 11px; color: var(--muted); } +.action[aria-pressed="true"] { color: #dc7477; } +.action[aria-pressed="true"] .icon { fill: currentColor; } +.reply-link { margin-left: auto; font-size: 10px; } +.attachment { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); background: var(--surface); border-radius: 12px; padding: 12px; margin: 10px 0; } +.attachment > .icon { color: var(--accent); } +.attachment strong { font-size: 11px; display: block; } +.attachment small { color: var(--muted); display: block; font-size: 10px; } +.composer-bar { display: flex; align-items: center; padding: 10px 16px; gap: 10px; border-top: 1px solid var(--line); background: var(--bg); flex: 0 0 auto; } +.composer-trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 46px; padding: 10px 14px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); border-radius: 24px; text-align: left; font-size: 12px; } +.composer-trigger.primary { background: var(--accent); color: var(--on-accent); border-color: transparent; justify-content: center; font-weight: 650; } +.thread-heading { padding: 13px 20px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; border-bottom: 1px solid var(--line); } +.thread-heading strong { color: var(--text); } +.reply { display: flex; padding: 16px 18px 0; gap: 10px; } +.reply-body { flex: 1; min-width: 0; padding-bottom: 9px; border-bottom: 1px solid var(--line); } +.reply .avatar { width: 32px; height: 32px; font-size: 10px; } +.reply p { font-size: 13px; margin: 6px 0 2px; line-height: 1.55; overflow-wrap: anywhere; } +.reply small { color: var(--muted); font-size: 10px; } +.reply strong { font-size: 12px; } +.reply .post-actions { gap: 16px; } +.reply-context { color: var(--accent); font-size: 10px; } +.empty { padding: 40px 25px; text-align: center; color: var(--muted); font-size: 13px; } +.overlay { position: absolute; inset: 38px 0 0; z-index: 5; background: #0009; display: flex; flex-direction: column; justify-content: end; } +.sheet { display: flex; flex-direction: column; background: var(--bg); max-height: 100%; min-height: 78%; border-radius: 24px 24px 0 0; overflow: hidden; box-shadow: 0 -8px 40px #0002; } +.sheet.full { height: 100%; border-radius: 0; } +.sheet-handle { width: 32px; height: 4px; border-radius: 4px; background: var(--line); align-self: center; margin-top: 9px; } +.sheet-header { display: flex; align-items: center; padding: 7px 12px; border-bottom: 1px solid var(--line); gap: 6px; } +.sheet-header h4 { margin: 0; flex: 1; text-align: center; font-size: 15px; } +.sheet-header .expand { color: var(--muted); } +.editor-scroll { overflow-y: auto; flex: 1; min-height: 0; padding: 18px; } +.source-preview { display: flex; gap: 10px; padding-bottom: 20px; position: relative; } +.source-preview::after { content: ""; position: absolute; left: 19px; top: 46px; bottom: 3px; width: 1px; background: var(--line); } +.source-preview p { font-size: 12px; color: var(--muted); margin: 5px 0; line-height: 1.5; } +.source-preview strong { font-size: 12px; } +.source-preview small { color: var(--muted); font-size: 10px; } +.editor-row { display: flex; gap: 10px; } +.editor-main { flex: 1; min-width: 0; } +.editor-main strong { display: block; font-size: 12px; margin: 2px 0; } +.editor-main > small { color: var(--muted); display: block; font-size: 10px; margin: 3px 0 10px; } +.editor-input { resize: none; border: 0; width: 100%; min-height: 115px; background: transparent; color: var(--text); font-size: 15px; line-height: 1.6; padding: 3px 0; } +.editor-input::placeholder { color: var(--muted); } +.editor-input:focus-visible { outline: none; box-shadow: 0 2px 0 var(--accent); } +.editor-footer { display: flex; align-items: center; gap: 4px; padding: 11px 16px 16px; border-top: 1px solid var(--line); background: var(--bg); } +.char-count { color: var(--muted); font-size: 10px; margin-left: auto; margin-right: 10px; } +.submit { border: 0; border-radius: 22px; padding: 12px 18px; background: var(--accent); color: var(--on-accent); font-size: 12px; font-weight: 650; min-height: 44px; } +.phone .submit { color: var(--on-accent); } +.editor-attachment { margin-top: 10px; display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: var(--soft); border-radius: 10px; font-size: 10px; } +.editor-attachment span { flex: 1; overflow-wrap: anywhere; } +.keyboard { flex: 0 0 auto; background: var(--surface); padding: 10px 4px 18px; border-top: 1px solid var(--line); } +.keyboard-label { font-size: 9px; text-align: center; color: var(--muted); margin-bottom: 10px; } +.keys { display: flex; justify-content: center; gap: 3px; margin: 5px 0; } +.keys span { display: grid; place-items: center; height: 31px; flex: 1; max-width: 29px; border-radius: 5px; font-size: 13px; color: var(--text); background: var(--soft); box-shadow: 0 1px 0 #0003; } +.keys .space { max-width: 150px; flex: 5; font-size: 10px; } +.keys .wide { max-width: 45px; flex: 1.7; font-size: 11px; } +.menu-sheet { min-height: 0; padding-bottom: 24px; } +.menu-content { padding: 10px 20px; } +.menu-action { border: 0; background: transparent; width: 100%; text-align: left; display: flex; gap: 12px; align-items: center; min-height: 47px; font-size: 13px; border-bottom: 1px solid var(--line); } +.menu-content p { color: var(--muted); font-size: 12px; line-height: 1.6; } +.toast { position: absolute; bottom: 86px; left: 18px; right: 18px; padding: 12px 15px; border-radius: 12px; background: var(--text); color: var(--bg); z-index: 10; font-size: 12px; box-shadow: 0 4px 20px #0003; } +.concept-notes { margin-top: 19px; font-size: 12px; color: #666d61; line-height: 1.65; } +.concept-notes strong { color: #33392f; font-weight: 600; } +.swatches { display: flex; gap: 5px; margin-bottom: 12px; } +.swatches span { width: 19px; height: 19px; border-radius: 50%; border: 1px solid #0002; } +.concept-tag { display: inline-block; border: 1px solid #c9cdc2; padding: 3px 7px; border-radius: 4px; font-size: 9px; letter-spacing: .3px; margin-bottom: 8px; } +.decision-note { padding: 36px 0; border-top: 1px solid #d5d6ce; } +.decision-note h2 { font-weight: 500; letter-spacing: -.7px; font-size: 27px; margin: 13px 0; } +.decision-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 38px; } +.decision-grid p { font-size: 13px; line-height: 1.7; color: #62665d; } +.decision-grid b { display: block; font-weight: 600; color: #33392f; margin-bottom: 6px; } +.disclaimer { font-size: 11px; color: #62665d; line-height: 1.7; margin-top: 24px; } +.studio-footer { display: flex; justify-content: space-between; margin: 0 42px; padding: 22px 0; border-top: 1px solid #d5d6ce; color: #73796b; font-size: 10px; gap: 15px; } +/* Варианты отличаются также плотностью, иерархией и формой редактора. */ +.phone[data-theme="mono"] .post { padding-left: 18px; } +.phone[data-theme="mono"] .post-content { margin-left: 43px; } +.phone[data-theme="mono"] .landscape { height: 128px; } +.phone[data-theme="warm"] .channel-row { margin: 0 12px 8px; width: calc(100% - 24px); border: 1px solid var(--line); border-radius: 16px; padding: 14px 12px; background: var(--surface); } +.phone[data-theme="warm"] .channel-intro { background: var(--soft); margin: 14px 16px 0; border: 0; border-radius: 16px; } +.phone[data-theme="warm"] .post { margin: 14px 12px; padding: 15px 14px 7px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); } +.phone[data-theme="warm"] .post-title { font-family: Georgia, serif; font-size: 20px; line-height: 1.3; } +.phone[data-theme="warm"] .reply .avatar { border-radius: 12px; } +.phone[data-theme="night"] .list-header { border-bottom: 0; padding-top: 12px; } +.phone[data-theme="night"] .channel-intro { background: linear-gradient(125deg, #1d3937, #16272e); border-bottom: 1px solid #36544f; } +.phone[data-theme="night"] .channel-row { border-bottom-color: #21343b; } +.phone[data-theme="night"] .channel-row:first-child { background: #172b2e; box-shadow: inset 3px 0 var(--accent); } +.phone[data-theme="night"] .composer-trigger { border-radius: 13px; } +.phone[data-theme="night"] .post-title { font-size: 17px; font-weight: 550; } +.phone[data-theme="club"] .channel-row { padding: 12px 16px; gap: 10px; } +.phone[data-theme="club"] .channel-row .avatar { width: 42px; height: 42px; border-radius: 13px; } +.phone[data-theme="club"] .channel-copy small { display: none; } +.phone[data-theme="club"] .filter-tabs { border-bottom: 1px solid var(--line); padding-bottom: 0; gap: 12px; } +.phone[data-theme="club"] .filter { border-radius: 0; padding: 0 1px; } +.phone[data-theme="club"] .filter[aria-pressed="true"] { background: transparent; color: var(--accent); border-bottom: 2px solid var(--accent); } +.phone[data-theme="club"] .post { padding: 12px 16px 5px; } +.phone[data-theme="club"] .post p { font-size: 13px; margin: 7px 0; } +.phone[data-theme="club"] .landscape { height: 108px; } +.phone[data-theme="club"] .channel-intro { padding: 12px 16px 8px; } +.phone[data-theme="club"] .channel-intro p { margin-bottom: 5px; } +.phone[data-theme="club"] .reply { padding-top: 12px; } +.phone[data-theme="club"] .reply-body { border-left: 2px solid var(--line); border-bottom: 0; padding-left: 12px; } +.phone[data-theme="club"] .composer-trigger { border-radius: 10px; } +.gallery.single { display: block; max-width: 410px; margin: auto; } +.single .concept-description { min-height: 0; } +body.focused .compare-link { display: inline; } +@media (max-width: 1490px) { .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 900px; margin: auto; gap: 40px; } .concept { max-width: 410px; width: 100%; justify-self: center; } } +@media (max-width: 760px) { .studio-header { margin: 0 20px; padding: 18px 0; } main { padding: 0 20px; } .brand-caption, .draft-label, .intro-note { display: none; } .intro { padding: 30px 0 24px; } h1 { letter-spacing: -1.5px; } .intro p { font-size: 13px; } .gallery { grid-template-columns: minmax(0, 1fr); gap: 32px; } .screen-switch { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .review-button { padding: 10px; } .review-options { gap: 12px; } .phone { height: 760px; border-radius: 25px; } .decision-grid { grid-template-columns: 1fr; gap: 0; } .studio-footer { margin: 0 20px; } } +@media (max-width: 380px) { main { padding: 0 10px; } .post-actions { gap: 10px; } .phone { font-size: 13px; } .channel-row { padding-left: 12px; padding-right: 12px; gap: 8px; } .header-text h3 { font-size: 15px; } .concept-heading h2 { font-size: 16px; } } +@media (prefers-reduced-motion: no-preference) { .sheet { animation: appear .18s ease-out; } @keyframes appear { from { transform: translateY(22px); opacity: .5; } to { transform: translateY(0); opacity: 1; } } } diff --git a/docs/UI-Design/channels-v1/mockups.js b/docs/UI-Design/channels-v1/mockups.js new file mode 100644 index 00000000..8c97645d --- /dev/null +++ b/docs/UI-Design/channels-v1/mockups.js @@ -0,0 +1,246 @@ +/* Изолированный прототип: без API, импортов приложения и постоянного хранилища. */ +'use strict'; +const icons = { + back: '', + close: '', + more: '', + search: '', + plus: '', + heart: '', + chat: '', + share: '', + channels: '', + people: '', + bell: '', + user: '', + book: '', + file: '', + attach: '', + image: '', + expand: '', + check: '', + edit: '', + wifi: '', + battery: '', +}; +const icon = name => ``; +const escapeText = value => String(value).replace(/[&<>"']/g, c => ({'&':'&', '<':'<', '>':'>', '"':'"', "'":'''}[c])); +const button = (action, label, name, extra = '') => ``; +const concepts = [ + { id:'mono', number:'01', title:'Чистый монохром', description:'Контент на первом плане. Знакомый ритм Threads.', colors:['#111212','#2c2d2d','#f3f3f1'], tag:'МАКСИМАЛЬНО ПРИВЫЧНО', note:'Плоская лента, аватар на полях и полноэкранный ответ. Самая нейтральная отправная точка.' }, + { id:'warm', number:'02', title:'Тёплый SHiNE', description:'Бумажные оттенки. Больше воздуха и мягкости.', colors:['#faf7f0','#efe6d6','#93511e'], tag:'МЯГКИЙ И ЛИЧНЫЙ', note:'Карточки отделяют разговоры, тёплый акцент выделяет действия. Ответ открывается округлым листом снизу.' }, + { id:'night', number:'03', title:'Ночной SHiNE', description:'Глубокий синий. Спокойный свет в деталях.', colors:['#101b20','#213b3a','#94e1ce'], tag:'ХАРАКТЕР SHiNE', note:'Широкий текст и выразительная шапка канала. Мой кандидат на развитие: узнаваемый, но сдержанный.' }, + { id:'club', number:'04', title:'Компактный клуб', description:'Больше разговоров на одном экране.', colors:['#f9fafb','#eaeefa','#4c5caa'], tag:'ПЛОТНО И СОБРАННО', note:'Компактные строки, подчёркнутые вкладки и линии ответов. Редактор на весь экран, без потери контекста.' }, +]; +const channels = [ + { title:'Город и люди', owner:'anna', slug:'city', symbol:'⌂', color:'city', preview:'Маленькие открытия по дороге домой', time:'12 мин', unread:3, following:true }, + { title:'Дизайн каждый день', owner:'mikhail', slug:'design', symbol:'Aa', color:'blue', preview:'Как сделать сложное простым?', time:'38 мин', unread:7, following:true }, + { title:'Тихие маршруты', owner:'lena', slug:'walks', symbol:'↗', color:'green', preview:'Сохранили маршрут на выходные', time:'1 ч', unread:2, following:true }, + { title:'Книжная полка', owner:'olga', slug:'books', symbol:'≋', color:'rose', preview:'Что сейчас читаете?', time:'2 ч', unread:0, following:true }, + { title:'SHiNE · новости', owner:'shine', slug:'news', symbol:'✳', color:'violet', preview:'Место, где начинаются разговоры', time:'вчера', unread:0, following:false }, + { title:'Мои заметки', owner:'you', slug:'notes', symbol:'✎', color:'blue', preview:'Идея для следующей встречи', time:'вчера', unread:0, following:false }, +]; +let role = 'reader'; +let showKeyboard = false; +const states = new Map(); +const mainText = 'Иногда лучший маршрут — чуть длиннее обычного. Сегодня свернула к реке и нашла это место. А где вы перезагружаетесь после рабочего дня?'; +const secondText = 'Собрала наши любимые места в один список. Добавляйте свои находки в ответах!'; +const landscape = () => ``; +function header(s) { + if (s.screen === 'list') return `
✳

Каналы

${button('new-channel','Создать канал','plus')}
`; + return `
${button('back','Назад','back')}

${s.screen === 'thread' ? 'Обсуждение' : escapeText(s.channel.title)}

${s.screen === 'thread' ? escapeText(s.channel.title) : '@'+escapeText(s.channel.owner)+' / '+escapeText(s.channel.slug)}
${button('notifications',s.muted ? 'Включить уведомления' : 'Настроить уведомления','bell')}${button('channel-menu','Меню канала','more')}
`; +} +function nav() { + return ``; +} +function rows(s) { + const filtered = channels.filter(c => (s.filter!=='mine'||c.owner==='you') && (s.filter!=='following'||c.following) && `${c.title} ${c.owner}`.toLowerCase().includes(s.query.toLowerCase())); + return filtered.length ? filtered.map(c=>``).join('') : '

Каналы не найдены.
Попробуйте другое название или логин.

'; +} +function list(s) { + return `
${[['all','Все'],['following','Подписки'],['mine','Мои']].map(([value,label])=>``).join('')}
${rows(s)}

Хорошие разговоры начинаются
с общих интересов.

`; +} +function actions(s, id, count, replies) { + const liked = s.likes.has(id); + replies = 2 + s.replies.filter(reply => reply.post === id).length; + return `
`; +} +function post(s, second = false) { + return `
АК
${escapeText(s.channel.owner==='anna'?'Анна К.':s.channel.owner)}${second?'вчера, 19:10':'12 минут назад'}
${second?'

Собрала наши любимые места в один список. Добавляйте свои находки в ответах!

'+icon('file')+'
Места для прогулок.pdfPDF · 552 КБ
':`

Маленькие открытия

${mainText}

${landscape()}`}${actions(s, second?'second':'first',second?12:24, second?3:2+s.replies.length)}
`; +} +function feed(s) { + return `

Замечаем красоту рядом. Делимся местами,
историями и маленькими открытиями.

Новые сообщения
${post(s)}${post(s,true)}`; +} +function replyRow(name, initials, color, text, target='') { + return `
${initials}
${target?`
В ответ ${escapeText(target)}
`:''}${escapeText(name)} · сейчас

${escapeText(text)}

`; +} +function thread(s) { + const replies=s.replies.filter(r=>r.post===s.activePost); + const second=s.activePost==='second'; + return `${post(s,second)}
Ответы · ${2+replies.length}По порядку
${replyRow('Михаил','МЛ','blue',second?'Спасибо за список! Добавлю ещё парк у старого моста.':'У воды всегда легче выдохнуть. Это возле старого моста?')}${replyRow('Лена','ЛС','green',second?'В выходные попробую один из маршрутов.':'А я ухожу гулять без наушников. Город звучит совсем иначе.')}${replies.map(r=>replyRow('Вы','ВЫ','self',r.text,r.target)).join('')}`; +} +function composer(s) { + if (s.screen==='list') return ''; + if (s.screen==='feed' && role==='reader') return ''; + return `
`; +} +function render(s) { + const previousView = s.phone.querySelector('.view'); + s.scrolls ||= {}; + if (previousView && s.renderedScreen) s.scrolls[s.renderedScreen] = previousView.scrollTop; + s.phone.innerHTML = `
9:41${icon('wifi')}${icon('battery')}
${header(s)}
${s.screen==='list'?list(s):s.screen==='feed'?feed(s):thread(s)}
${composer(s)}${nav()}`; + if(s.screen==='feed') for(const text of s.posts) { + const item=document.createElement('article'); item.className='post'; + const author=document.createElement('strong'); author.textContent='Вы · сейчас'; + const body=document.createElement('p'); body.textContent=text; item.append(author,body); + s.phone.querySelector('.view').append(item); + } + s.phone.querySelector('.view').scrollTop = s.scrolls[s.screen] || 0; + s.renderedScreen = s.screen; + if (s.editor) mountEditor(s); +} +function keyboard() { + return ``; +} +function mountEditor(s) { + const mode = s.editor; + const full = s.id==='mono'||s.id==='club'||s.expanded; + const overlay = document.createElement('div'); + overlay.className = 'overlay'; + overlay.innerHTML = ``; + s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=true); + s.phone.append(overlay); + overlay.querySelector('.sheet').focus({preventScroll:true}); +} +function attachmentMarkup(s) { + const name = s.attachments[s.editor]; + return name?`
${icon('file')}${escapeText(name)}${button('remove-attachment','Удалить вложение','close')}
`:''; +} +function removeOverlay(s) { + s.phone.querySelector('.overlay')?.remove(); + s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=false); +} +function closeEditor(s) { + s.editor = null; + removeOverlay(s); + if (s.returnFocus?.isConnected) s.returnFocus.focus({preventScroll:true}); + else s.phone.querySelector('[data-action="reply"], [data-action="new-post"], [data-action="back"]')?.focus({preventScroll:true}); +} +function openEditor(s, mode='reply', author=s.channel.owner, text=s.activePost==='second'?secondText:mainText, trigger=null) { + if (s.editor) closeEditor(s); + s.editor=mode; + s.replyTo=author; + s.replyText=text; + s.returnFocus=trigger; + s.expanded=false; + mountEditor(s); +} +function toast(s, message) { + s.phone.querySelector('.toast')?.remove(); + clearTimeout(s.toastTimer); + const el=document.createElement('div'); + el.className='toast'; el.setAttribute('role','status'); el.textContent=message; + s.phone.append(el); + s.toastTimer=setTimeout(()=>el.remove(),3500); +} +function menu(s, title, contents, trigger) { + removeOverlay(s); + s.returnFocus=trigger; + const el=document.createElement('div'); el.className='overlay'; + el.innerHTML=``; + s.phone.querySelectorAll(':scope > *').forEach(child=>child.inert=true); + s.phone.append(el); el.querySelector('.sheet').focus({preventScroll:true}); +} +const menuAction=(action,label,i='file')=>``; +function updateEditor(s) { + const modal=s.phone.querySelector('.overlay'); + modal.querySelector('.char-count').textContent=`${s.drafts[s.editor].length} / 2000`; + modal.querySelector('[data-action="send"]').disabled=!s.drafts[s.editor].trim()&&!s.attachments[s.editor]; + modal.querySelector('.selected-attachment').innerHTML=attachmentMarkup(s); +} +function handle(s, event) { + const el=event.target.closest('[data-action]'); + if (!el || el.disabled) return; + const action=el.dataset.action; + if (action==='filter') { s.filter=el.dataset.value; render(s); } + else if (action==='open-channel') { s.channel=channels[Number(el.dataset.index)]; s.screen='feed'; s.activePost='first'; s.replies=[]; s.posts=[]; s.likes.clear(); render(s); } + else if (action==='list'||action==='back') { s.screen=action==='list'||s.screen==='feed'?'list':'feed'; render(s); } + else if (action==='thread') { s.activePost=el.dataset.post||s.activePost; s.screen='thread'; render(s); } + else if (action==='reply'||action==='new-post') { s.activePost=el.dataset.post||s.activePost; openEditor(s,action==='new-post'?'post':'reply',s.channel.owner,s.activePost==='second'?secondText:mainText,el); } + else if (action==='reply-to') openEditor(s,'reply',el.dataset.author,el.dataset.text,el); + else if (action==='close-editor') { closeEditor(s); toast(s,'Черновик сохранён до закрытия страницы'); } + else if (action==='expand') { s.expanded=!s.expanded; removeOverlay(s); mountEditor(s); } + else if (action==='attach') s.phone.querySelector('[data-file]').click(); + else if (action==='remove-attachment') { s.attachments[s.editor]=''; updateEditor(s); } + else if (action==='send') { + const mode=s.editor; + const text=s.drafts[mode].trim(); + const attachment=s.attachments[mode]; + if (!text&&!attachment) return; + if (mode==='reply') s.replies.push({text:text+(attachment?'\n📎 '+attachment:''),target:s.replyTo,post:s.activePost}); + else s.posts.push(text+(attachment?'\n📎 '+attachment:'')); + s.drafts[mode]=''; s.attachments[mode]=''; s.editor=null; s.screen=mode==='reply'?'thread':'feed'; render(s); + const view=s.phone.querySelector('.view'); view.scrollTop=view.scrollHeight; + s.phone.querySelector('.composer-trigger')?.focus({preventScroll:true}); toast(s,'Отправлено в макете. Данные на сервер не передаются.'); + } + else if (action==='like') { + const key=el.dataset.post; const liked=s.likes.has(key); liked?s.likes.delete(key):s.likes.add(key); + el.setAttribute('aria-pressed',String(!liked)); el.querySelector('span').textContent=String(Number(el.querySelector('span').textContent)+(liked?-1:1)); + } + else if (action==='share') menu(s,'Поделиться', '

В приложении здесь появится системное меню отправки ссылки. В макете внешние ссылки не создаются.

'+menuAction('close-menu','Понятно','check'),el); + else if (action==='subscribe') { s.subscribed=!s.subscribed; el.textContent=s.subscribed?'Вы подписаны':'Подписаться'; } + else if (action==='notifications') { s.muted=!s.muted; el.setAttribute('aria-label',s.muted?'Включить уведомления':'Настроить уведомления'); toast(s,s.muted?'Уведомления канала выключены в макете':'Уведомления канала включены в макете'); } + else if (action==='post-menu') menu(s,'Сообщение',menuAction('reactions','Кто поставил лайк','heart')+menuAction('details','Данные записи','file')+(role==='owner'?menuAction('edit-demo','Редактировать','edit'):'')+'

Редкие действия собраны здесь, чтобы оставлять больше места самому сообщению.

',el); + else if (action==='channel-menu') menu(s,s.channel.title,menuAction('about','О канале','channels')+menuAction('contents','Оглавление','book')+menuAction('notifications','Уведомления','bell'),el); + else if (action==='contents') menu(s,'Оглавление','

С чего начать знакомство с каналом

'+menuAction('jump-post','01 · Маленькие открытия','book')+menuAction('jump-file','02 · Наш список любимых мест','file'),el); + else if (action==='jump-post'||action==='jump-file') { removeOverlay(s); s.screen='feed'; render(s); s.phone.querySelectorAll('.post')[action==='jump-file'?1:0]?.scrollIntoView({block:'nearest'}); } + else if (action==='reactions') menu(s,'Лайки','

Все: 24 · основные: 18 · сияющие: 6

Названия категорий сохранены из текущего приложения. Их объяснение пользователю нужно согласовать отдельно.

',el); + else if (action==='details') menu(s,'Данные записи','

Здесь доступны номер записи, цепочка автора, хэш и подпись. В мокапе технические данные не генерируются.

',el); + else if (action==='about') menu(s,'О канале','

Замечаем красоту рядом. Делимся местами, историями и маленькими открытиями.

Автор: @'+escapeText(s.channel.owner)+'

',el); + else if (action==='close-menu') { removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); } + else if (action==='new-channel') toast(s,'Создание канала — следующий этап проектирования'); + else if (action==='edit-demo') toast(s,'Редактор существующего сообщения — следующий этап'); + else if (action==='outside') toast(s,'Сейчас проектируем каналы. Этот раздел пока вне макета.'); +} +function attachEvents(s) { + s.phone.addEventListener('click',event=>handle(s,event)); + s.phone.addEventListener('input',event=>{ + if(event.target.matches('[data-search]')) { s.query=event.target.value; s.phone.querySelector('.channel-rows').innerHTML=rows(s); } + if(event.target.matches('.editor-input')) { s.drafts[s.editor]=event.target.value; updateEditor(s); } + }); + s.phone.addEventListener('change',event=>{ + if(event.target.matches('[data-file]')) { s.attachments[s.editor]=event.target.files[0]?.name||''; updateEditor(s); } + }); + s.phone.addEventListener('keydown',event=>{ + const dialog=s.phone.querySelector('[role="dialog"]'); + if(!dialog) return; + if(event.key==='Escape') { event.preventDefault(); s.editor?closeEditor(s):removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); } + if(event.key==='Tab') { + const focusable=[...dialog.querySelectorAll('button:not(:disabled), textarea, input:not([hidden])')]; + const first=focusable[0], last=focusable.at(-1); + if(event.shiftKey&&(document.activeElement===first||document.activeElement===dialog)) { event.preventDefault(); last?.focus(); } + else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===dialog)) { event.preventDefault(); first?.focus(); } + } + }); +} +function init() { + const query=new URLSearchParams(location.search); + const chosen=concepts.find(c=>c.id===query.get('variant')); + const gallery=document.getElementById('gallery'); + if(chosen) {gallery.classList.add('single');document.body.classList.add('focused');} + for(const c of chosen?[chosen]:concepts) { + const article=document.createElement('section');article.className='concept';article.setAttribute('aria-label',c.title); + article.innerHTML=`
${c.number}

${c.title}

↗

${c.description}

${c.tag}
${c.note}
`; + gallery.append(article); + const s={id:c.id,phone:article.querySelector('.phone'),screen:'list',activePost:'first',channel:channels[0],query:'',filter:'all',likes:new Set(),subscribed:true,muted:false,replies:[],posts:[],drafts:{reply:'',post:''},attachments:{reply:'',post:''},editor:null}; + states.set(c.id,s);render(s);attachEvents(s); + } + document.querySelectorAll('[data-screen]').forEach(el=>el.addEventListener('click',()=>{ + document.querySelectorAll('[data-screen]').forEach(b=>{b.classList.toggle('selected',b===el);b.setAttribute('aria-pressed',String(b===el));}); + for(const s of states.values()) { s.editor=null;s.screen=el.dataset.screen==='reply'?'thread':el.dataset.screen;render(s);if(el.dataset.screen==='reply')openEditor(s); } + el.focus({preventScroll:true}); + })); + document.getElementById('role').addEventListener('change',event=>{role=event.target.value;for(const s of states.values())render(s);}); + document.getElementById('keyboard').addEventListener('change',event=>{showKeyboard=event.target.checked;for(const s of states.values())if(s.editor){removeOverlay(s);mountEditor(s);}event.target.focus({preventScroll:true});}); +} +init(); diff --git a/shine-UI/channel-design-check.mjs b/shine-UI/channel-design-check.mjs new file mode 100644 index 00000000..46b1c2a7 --- /dev/null +++ b/shine-UI/channel-design-check.mjs @@ -0,0 +1,314 @@ +// Запуск: SHINE_UI_TEST_DEPS=/путь/к/node_modules node --experimental-vm-modules shine-UI/channel-design-check.mjs +// Зависимости проверки (не приложения): jsdom, postcss. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +const require = createRequire(path.join(process.env.SHINE_UI_TEST_DEPS || process.cwd() + '/node_modules', '_check.cjs')); +const { JSDOM } = require('jsdom'); +const postcss = require('postcss'); +const ui = path.dirname(fileURLToPath(import.meta.url)); +const dom = new JSDOM('
', { url: 'https://shine.test/#channel', pretendToBeVisual: true, runScripts: 'outside-only' }); +const context = dom.getInternalVMContext(); +const { window } = dom; +const { document } = window; +const state = { session: { login: 'alice', isAuthorized: true }, entrySettings: {} }; +let placed = 0; +let authPrompts = 0; +const mocks = { + 'arweave-attachment-manager.js': { markArweaveAttachmentPlaced: () => placed++, openArweaveAttachmentManager: async () => ({ name: 'photo.jpg', size: 1024, ar: 'test' }) }, + 'attachment-format.js': { MAX_MESSAGE_ATTACHMENTS: 10, composeMessageWithAttachments: (text) => text }, + 'state.js': { state }, + 'ui-error-texts.js': { toUserMessage: (error) => error.message }, + 'avatar-image.js': { renderUserAvatar: () => document.createElement('span') }, + 'auth-required-modal.js': { openAuthRequiredModal: () => authPrompts++ }, +}; +async function moduleAt(relative, mockImports = false) { + const module = new vm.SourceTextModule(fs.readFileSync(path.join(ui, relative), 'utf8'), { context }); + await module.link(async (specifier) => { + const values = mockImports && mocks[path.basename(specifier)]; + assert.ok(values, `Неожиданный импорт ${specifier}`); + return new vm.SyntheticModule(Object.keys(values), function () { + for (const [key, value] of Object.entries(values)) this.setExport(key, value); + }, { context }); + }); + await module.evaluate(); + return module.namespace; +} +const tick = () => new Promise((resolve) => setTimeout(resolve, 35)); +const query = (selector) => document.querySelector(selector); +function input(value) { + const field = query('textarea'); + field.value = value; + field.dispatchEvent(new window.Event('input', { bubbles: true })); +} +function key(key, options = {}) { + const event = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options }); + document.activeElement.dispatchEvent(event); + return event; +} + +const { openChannelEditor } = await moduleAt('js/components/channel-editor.js', true); +let sent = []; +const options = { key: 'channel:one:message:1', onSubmit: async (value) => sent.push(value) }; +query('#opener').focus(); +let editor = openChannelEditor(options); +await tick(); +assert.equal(document.activeElement.tagName, 'TEXTAREA'); +assert.equal(query('.channel-editor__submit').disabled, true); +input('Первая строка\nВторая строка'); +assert.equal(key('Enter').defaultPrevented, false); +assert.equal(sent.length, 0); +editor.close(); +await tick(); +assert.equal(document.activeElement.id, 'opener'); +editor = openChannelEditor(options); +await tick(); +assert.equal(query('textarea').value, 'Первая строка\nВторая строка'); +key('Enter', { ctrlKey: true, isComposing: true }); +assert.equal(sent.length, 0); +key('Enter', { ctrlKey: true }); +await tick(); +assert.equal(sent.length, 1); +assert.equal(query('.channel-editor-overlay'), null); +editor = openChannelEditor(options); +assert.equal(query('textarea').value, ''); +editor.close(); +await tick(); + +editor = openChannelEditor({ ...options, onSubmit: async () => { throw new Error('Нет соединения'); } }); +await tick(); +input('Не потерять'); +query('.channel-editor__submit').click(); +await tick(); +assert.equal(query('[role="alert"]').textContent, 'Нет соединения'); +assert.equal(query('textarea').value, 'Не потерять'); +assert.equal(query('.channel-editor__submit').disabled, false); +query('.channel-editor__close').focus(); +key('Tab', { shiftKey: true }); +assert.equal(document.activeElement, query('.channel-editor__submit')); +editor.close(); +await tick(); +state.session.login = 'bob'; +editor = openChannelEditor(options); +assert.equal(query('textarea').value, ''); +editor.close(); +await tick(); +state.session.login = 'alice'; +editor = openChannelEditor(options); +assert.equal(query('textarea').value, 'Не потерять'); +query('.channel-editor__clear').click(); +assert.equal(query('textarea').value, ''); +query('.channel-editor__attach').click(); +await tick(); +assert.equal(document.querySelectorAll('.channel-editor__attachment').length, 1); +assert.equal(query('.channel-editor__submit').disabled, false); +query('.channel-editor__submit').click(); +await tick(); +assert.equal(placed, 1); +editor = openChannelEditor(options); +await tick(); +window.history.back(); +await tick(); +assert.equal(query('.channel-editor-overlay'), null); +editor.close(); +state.session.isAuthorized = false; +assert.equal(openChannelEditor(options), null); +assert.equal(authPrompts, 1); + +const theme = await moduleAt('js/services/theme-service.js'); +assert.equal(theme.applyThemeMode('system').resolved, 'dark'); +theme.setThemeMode('light'); +assert.equal(document.documentElement.dataset.theme, 'light'); +theme.setThemeMode('dark'); +assert.equal(document.documentElement.dataset.theme, 'dark'); +const scroll = await moduleAt('js/services/channel-view-state.js'); +query('#app-screen').scrollTop = 123; +scroll.rememberChannelPosition('alice:channel:one'); +query('#app-screen').scrollTop = 0; +scroll.restoreChannelPosition(scroll.readChannelPosition('alice:channel:one')); +assert.equal(query('#app-screen').scrollTop, 123); +assert.equal(scroll.readChannelPosition('bob:channel:one'), undefined); + +const { createDropdownMenu } = await moduleAt('js/components/dropdown-menu.js'); +const menu = createDropdownMenu({ anchorEl: query('#opener'), items: [{ label: 'Первый' }, { label: 'Второй' }] }); +menu.open(); +assert.equal(document.activeElement.textContent, 'Первый'); +key('ArrowDown'); +assert.equal(document.activeElement.textContent, 'Второй'); +key('Escape'); +assert.equal(document.activeElement.id, 'opener'); +menu.destroy(); +menu.destroy(); +assert.equal(query('.dropdown-portal'), null); +console.log('PASS: редактор — клавиши, фокус, черновики, аккаунты, ошибка, вложение, отправка, Назад; темы, позиция чтения, меню.'); + +// DOM producer страниц исполняется с изолированным API: тест не пишет в блокчейн. +let replyOptions; +const reactionState = new Map(); +const iconModule = await moduleAt('js/components/ui-icon.js'); +const pageValues = { + state, authService: {}, + channels: [], + readChannelNotificationsState: () => ({}), + createSkeletonCard: () => document.createElement('div'), + createTopBar: ({ center }) => { + const header = document.createElement('header'); + if (center) header.append(center); + return header; + }, + iconHtml: iconModule.iconHtml, + parseMessageAttachments: (text) => ({ text: text || '', attachments: [] }), + parseDmTechBlocks: (text) => ({ displayText: text, visibleText: text }), + loadProfileSnapshot: async () => null, + renderAvatar: () => document.createElement('span'), + renderUserAvatar: () => document.createElement('span'), + formatRelativeTime: () => 'сейчас', + escapeHtml: (text) => String(text || ''), + openChannelEditor: (options) => { replyOptions = options; }, + getMessageReactionState: (target) => reactionState.get(target.blockHash) || 'unliked', + setMessageReactionState: (target, value) => reactionState.set(target.blockHash, value), + makeShineMessageRoute: () => 'thread:test', + attachMessageMenu: (card, head, items) => { + const button = document.createElement('button'); + head.append(button); + card.testMenu = items; + const menu = createDropdownMenu({ anchorEl: button, items }); + card.cleanup = () => menu.destroy(); + }, +}; +async function pageProducer(file, exported) { + const source = fs.readFileSync(path.join(ui, 'js/pages', file), 'utf8'); + const imports = new Map(); + for (const match of source.matchAll(/import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) { + imports.set(match[2], [...new Set([...(imports.get(match[2]) || []), ...match[1].split(',').map((name) => name.trim().split(/\s+as\s+/)[0]).filter(Boolean)])]); + } + const module = new vm.SourceTextModule(source + (exported === 'render' ? '' : `\nexport { ${exported} };`), { context }); + await module.link(async (specifier) => { + const names = imports.get(specifier); + assert.ok(names, specifier); + return new vm.SyntheticModule(names, function () { + names.forEach((name) => this.setExport(name, pageValues[name] || (() => {}))); + }, { context }); + }); + await module.evaluate(); + return module.namespace[exported]; +} +state.session.isAuthorized = true; +state.session.login = 'alice'; +const ref = { blockchainName: 'alice-1', blockNumber: 2, blockHash: 'a'.repeat(64) }; +let likes = 0; +let navigations = 0; +const handlers = { selector: { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, navigate: () => navigations++, onToggleLike: async () => likes++, onReply: async () => {}, onEdit: async () => {}, isActive: () => true }; +const renderPost = await pageProducer('channel-view.js', 'renderPostCard'); +const post = renderPost({ body: 'Публикация', authorLogin: 'alice', localNumber: 1, messageRef: ref, isOwnMessage: true, msgSubType: 10, likesCount: 7, repliesCount: 2 }, handlers); +document.body.append(post); +assert.equal(post.querySelector('.channel-message-body').textContent, 'Публикация'); +assert.equal(post.querySelectorAll('.channel-action-counter').length, 3); +assert.ok(post.testMenu.some((item) => item.label === 'Редактировать')); +assert.ok(post.testMenu.some((item) => item.label === 'Удалить')); +post.querySelector('.channel-action-like').click(); +await tick(); +assert.equal(likes, 1); +assert.equal(post.querySelector('.channel-action-like').disabled, false); +post.querySelector('.channel-action-reply').click(); +assert.equal(replyOptions.context.author, 'alice'); +assert.equal(replyOptions.context.text, 'Публикация'); +assert.equal(navigations, 0); +post.cleanup(); post.cleanup(); post.remove(); +const renderNode = await pageProducer('channel-thread-view.js', 'renderNodeCard'); +const node = renderNode({ authorBlockchainName: ref.blockchainName, messageRef: ref, authorLogin: 'bob', text: 'Ответ', msgSubType: 10, likesCount: 3 }, '', handlers, 2); +document.body.append(node); +assert.equal(node.querySelector('.channel-message-body').textContent, 'Ответ'); +assert.ok(!node.testMenu.some((item) => item.label === 'Удалить')); +node.querySelector('.thread-reply-btn').click(); +assert.equal(replyOptions.context.author, 'bob'); +node.cleanup(); node.cleanup(); node.remove(); +console.log('PASS: карточки канала/ветки — текст, общий лайк без диалога, контекст ответа, меню по авторству, cleanup.'); + +pageValues.authService.onEvent = () => () => {}; +pageValues.toUserMessage = (error) => error.message; +pageValues.rememberChannelPosition = scroll.rememberChannelPosition; +pageValues.readChannelPosition = scroll.readChannelPosition; +pageValues.restoreChannelPosition = scroll.restoreChannelPosition; +state.channelsFeed = {}; +state.channelIndex = {}; +const chrome = { setTopbar() {}, setComposer() {} }; +for (const [file, method, params, payload] of [ + ['channels-list.js', 'listSubscriptionsFeed', {}, { ownedChannels: [], followedUsersChannels: [], followedChannels: [] }], + ['channel-view.js', 'getChannelMessages', { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, { channel: { ownerLogin: 'alice', channelName: 'news' }, messages: [] }], + ['channel-thread-view.js', 'getMessageThread', { messageBlockchainName: 'alice-1', messageBlockNumber: 2, messageBlockHash: ref.blockHash }, { focus: null, descendants: [], ancestors: [] }], +]) { + let finish; + pageValues.authService[method] = () => new Promise((resolve) => { finish = resolve; }); + const render = await pageProducer(file, 'render'); + const screen = render({ route: { params }, navigate() {}, chrome }); + query('#app-screen').append(screen); + await tick(); + assert.ok(finish, `${file}: запрос начат`); + screen.cleanup(); + screen.cleanup(); + const markup = screen.innerHTML; + finish(payload); + await tick(); + assert.equal(screen.innerHTML, markup, `${file}: async после dispose`); + assert.equal(query('#app-screen').firstElementChild, screen, `${file}: root identity`); + screen.remove(); + pageValues.authService[method] = async () => payload; + const loaded = render({ route: { params }, navigate() {}, chrome }); + query('#app-screen').append(loaded); + await tick(); + const expected = file === 'channels-list.js' ? '.channels-empty-state' : file === 'channel-view.js' ? '.channel-feed' : '.thread-block'; + assert.ok(loaded.querySelector(expected), `${file}: успешная загрузка ${loaded.textContent}`); + if (loaded.refresh) await loaded.refresh(); + assert.equal(query('#app-screen').firstElementChild, loaded, `${file}: refresh сохраняет root`); + loaded.cleanup(); loaded.remove(); +} +console.log('PASS: список/канал/ветка — стабильный root, идемпотентный cleanup, поздний API-ответ после dispose.'); + +function walk(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)]); +} +const scripts = walk(path.join(ui, 'js')).filter((file) => file.endsWith('.js')); +for (const file of scripts) { + const source = fs.readFileSync(file, 'utf8'); + for (const match of source.matchAll(/(?:from\s*|import\s*\()(['"])(\.[^'"]+)\1/g)) { + assert.ok(fs.existsSync(path.resolve(path.dirname(file), match[2].split(/[?#]/)[0])), `${file}: ${match[2]}`); + } +} +const repo = path.dirname(ui); +const changed = execFileSync('git', ['ls-files', '--modified', '--others', '--exclude-standard'], { cwd: repo, encoding: 'utf8' }).trim().split('\n'); +let cssCount = 0; +for (const relative of changed) { + const file = path.join(repo, relative); + if (relative.endsWith('.js')) execFileSync('node', ['--input-type=module', '--check'], { input: fs.readFileSync(file) }); + if (!relative.startsWith('shine-UI/styles/') || !relative.endsWith('.css')) continue; + cssCount++; + const source = fs.readFileSync(file, 'utf8'); + const tree = postcss.parse(source, { from: file }); + let baseline = ''; try { baseline = execFileSync('git', ['show', `HEAD:${relative}`], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch {} + const baselineRules = new Map(); + postcss.parse(baseline).walkRules((rule) => baselineRules.set(rule.selector, (baselineRules.get(rule.selector) || 0) + 1)); + const rules = new Map(); + const duplicates = []; + tree.walkRules((rule) => { + const parents = []; for (let node = rule.parent; node; node = node.parent) if (node.type === 'atrule') parents.push(node.name + ':' + node.params); + const key = parents.join('/') + ':' + rule.selector; + const count = (rules.get(key) || 0) + 1; + rules.set(key, count); + if (count > Math.max(1, baselineRules.get(rule.selector) || 0)) duplicates.push(key); + }); + assert.deepEqual(duplicates, [], `Новые повторы selectors: ${relative}`); + assert.ok((source.match(/!important/g) || []).length <= (baseline.match(/!important/g) || []).length, `Вырос !important: ${relative}`); +} +const html = fs.readFileSync(path.join(ui, 'index.html'), 'utf8'); +const cssManifest = [...html.matchAll(/['"]\.\/(styles\/[^'"]+\.css)['"]/g)].map((match) => match[1]); +assert.equal(new Set(cssManifest).size, cssManifest.length); +for (const file of cssManifest) assert.ok(fs.existsSync(path.join(ui, file)), `CSS manifest: ${file}`); +assert.ok(cssManifest.includes('styles/components/channel-editor.css')); +console.log(`PASS: импорты ${scripts.length} JS; синтаксис изменённых JS; ${cssCount} CSS — parser, дубликаты selectors, !important; CSS manifest.`); +dom.window.close(); diff --git a/shine-UI/index.html b/shine-UI/index.html index db9c5a00..55d2ed5c 100644 --- a/shine-UI/index.html +++ b/shine-UI/index.html @@ -4,7 +4,7 @@ @@ -12,13 +12,28 @@ СИЯНИЕ + + + + +
+
+
SHiNE prototype viewer
+

SHiNE Solana Arweave Viewer

+

+ Статическая страница для прямого чтения shine_users из Solana, + с настройками RPC и gateway, которые можно передавать через URL и сохранять в браузере. +

+
+ +
+
+
+

Настройки доступа

+

Параметры можно сохранить локально и переиспользовать при следующем открытии.

+
+ +
+ + + + + + + + + + + + +
+ Быстрые действия +
+ + + +
+
+ +
+ Проверка доступности +
+ + + + +
+
+
+ +
Готово к проверке и загрузке.
+
+ +
+
+

Текущее состояние

+

Короткая сводка по найденной записи пользователя.

+
+
+
+ Логин + — +
+
+ PDA + — +
+
+ Версия записи + — +
+
+ Блокчейн + — +
+
+ Arweave TX + — +
+
+ Последний блок + — +
+
+
+ +
+
+

Полезные ссылки

+

Ссылки формируются из текущих настроек и содержимого PDA.

+
+ +
+ +
+
+

Содержимое PDA

+

Распарсенные данные записи пользователя из программы shine_users.

+
+
Пока ничего не загружено.
+
+ +
+
+

Содержимое блокчейна

+

Viewer скачивает закреплённый блокчейн пользователя из Arweave и локально собирает каналы без сервера SHiNE.

+
+
Сначала загрузите PDA пользователя, затем нажмите «Показать содержимое».
+ +
+
+
+ + diff --git a/shine-UI/static-sites/arweave-viewer/index.html b/shine-UI/static-sites/arweave-viewer/index.html new file mode 100644 index 00000000..6e0c4bac --- /dev/null +++ b/shine-UI/static-sites/arweave-viewer/index.html @@ -0,0 +1,3314 @@ + + + + + + SHiNE Arweave Viewer v3 + + + + + +
+ +
SHiNE Viewer
+
+
Готово
+ + diff --git a/shine-UI/static-sites/design-examples/channels-v1/index.html b/shine-UI/static-sites/design-examples/channels-v1/index.html new file mode 100644 index 00000000..f74d5db0 --- /dev/null +++ b/shine-UI/static-sites/design-examples/channels-v1/index.html @@ -0,0 +1,34 @@ + + + + + + SHiNE — четыре направления UI + + + + +
+ ✳ SHiNEDESIGN STUDY / 01 + Концепции · сентябрь 2026 +
+
+
+
КАНАЛЫ / СООБЩЕНИЯ / ОТВЕТЫ

Ближе к разговору.

Четыре характера. Один привычный путь от канала к ответу.
Выберите экран и сравните — или нажмите на элементы внутри макета.

+
01—04

Меньше визуального шума.
Больше места людям и мыслям.

+
+
+
+ + + + +
+
Все варианты ↗
+
+ +
С ЧЕГО НАЧАТЬ ОБСУЖДЕНИЕ

Сначала удобство. Затем характер.

01 / ЧитаемостьГде комфортнее читать длинный пост и замечать новый ответ?

02 / ПлотностьНужны просторные сообщения или больше содержимого на одном экране?

03 / Окно ответаПолный экран или лист поверх ветки? Проверьте с макетом клавиатуры.

Демонстрационные данные. Отправка и реакции работают только внутри этой страницы. Поиск фильтрует примеры. Другие разделы приложения не проектировались. Дизайн-документ ↗

+
+
SHiNE / пространство для общенияРабочий материал · не финальный дизайн
+ + diff --git a/shine-UI/static-sites/design-examples/channels-v1/mockups.css b/shine-UI/static-sites/design-examples/channels-v1/mockups.css new file mode 100644 index 00000000..c55409c9 --- /dev/null +++ b/shine-UI/static-sites/design-examples/channels-v1/mockups.css @@ -0,0 +1,212 @@ +* { box-sizing: border-box; } +:root { color-scheme: light; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #242622; background: #f1f0eb; } +body { margin: 0; } +button, input, textarea, select { font: inherit; } +button, a, select { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +button:disabled { cursor: default; opacity: .4; } +button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid #728de3; outline-offset: 3px; } +a { color: inherit; } +.studio-header { margin: 0 42px; padding: 26px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #d5d6ce; gap: 16px; } +.brand { display: flex; align-items: center; gap: 9px; font-size: 25px; font-weight: 760; text-decoration: none; letter-spacing: -1px; } +.brand-spark { font-size: 36px; font-weight: 400; } +.brand-caption { margin-left: 22px; font-size: 10px; letter-spacing: 2px; font-weight: 600; } +.draft-label { font-size: 12px; color: #63675f; } +main { padding: 0 42px; max-width: 1900px; margin: auto; } +.intro { display: flex; justify-content: space-between; align-items: end; padding: 48px 0 34px; gap: 30px; } +.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 1.8px; } +h1 { font-size: clamp(34px, 4vw, 60px); line-height: 1.1; letter-spacing: -2.8px; font-weight: 550; margin: 16px 0; } +.intro p { color: #62665d; font-size: 14px; line-height: 1.7; margin: 0; } +.intro-note { border-left: 1px solid #c6c8bd; padding-left: 24px; min-width: 250px; } +.intro-note > span { font-size: 38px; letter-spacing: -2px; } +.intro-note p { font-size: 12px; margin-top: 10px; } +.review-bar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; padding: 16px 0; border-top: 1px solid #d5d6ce; border-bottom: 1px solid #d5d6ce; } +.screen-switch { display: flex; flex-wrap: wrap; gap: 4px; } +.review-button { border: 0; padding: 11px 14px; background: transparent; border-radius: 7px; font-size: 12px; color: #62665d; min-height: 42px; } +.review-button.selected { background: #292d27; color: white; } +.review-options { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; font-size: 12px; color: #62665d; } +.review-options label { display: flex; align-items: center; gap: 6px; } +.review-options select { padding: 8px; border: 1px solid #d5d6ce; border-radius: 7px; background: transparent; color: #242622; } +.compare-link { display: none; } +.gallery { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; padding: 30px 0 36px; align-items: start; } +.concept { min-width: 0; } +.concept-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.concept-number { font-size: 11px; border: 1px solid #c9cdc2; border-radius: 50%; width: 27px; height: 27px; display: grid; place-items: center; } +.concept-heading h2 { font-size: 17px; font-weight: 600; margin: 0; letter-spacing: -.5px; } +.open-concept { margin-left: auto; padding: 10px; text-decoration: none; font-size: 18px; } +.concept-description { font-size: 12px; line-height: 1.6; color: #6b7065; min-height: 40px; margin: 0 0 18px; } +.phone { --bg: #111212; --surface: #1c1d1d; --text: #f3f3f1; --muted: #a0a2a0; --line: #2c2d2d; --accent: #f3f3f1; --on-accent: #161717; --soft: #252727; position: relative; display: flex; flex-direction: column; height: 760px; width: 100%; max-width: 410px; margin: auto; color: var(--text); background: var(--bg); border: 1px solid #bfc2b8; border-radius: 32px; overflow: hidden; box-shadow: 0 14px 35px #252d2210; font-size: 14px; line-height: 1.45; isolation: isolate; } +.phone[data-theme="warm"] { --bg: #faf7f0; --surface: #fffdf8; --text: #302c25; --muted: #7b7264; --line: #e6dfd1; --accent: #93511e; --on-accent: #fffaf3; --soft: #efe6d6; } +.phone[data-theme="night"] { --bg: #101b20; --surface: #17272d; --text: #e8f2f1; --muted: #98adaf; --line: #2a3b40; --accent: #94e1ce; --on-accent: #102c27; --soft: #213b3a; } +.phone[data-theme="club"] { --bg: #f9fafb; --surface: #fff; --text: #202b3c; --muted: #6b7688; --line: #e2e7ee; --accent: #4c5caa; --on-accent: #fff; --soft: #eaeefa; } +.status-bar { display: flex; justify-content: space-between; padding: 15px 24px 6px; font-size: 11px; font-weight: 700; flex: 0 0 38px; } +.status-icons { display: flex; gap: 5px; align-items: center; font-size: 10px; } +.icon { height: 21px; width: 21px; display: inline-block; vertical-align: middle; flex-shrink: 0; } +.status-icons .icon { height: 16px; width: 16px; } +.phone button { color: inherit; } +.icon-btn { background: transparent; border: 0; width: 44px; height: 44px; flex: 0 0 44px; display: inline-grid; place-items: center; border-radius: 50%; padding: 0; } +.icon-btn:hover { background: var(--soft); } +.app-header { display: flex; align-items: center; padding: 4px 12px 10px; gap: 6px; min-height: 66px; border-bottom: 1px solid var(--line); } +.header-text { flex: 1; min-width: 0; } +.header-text h3 { font-size: 17px; letter-spacing: -.4px; margin: 0; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.header-text small { color: var(--muted); font-size: 10px; } +.list-header { padding-left: 22px; } +.list-header h3 { font-size: 25px; letter-spacing: -.9px; } +.mini-brand { color: var(--accent); font-size: 26px; line-height: 1; margin-right: 3px; } +.view { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; overscroll-behavior: contain; } +.search-field { margin: 15px 18px 10px; display: flex; gap: 9px; align-items: center; background: var(--surface); border-radius: 12px; padding: 0 12px; border: 1px solid var(--line); color: var(--muted); } +.search-field input { width: 100%; min-width: 0; border: 0; background: transparent; height: 42px; outline-offset: 0; color: var(--text); font-size: 13px; } +.search-field input::placeholder { color: var(--muted); } +.filter-tabs { display: flex; padding: 0 18px 12px; gap: 7px; } +.filter { font-size: 11px; padding: 0 11px; min-height: 36px; background: transparent; border: 1px solid transparent; border-radius: 20px; color: var(--muted); } +.filter[aria-pressed="true"] { color: var(--text); background: var(--soft); } +.section-label { padding: 10px 20px 7px; display: flex; justify-content: space-between; text-transform: uppercase; letter-spacing: 1.3px; font-size: 9px; font-weight: 700; color: var(--muted); } +.channel-row { display: flex; width: 100%; align-items: center; gap: 12px; padding: 17px 18px; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; } +.channel-row:hover { background: var(--soft); } +.avatar { width: 40px; height: 40px; border-radius: 50%; background: #bdc5a8; color: #283124; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; font-size: 13px; font-weight: 650; letter-spacing: -.5px; } +.avatar.city { background: #ded3bb; color: #635039; } +.avatar.blue { background: #bac5d9; color: #354663; } +.avatar.rose { background: #d9bcbb; color: #704444; } +.avatar.green { background: #b9cdc0; color: #345747; } +.avatar.violet { background: #cbc2d9; color: #56476c; } +.avatar.self { background: var(--soft); color: var(--accent); } +.channel-row .avatar { width: 48px; height: 48px; font-size: 23px; } +.channel-copy { min-width: 0; flex: 1; } +.channel-copy strong { display: block; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.channel-copy small { display: block; color: var(--muted); font-size: 10px; margin: 2px 0; } +.channel-copy p { margin: 3px 0 0; font-size: 12px; color: var(--muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.channel-tail { display: flex; align-items: end; flex-direction: column; gap: 11px; align-self: stretch; padding-top: 2px; color: var(--muted); font-size: 10px; } +.badge { min-width: 20px; height: 20px; background: var(--accent); color: var(--on-accent); padding: 2px 6px; border-radius: 10px; text-align: center; font-size: 10px; font-weight: 700; } +.list-tip { padding: 24px 24px 16px; color: var(--muted); font-size: 11px; text-align: center; } +.bottom-nav { border-top: 1px solid var(--line); display: flex; justify-content: space-around; flex: 0 0 73px; align-items: start; padding: 9px 6px 12px; background: var(--bg); } +.nav-item { border: 0; background: transparent; display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 8px; width: 20%; min-height: 44px; opacity: .65; } +.nav-item[aria-current="page"] { opacity: 1; color: var(--accent); } +.nav-item[aria-current="page"] .icon { background: var(--soft); border-radius: 7px; box-shadow: 0 0 0 5px var(--soft); } +.channel-intro { margin: 0; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); } +.channel-intro p { margin: 0 0 12px; color: var(--muted); font-size: 12px; line-height: 1.6; } +.channel-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.text-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; padding: 0 4px; min-height: 36px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 600; } +.text-button .icon { width: 16px; height: 16px; } +.pill { border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 8px 11px; font-size: 10px; min-height: 36px; } +.unread-line { display: flex; align-items: center; gap: 10px; color: var(--accent); font-size: 9px; padding: 13px 20px 0; } +.unread-line::before, .unread-line::after { content: ""; height: 1px; flex: 1; background: var(--line); } +.post { padding: 18px 18px 10px; border-bottom: 1px solid var(--line); } +.post-header { display: flex; align-items: center; gap: 9px; } +.post-header .avatar { width: 34px; height: 34px; font-size: 11px; } +.author { min-width: 0; flex: 1; font-size: 12px; } +.author strong { font-weight: 650; } +.author small { color: var(--muted); font-size: 10px; display: block; } +.post .more { width: 36px; height: 36px; flex-basis: 36px; color: var(--muted); } +.post p { font-size: 14px; line-height: 1.6; margin: 10px 0 12px; overflow-wrap: anywhere; } +.post-title { font-weight: 600; } +.landscape { height: 142px; overflow: hidden; border-radius: 12px; position: relative; background: #c9d6cf; margin: 10px 0 8px; } +.landscape svg { width: 100%; height: 100%; display: block; } +.media-caption { position: absolute; bottom: 10px; left: 12px; font-size: 9px; letter-spacing: 1px; color: #fff; text-transform: uppercase; } +.post-actions { display: flex; gap: 14px; align-items: center; color: var(--muted); } +.action { border: 0; background: transparent; min-height: 44px; display: flex; align-items: center; gap: 6px; padding: 0 2px; font-size: 11px; color: var(--muted); } +.action[aria-pressed="true"] { color: #dc7477; } +.action[aria-pressed="true"] .icon { fill: currentColor; } +.reply-link { margin-left: auto; font-size: 10px; } +.attachment { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); background: var(--surface); border-radius: 12px; padding: 12px; margin: 10px 0; } +.attachment > .icon { color: var(--accent); } +.attachment strong { font-size: 11px; display: block; } +.attachment small { color: var(--muted); display: block; font-size: 10px; } +.composer-bar { display: flex; align-items: center; padding: 10px 16px; gap: 10px; border-top: 1px solid var(--line); background: var(--bg); flex: 0 0 auto; } +.composer-trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 46px; padding: 10px 14px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); border-radius: 24px; text-align: left; font-size: 12px; } +.composer-trigger.primary { background: var(--accent); color: var(--on-accent); border-color: transparent; justify-content: center; font-weight: 650; } +.thread-heading { padding: 13px 20px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; border-bottom: 1px solid var(--line); } +.thread-heading strong { color: var(--text); } +.reply { display: flex; padding: 16px 18px 0; gap: 10px; } +.reply-body { flex: 1; min-width: 0; padding-bottom: 9px; border-bottom: 1px solid var(--line); } +.reply .avatar { width: 32px; height: 32px; font-size: 10px; } +.reply p { font-size: 13px; margin: 6px 0 2px; line-height: 1.55; overflow-wrap: anywhere; } +.reply small { color: var(--muted); font-size: 10px; } +.reply strong { font-size: 12px; } +.reply .post-actions { gap: 16px; } +.reply-context { color: var(--accent); font-size: 10px; } +.empty { padding: 40px 25px; text-align: center; color: var(--muted); font-size: 13px; } +.overlay { position: absolute; inset: 38px 0 0; z-index: 5; background: #0009; display: flex; flex-direction: column; justify-content: end; } +.sheet { display: flex; flex-direction: column; background: var(--bg); max-height: 100%; min-height: 78%; border-radius: 24px 24px 0 0; overflow: hidden; box-shadow: 0 -8px 40px #0002; } +.sheet.full { height: 100%; border-radius: 0; } +.sheet-handle { width: 32px; height: 4px; border-radius: 4px; background: var(--line); align-self: center; margin-top: 9px; } +.sheet-header { display: flex; align-items: center; padding: 7px 12px; border-bottom: 1px solid var(--line); gap: 6px; } +.sheet-header h4 { margin: 0; flex: 1; text-align: center; font-size: 15px; } +.sheet-header .expand { color: var(--muted); } +.editor-scroll { overflow-y: auto; flex: 1; min-height: 0; padding: 18px; } +.source-preview { display: flex; gap: 10px; padding-bottom: 20px; position: relative; } +.source-preview::after { content: ""; position: absolute; left: 19px; top: 46px; bottom: 3px; width: 1px; background: var(--line); } +.source-preview p { font-size: 12px; color: var(--muted); margin: 5px 0; line-height: 1.5; } +.source-preview strong { font-size: 12px; } +.source-preview small { color: var(--muted); font-size: 10px; } +.editor-row { display: flex; gap: 10px; } +.editor-main { flex: 1; min-width: 0; } +.editor-main strong { display: block; font-size: 12px; margin: 2px 0; } +.editor-main > small { color: var(--muted); display: block; font-size: 10px; margin: 3px 0 10px; } +.editor-input { resize: none; border: 0; width: 100%; min-height: 115px; background: transparent; color: var(--text); font-size: 15px; line-height: 1.6; padding: 3px 0; } +.editor-input::placeholder { color: var(--muted); } +.editor-input:focus-visible { outline: none; box-shadow: 0 2px 0 var(--accent); } +.editor-footer { display: flex; align-items: center; gap: 4px; padding: 11px 16px 16px; border-top: 1px solid var(--line); background: var(--bg); } +.char-count { color: var(--muted); font-size: 10px; margin-left: auto; margin-right: 10px; } +.submit { border: 0; border-radius: 22px; padding: 12px 18px; background: var(--accent); color: var(--on-accent); font-size: 12px; font-weight: 650; min-height: 44px; } +.phone .submit { color: var(--on-accent); } +.editor-attachment { margin-top: 10px; display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: var(--soft); border-radius: 10px; font-size: 10px; } +.editor-attachment span { flex: 1; overflow-wrap: anywhere; } +.keyboard { flex: 0 0 auto; background: var(--surface); padding: 10px 4px 18px; border-top: 1px solid var(--line); } +.keyboard-label { font-size: 9px; text-align: center; color: var(--muted); margin-bottom: 10px; } +.keys { display: flex; justify-content: center; gap: 3px; margin: 5px 0; } +.keys span { display: grid; place-items: center; height: 31px; flex: 1; max-width: 29px; border-radius: 5px; font-size: 13px; color: var(--text); background: var(--soft); box-shadow: 0 1px 0 #0003; } +.keys .space { max-width: 150px; flex: 5; font-size: 10px; } +.keys .wide { max-width: 45px; flex: 1.7; font-size: 11px; } +.menu-sheet { min-height: 0; padding-bottom: 24px; } +.menu-content { padding: 10px 20px; } +.menu-action { border: 0; background: transparent; width: 100%; text-align: left; display: flex; gap: 12px; align-items: center; min-height: 47px; font-size: 13px; border-bottom: 1px solid var(--line); } +.menu-content p { color: var(--muted); font-size: 12px; line-height: 1.6; } +.toast { position: absolute; bottom: 86px; left: 18px; right: 18px; padding: 12px 15px; border-radius: 12px; background: var(--text); color: var(--bg); z-index: 10; font-size: 12px; box-shadow: 0 4px 20px #0003; } +.concept-notes { margin-top: 19px; font-size: 12px; color: #666d61; line-height: 1.65; } +.concept-notes strong { color: #33392f; font-weight: 600; } +.swatches { display: flex; gap: 5px; margin-bottom: 12px; } +.swatches span { width: 19px; height: 19px; border-radius: 50%; border: 1px solid #0002; } +.concept-tag { display: inline-block; border: 1px solid #c9cdc2; padding: 3px 7px; border-radius: 4px; font-size: 9px; letter-spacing: .3px; margin-bottom: 8px; } +.decision-note { padding: 36px 0; border-top: 1px solid #d5d6ce; } +.decision-note h2 { font-weight: 500; letter-spacing: -.7px; font-size: 27px; margin: 13px 0; } +.decision-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 38px; } +.decision-grid p { font-size: 13px; line-height: 1.7; color: #62665d; } +.decision-grid b { display: block; font-weight: 600; color: #33392f; margin-bottom: 6px; } +.disclaimer { font-size: 11px; color: #62665d; line-height: 1.7; margin-top: 24px; } +.studio-footer { display: flex; justify-content: space-between; margin: 0 42px; padding: 22px 0; border-top: 1px solid #d5d6ce; color: #73796b; font-size: 10px; gap: 15px; } +/* Варианты отличаются также плотностью, иерархией и формой редактора. */ +.phone[data-theme="mono"] .post { padding-left: 18px; } +.phone[data-theme="mono"] .post-content { margin-left: 43px; } +.phone[data-theme="mono"] .landscape { height: 128px; } +.phone[data-theme="warm"] .channel-row { margin: 0 12px 8px; width: calc(100% - 24px); border: 1px solid var(--line); border-radius: 16px; padding: 14px 12px; background: var(--surface); } +.phone[data-theme="warm"] .channel-intro { background: var(--soft); margin: 14px 16px 0; border: 0; border-radius: 16px; } +.phone[data-theme="warm"] .post { margin: 14px 12px; padding: 15px 14px 7px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); } +.phone[data-theme="warm"] .post-title { font-family: Georgia, serif; font-size: 20px; line-height: 1.3; } +.phone[data-theme="warm"] .reply .avatar { border-radius: 12px; } +.phone[data-theme="night"] .list-header { border-bottom: 0; padding-top: 12px; } +.phone[data-theme="night"] .channel-intro { background: linear-gradient(125deg, #1d3937, #16272e); border-bottom: 1px solid #36544f; } +.phone[data-theme="night"] .channel-row { border-bottom-color: #21343b; } +.phone[data-theme="night"] .channel-row:first-child { background: #172b2e; box-shadow: inset 3px 0 var(--accent); } +.phone[data-theme="night"] .composer-trigger { border-radius: 13px; } +.phone[data-theme="night"] .post-title { font-size: 17px; font-weight: 550; } +.phone[data-theme="club"] .channel-row { padding: 12px 16px; gap: 10px; } +.phone[data-theme="club"] .channel-row .avatar { width: 42px; height: 42px; border-radius: 13px; } +.phone[data-theme="club"] .channel-copy small { display: none; } +.phone[data-theme="club"] .filter-tabs { border-bottom: 1px solid var(--line); padding-bottom: 0; gap: 12px; } +.phone[data-theme="club"] .filter { border-radius: 0; padding: 0 1px; } +.phone[data-theme="club"] .filter[aria-pressed="true"] { background: transparent; color: var(--accent); border-bottom: 2px solid var(--accent); } +.phone[data-theme="club"] .post { padding: 12px 16px 5px; } +.phone[data-theme="club"] .post p { font-size: 13px; margin: 7px 0; } +.phone[data-theme="club"] .landscape { height: 108px; } +.phone[data-theme="club"] .channel-intro { padding: 12px 16px 8px; } +.phone[data-theme="club"] .channel-intro p { margin-bottom: 5px; } +.phone[data-theme="club"] .reply { padding-top: 12px; } +.phone[data-theme="club"] .reply-body { border-left: 2px solid var(--line); border-bottom: 0; padding-left: 12px; } +.phone[data-theme="club"] .composer-trigger { border-radius: 10px; } +.gallery.single { display: block; max-width: 410px; margin: auto; } +.single .concept-description { min-height: 0; } +body.focused .compare-link { display: inline; } +@media (max-width: 1490px) { .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 900px; margin: auto; gap: 40px; } .concept { max-width: 410px; width: 100%; justify-self: center; } } +@media (max-width: 760px) { .studio-header { margin: 0 20px; padding: 18px 0; } main { padding: 0 20px; } .brand-caption, .draft-label, .intro-note { display: none; } .intro { padding: 30px 0 24px; } h1 { letter-spacing: -1.5px; } .intro p { font-size: 13px; } .gallery { grid-template-columns: minmax(0, 1fr); gap: 32px; } .screen-switch { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .review-button { padding: 10px; } .review-options { gap: 12px; } .phone { height: 760px; border-radius: 25px; } .decision-grid { grid-template-columns: 1fr; gap: 0; } .studio-footer { margin: 0 20px; } } +@media (max-width: 380px) { main { padding: 0 10px; } .post-actions { gap: 10px; } .phone { font-size: 13px; } .channel-row { padding-left: 12px; padding-right: 12px; gap: 8px; } .header-text h3 { font-size: 15px; } .concept-heading h2 { font-size: 16px; } } +@media (prefers-reduced-motion: no-preference) { .sheet { animation: appear .18s ease-out; } @keyframes appear { from { transform: translateY(22px); opacity: .5; } to { transform: translateY(0); opacity: 1; } } } diff --git a/shine-UI/static-sites/design-examples/channels-v1/mockups.js b/shine-UI/static-sites/design-examples/channels-v1/mockups.js new file mode 100644 index 00000000..8c97645d --- /dev/null +++ b/shine-UI/static-sites/design-examples/channels-v1/mockups.js @@ -0,0 +1,246 @@ +/* Изолированный прототип: без API, импортов приложения и постоянного хранилища. */ +'use strict'; +const icons = { + back: '', + close: '', + more: '', + search: '', + plus: '', + heart: '', + chat: '', + share: '', + channels: '', + people: '', + bell: '', + user: '', + book: '', + file: '', + attach: '', + image: '', + expand: '', + check: '', + edit: '', + wifi: '', + battery: '', +}; +const icon = name => ``; +const escapeText = value => String(value).replace(/[&<>"']/g, c => ({'&':'&', '<':'<', '>':'>', '"':'"', "'":'''}[c])); +const button = (action, label, name, extra = '') => ``; +const concepts = [ + { id:'mono', number:'01', title:'Чистый монохром', description:'Контент на первом плане. Знакомый ритм Threads.', colors:['#111212','#2c2d2d','#f3f3f1'], tag:'МАКСИМАЛЬНО ПРИВЫЧНО', note:'Плоская лента, аватар на полях и полноэкранный ответ. Самая нейтральная отправная точка.' }, + { id:'warm', number:'02', title:'Тёплый SHiNE', description:'Бумажные оттенки. Больше воздуха и мягкости.', colors:['#faf7f0','#efe6d6','#93511e'], tag:'МЯГКИЙ И ЛИЧНЫЙ', note:'Карточки отделяют разговоры, тёплый акцент выделяет действия. Ответ открывается округлым листом снизу.' }, + { id:'night', number:'03', title:'Ночной SHiNE', description:'Глубокий синий. Спокойный свет в деталях.', colors:['#101b20','#213b3a','#94e1ce'], tag:'ХАРАКТЕР SHiNE', note:'Широкий текст и выразительная шапка канала. Мой кандидат на развитие: узнаваемый, но сдержанный.' }, + { id:'club', number:'04', title:'Компактный клуб', description:'Больше разговоров на одном экране.', colors:['#f9fafb','#eaeefa','#4c5caa'], tag:'ПЛОТНО И СОБРАННО', note:'Компактные строки, подчёркнутые вкладки и линии ответов. Редактор на весь экран, без потери контекста.' }, +]; +const channels = [ + { title:'Город и люди', owner:'anna', slug:'city', symbol:'⌂', color:'city', preview:'Маленькие открытия по дороге домой', time:'12 мин', unread:3, following:true }, + { title:'Дизайн каждый день', owner:'mikhail', slug:'design', symbol:'Aa', color:'blue', preview:'Как сделать сложное простым?', time:'38 мин', unread:7, following:true }, + { title:'Тихие маршруты', owner:'lena', slug:'walks', symbol:'↗', color:'green', preview:'Сохранили маршрут на выходные', time:'1 ч', unread:2, following:true }, + { title:'Книжная полка', owner:'olga', slug:'books', symbol:'≋', color:'rose', preview:'Что сейчас читаете?', time:'2 ч', unread:0, following:true }, + { title:'SHiNE · новости', owner:'shine', slug:'news', symbol:'✳', color:'violet', preview:'Место, где начинаются разговоры', time:'вчера', unread:0, following:false }, + { title:'Мои заметки', owner:'you', slug:'notes', symbol:'✎', color:'blue', preview:'Идея для следующей встречи', time:'вчера', unread:0, following:false }, +]; +let role = 'reader'; +let showKeyboard = false; +const states = new Map(); +const mainText = 'Иногда лучший маршрут — чуть длиннее обычного. Сегодня свернула к реке и нашла это место. А где вы перезагружаетесь после рабочего дня?'; +const secondText = 'Собрала наши любимые места в один список. Добавляйте свои находки в ответах!'; +const landscape = () => ``; +function header(s) { + if (s.screen === 'list') return `
✳

Каналы

${button('new-channel','Создать канал','plus')}
`; + return `
${button('back','Назад','back')}

${s.screen === 'thread' ? 'Обсуждение' : escapeText(s.channel.title)}

${s.screen === 'thread' ? escapeText(s.channel.title) : '@'+escapeText(s.channel.owner)+' / '+escapeText(s.channel.slug)}
${button('notifications',s.muted ? 'Включить уведомления' : 'Настроить уведомления','bell')}${button('channel-menu','Меню канала','more')}
`; +} +function nav() { + return ``; +} +function rows(s) { + const filtered = channels.filter(c => (s.filter!=='mine'||c.owner==='you') && (s.filter!=='following'||c.following) && `${c.title} ${c.owner}`.toLowerCase().includes(s.query.toLowerCase())); + return filtered.length ? filtered.map(c=>``).join('') : '

Каналы не найдены.
Попробуйте другое название или логин.

'; +} +function list(s) { + return `
${[['all','Все'],['following','Подписки'],['mine','Мои']].map(([value,label])=>``).join('')}
${rows(s)}

Хорошие разговоры начинаются
с общих интересов.

`; +} +function actions(s, id, count, replies) { + const liked = s.likes.has(id); + replies = 2 + s.replies.filter(reply => reply.post === id).length; + return `
`; +} +function post(s, second = false) { + return `
АК
${escapeText(s.channel.owner==='anna'?'Анна К.':s.channel.owner)}${second?'вчера, 19:10':'12 минут назад'}
${second?'

Собрала наши любимые места в один список. Добавляйте свои находки в ответах!

'+icon('file')+'
Места для прогулок.pdfPDF · 552 КБ
':`

Маленькие открытия

${mainText}

${landscape()}`}${actions(s, second?'second':'first',second?12:24, second?3:2+s.replies.length)}
`; +} +function feed(s) { + return `

Замечаем красоту рядом. Делимся местами,
историями и маленькими открытиями.

Новые сообщения
${post(s)}${post(s,true)}`; +} +function replyRow(name, initials, color, text, target='') { + return `
${initials}
${target?`
В ответ ${escapeText(target)}
`:''}${escapeText(name)} · сейчас

${escapeText(text)}

`; +} +function thread(s) { + const replies=s.replies.filter(r=>r.post===s.activePost); + const second=s.activePost==='second'; + return `${post(s,second)}
Ответы · ${2+replies.length}По порядку
${replyRow('Михаил','МЛ','blue',second?'Спасибо за список! Добавлю ещё парк у старого моста.':'У воды всегда легче выдохнуть. Это возле старого моста?')}${replyRow('Лена','ЛС','green',second?'В выходные попробую один из маршрутов.':'А я ухожу гулять без наушников. Город звучит совсем иначе.')}${replies.map(r=>replyRow('Вы','ВЫ','self',r.text,r.target)).join('')}`; +} +function composer(s) { + if (s.screen==='list') return ''; + if (s.screen==='feed' && role==='reader') return ''; + return `
`; +} +function render(s) { + const previousView = s.phone.querySelector('.view'); + s.scrolls ||= {}; + if (previousView && s.renderedScreen) s.scrolls[s.renderedScreen] = previousView.scrollTop; + s.phone.innerHTML = `
9:41${icon('wifi')}${icon('battery')}
${header(s)}
${s.screen==='list'?list(s):s.screen==='feed'?feed(s):thread(s)}
${composer(s)}${nav()}`; + if(s.screen==='feed') for(const text of s.posts) { + const item=document.createElement('article'); item.className='post'; + const author=document.createElement('strong'); author.textContent='Вы · сейчас'; + const body=document.createElement('p'); body.textContent=text; item.append(author,body); + s.phone.querySelector('.view').append(item); + } + s.phone.querySelector('.view').scrollTop = s.scrolls[s.screen] || 0; + s.renderedScreen = s.screen; + if (s.editor) mountEditor(s); +} +function keyboard() { + return ``; +} +function mountEditor(s) { + const mode = s.editor; + const full = s.id==='mono'||s.id==='club'||s.expanded; + const overlay = document.createElement('div'); + overlay.className = 'overlay'; + overlay.innerHTML = ``; + s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=true); + s.phone.append(overlay); + overlay.querySelector('.sheet').focus({preventScroll:true}); +} +function attachmentMarkup(s) { + const name = s.attachments[s.editor]; + return name?`
${icon('file')}${escapeText(name)}${button('remove-attachment','Удалить вложение','close')}
`:''; +} +function removeOverlay(s) { + s.phone.querySelector('.overlay')?.remove(); + s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=false); +} +function closeEditor(s) { + s.editor = null; + removeOverlay(s); + if (s.returnFocus?.isConnected) s.returnFocus.focus({preventScroll:true}); + else s.phone.querySelector('[data-action="reply"], [data-action="new-post"], [data-action="back"]')?.focus({preventScroll:true}); +} +function openEditor(s, mode='reply', author=s.channel.owner, text=s.activePost==='second'?secondText:mainText, trigger=null) { + if (s.editor) closeEditor(s); + s.editor=mode; + s.replyTo=author; + s.replyText=text; + s.returnFocus=trigger; + s.expanded=false; + mountEditor(s); +} +function toast(s, message) { + s.phone.querySelector('.toast')?.remove(); + clearTimeout(s.toastTimer); + const el=document.createElement('div'); + el.className='toast'; el.setAttribute('role','status'); el.textContent=message; + s.phone.append(el); + s.toastTimer=setTimeout(()=>el.remove(),3500); +} +function menu(s, title, contents, trigger) { + removeOverlay(s); + s.returnFocus=trigger; + const el=document.createElement('div'); el.className='overlay'; + el.innerHTML=``; + s.phone.querySelectorAll(':scope > *').forEach(child=>child.inert=true); + s.phone.append(el); el.querySelector('.sheet').focus({preventScroll:true}); +} +const menuAction=(action,label,i='file')=>``; +function updateEditor(s) { + const modal=s.phone.querySelector('.overlay'); + modal.querySelector('.char-count').textContent=`${s.drafts[s.editor].length} / 2000`; + modal.querySelector('[data-action="send"]').disabled=!s.drafts[s.editor].trim()&&!s.attachments[s.editor]; + modal.querySelector('.selected-attachment').innerHTML=attachmentMarkup(s); +} +function handle(s, event) { + const el=event.target.closest('[data-action]'); + if (!el || el.disabled) return; + const action=el.dataset.action; + if (action==='filter') { s.filter=el.dataset.value; render(s); } + else if (action==='open-channel') { s.channel=channels[Number(el.dataset.index)]; s.screen='feed'; s.activePost='first'; s.replies=[]; s.posts=[]; s.likes.clear(); render(s); } + else if (action==='list'||action==='back') { s.screen=action==='list'||s.screen==='feed'?'list':'feed'; render(s); } + else if (action==='thread') { s.activePost=el.dataset.post||s.activePost; s.screen='thread'; render(s); } + else if (action==='reply'||action==='new-post') { s.activePost=el.dataset.post||s.activePost; openEditor(s,action==='new-post'?'post':'reply',s.channel.owner,s.activePost==='second'?secondText:mainText,el); } + else if (action==='reply-to') openEditor(s,'reply',el.dataset.author,el.dataset.text,el); + else if (action==='close-editor') { closeEditor(s); toast(s,'Черновик сохранён до закрытия страницы'); } + else if (action==='expand') { s.expanded=!s.expanded; removeOverlay(s); mountEditor(s); } + else if (action==='attach') s.phone.querySelector('[data-file]').click(); + else if (action==='remove-attachment') { s.attachments[s.editor]=''; updateEditor(s); } + else if (action==='send') { + const mode=s.editor; + const text=s.drafts[mode].trim(); + const attachment=s.attachments[mode]; + if (!text&&!attachment) return; + if (mode==='reply') s.replies.push({text:text+(attachment?'\n📎 '+attachment:''),target:s.replyTo,post:s.activePost}); + else s.posts.push(text+(attachment?'\n📎 '+attachment:'')); + s.drafts[mode]=''; s.attachments[mode]=''; s.editor=null; s.screen=mode==='reply'?'thread':'feed'; render(s); + const view=s.phone.querySelector('.view'); view.scrollTop=view.scrollHeight; + s.phone.querySelector('.composer-trigger')?.focus({preventScroll:true}); toast(s,'Отправлено в макете. Данные на сервер не передаются.'); + } + else if (action==='like') { + const key=el.dataset.post; const liked=s.likes.has(key); liked?s.likes.delete(key):s.likes.add(key); + el.setAttribute('aria-pressed',String(!liked)); el.querySelector('span').textContent=String(Number(el.querySelector('span').textContent)+(liked?-1:1)); + } + else if (action==='share') menu(s,'Поделиться', '

В приложении здесь появится системное меню отправки ссылки. В макете внешние ссылки не создаются.

'+menuAction('close-menu','Понятно','check'),el); + else if (action==='subscribe') { s.subscribed=!s.subscribed; el.textContent=s.subscribed?'Вы подписаны':'Подписаться'; } + else if (action==='notifications') { s.muted=!s.muted; el.setAttribute('aria-label',s.muted?'Включить уведомления':'Настроить уведомления'); toast(s,s.muted?'Уведомления канала выключены в макете':'Уведомления канала включены в макете'); } + else if (action==='post-menu') menu(s,'Сообщение',menuAction('reactions','Кто поставил лайк','heart')+menuAction('details','Данные записи','file')+(role==='owner'?menuAction('edit-demo','Редактировать','edit'):'')+'

Редкие действия собраны здесь, чтобы оставлять больше места самому сообщению.

',el); + else if (action==='channel-menu') menu(s,s.channel.title,menuAction('about','О канале','channels')+menuAction('contents','Оглавление','book')+menuAction('notifications','Уведомления','bell'),el); + else if (action==='contents') menu(s,'Оглавление','

С чего начать знакомство с каналом

'+menuAction('jump-post','01 · Маленькие открытия','book')+menuAction('jump-file','02 · Наш список любимых мест','file'),el); + else if (action==='jump-post'||action==='jump-file') { removeOverlay(s); s.screen='feed'; render(s); s.phone.querySelectorAll('.post')[action==='jump-file'?1:0]?.scrollIntoView({block:'nearest'}); } + else if (action==='reactions') menu(s,'Лайки','

Все: 24 · основные: 18 · сияющие: 6

Названия категорий сохранены из текущего приложения. Их объяснение пользователю нужно согласовать отдельно.

',el); + else if (action==='details') menu(s,'Данные записи','

Здесь доступны номер записи, цепочка автора, хэш и подпись. В мокапе технические данные не генерируются.

',el); + else if (action==='about') menu(s,'О канале','

Замечаем красоту рядом. Делимся местами, историями и маленькими открытиями.

Автор: @'+escapeText(s.channel.owner)+'

',el); + else if (action==='close-menu') { removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); } + else if (action==='new-channel') toast(s,'Создание канала — следующий этап проектирования'); + else if (action==='edit-demo') toast(s,'Редактор существующего сообщения — следующий этап'); + else if (action==='outside') toast(s,'Сейчас проектируем каналы. Этот раздел пока вне макета.'); +} +function attachEvents(s) { + s.phone.addEventListener('click',event=>handle(s,event)); + s.phone.addEventListener('input',event=>{ + if(event.target.matches('[data-search]')) { s.query=event.target.value; s.phone.querySelector('.channel-rows').innerHTML=rows(s); } + if(event.target.matches('.editor-input')) { s.drafts[s.editor]=event.target.value; updateEditor(s); } + }); + s.phone.addEventListener('change',event=>{ + if(event.target.matches('[data-file]')) { s.attachments[s.editor]=event.target.files[0]?.name||''; updateEditor(s); } + }); + s.phone.addEventListener('keydown',event=>{ + const dialog=s.phone.querySelector('[role="dialog"]'); + if(!dialog) return; + if(event.key==='Escape') { event.preventDefault(); s.editor?closeEditor(s):removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); } + if(event.key==='Tab') { + const focusable=[...dialog.querySelectorAll('button:not(:disabled), textarea, input:not([hidden])')]; + const first=focusable[0], last=focusable.at(-1); + if(event.shiftKey&&(document.activeElement===first||document.activeElement===dialog)) { event.preventDefault(); last?.focus(); } + else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===dialog)) { event.preventDefault(); first?.focus(); } + } + }); +} +function init() { + const query=new URLSearchParams(location.search); + const chosen=concepts.find(c=>c.id===query.get('variant')); + const gallery=document.getElementById('gallery'); + if(chosen) {gallery.classList.add('single');document.body.classList.add('focused');} + for(const c of chosen?[chosen]:concepts) { + const article=document.createElement('section');article.className='concept';article.setAttribute('aria-label',c.title); + article.innerHTML=`
${c.number}

${c.title}

↗

${c.description}

${c.tag}
${c.note}
`; + gallery.append(article); + const s={id:c.id,phone:article.querySelector('.phone'),screen:'list',activePost:'first',channel:channels[0],query:'',filter:'all',likes:new Set(),subscribed:true,muted:false,replies:[],posts:[],drafts:{reply:'',post:''},attachments:{reply:'',post:''},editor:null}; + states.set(c.id,s);render(s);attachEvents(s); + } + document.querySelectorAll('[data-screen]').forEach(el=>el.addEventListener('click',()=>{ + document.querySelectorAll('[data-screen]').forEach(b=>{b.classList.toggle('selected',b===el);b.setAttribute('aria-pressed',String(b===el));}); + for(const s of states.values()) { s.editor=null;s.screen=el.dataset.screen==='reply'?'thread':el.dataset.screen;render(s);if(el.dataset.screen==='reply')openEditor(s); } + el.focus({preventScroll:true}); + })); + document.getElementById('role').addEventListener('change',event=>{role=event.target.value;for(const s of states.values())render(s);}); + document.getElementById('keyboard').addEventListener('change',event=>{showKeyboard=event.target.checked;for(const s of states.values())if(s.editor){removeOverlay(s);mountEditor(s);}event.target.focus({preventScroll:true});}); +} +init(); diff --git a/shine-UI/static-sites/index.html b/shine-UI/static-sites/index.html new file mode 100644 index 00000000..6b1cb93b --- /dev/null +++ b/shine-UI/static-sites/index.html @@ -0,0 +1,98 @@ + + + + + + SHiNE static sites + + + +
+

SHiNE static sites

+

Рабочая папка для временных статических сайтов, дизайн-примеров и standalone-viewer'ов.

+ + +
+ + diff --git a/shine-solana-arweave-viewer-v3/README.md b/shine-solana-arweave-viewer-v3/README.md new file mode 100644 index 00000000..eccebca9 --- /dev/null +++ b/shine-solana-arweave-viewer-v3/README.md @@ -0,0 +1,57 @@ +# SHiNE Arweave Viewer v2 — ANS-104 / Frame v1 + +Новая версия viewer для формата, где каждый пользовательский блок хранится в Arweave как отдельный ANS-104 DataItem, а `data` DataItem содержит один `SHiNE Frame v1`. + +Основной файл: `index.html`. + +## Быстрый запуск + +Через логин (ключ берётся из Solana PDA): + +```text +index.html?login=aidarkc&channel=books +``` + +Напрямую через blockchain public key, без Solana: + +```text +index.html?key=&channel=books +``` + +Дополнительные параметры: + +```text +&arweaveGateway=https://arweave.net +&solanaRpc=https://api.mainnet-beta.solana.com +``` + +`channel` можно не передавать. Тогда viewer ищет все DataItem пользователя с `App=test5590` и строит список каналов из найденных блоков. + +## Что делает viewer + +1. Если передан `login`, вычисляет PDA `user_login=` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`. +2. Если передан `key`, использует его напрямую и не обращается к Solana. +3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`. +4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c=`. +5. Загружает payload каждого DataItem через gateway `/`. +6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes). +7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1. +8. Восстанавливает канал/сообщения тем же body parser, который использовал старый viewer. + +## Важно + +При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально. + +Старый `shine-solana-arweave-viewer/` не изменяется; новая версия лежит отдельно в `shine-solana-arweave-viewer-v2/`. + + +## Проверка данных (v3.3) +Нижняя строка показывает краткий итог доступных проверок. Нажатие на неё или `⋮ → Проверка данных` открывает отдельный экран: +- локально вычисленный SHA-256 каждого SHiNE Frame; +- `prevHash` общей цепочки, когда предыдущий глобальный блок загружен; +- `prevLineHash` канальной цепочки, когда указанный предыдущий блок загружен; +- наличие DataItem в выборке Arweave owner соответствующего blockchain key; +- разрывы «нет данных» отдельно от доказанных несовпадений; +- честный статус ANS-104 signature: gateway payload/GraphQL не содержит сырой подписи, поэтому она не помечается как локально перепроверенная. + +`○ нет данных` не является ошибкой. `⚠` появляется только при фактическом несовпадении доступных хэшей. diff --git a/shine-solana-arweave-viewer-v3/README_V3.md b/shine-solana-arweave-viewer-v3/README_V3.md new file mode 100644 index 00000000..5511797a --- /dev/null +++ b/shine-solana-arweave-viewer-v3/README_V3.md @@ -0,0 +1,26 @@ +# SHiNE Arweave Viewer v3 + +Viewer нового per-action Arweave формата SHiNE. + +## URL +- `?login=&channel=` — получает blockchain key через Solana PDA, затем читает Arweave. +- `?key=&channel=` — Arweave запускается напрямую; Solana не является блокирующей зависимостью. +- без параметров — стартовая форма (логин или blockchain key + необязательный канал). + +## v3 +- переключатель `Канал / Блоки`; +- рекурсивные комментарии скрыты до раскрытия, на любой глубине; +- light/dark тема; +- меню `⋮`: кэш, Solana RPC, Arweave gateway; +- выключение кэша удаляет сохранённые данные каналов; +- одна нижняя status bar, по нажатию — подробный журнал; +- GraphQL читает от новых Arweave записей к старым (`HEIGHT_DESC`); +- до 6 параллельных загрузок Frame; +- отображение известных типов и raw-представление любых неизвестных блоков в режиме `Блоки`; +- лайки намеренно не отображаются. + +Кэш использует browser localStorage и сериализует BigInt-поля Frame явно. Тема/RPC/gateway являются настройками и не удаляются при отключении кэша. + +## V3.1 — streaming reverse loading + +Arweave history is now processed page-by-page in `HEIGHT_DESC` order. The first GraphQL page (up to 100 DataItems) is downloaded, parsed and rendered immediately. Older pages are then fetched and merged in the background. The bottom status line shows the currently available block range while history expands toward the beginning. Each completed page is also persisted when cache is enabled. diff --git a/shine-solana-arweave-viewer-v3/history/v1/README.md b/shine-solana-arweave-viewer-v3/history/v1/README.md new file mode 100644 index 00000000..01f0fcab --- /dev/null +++ b/shine-solana-arweave-viewer-v3/history/v1/README.md @@ -0,0 +1,11 @@ +# SHiNE Solana Arweave Viewer + +Основной рабочий файл: + +- `index.html` + +Подробное описание работы, параметров и готовых ссылок: + +- `КАК_ЭТО_РАБОТАЕТ.md` + +Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей. diff --git a/shine-solana-arweave-viewer-v3/history/v1/index.html b/shine-solana-arweave-viewer-v3/history/v1/index.html new file mode 100644 index 00000000..72484911 --- /dev/null +++ b/shine-solana-arweave-viewer-v3/history/v1/index.html @@ -0,0 +1,2358 @@ + + + + + + SHiNE Solana Arweave Viewer + + + + + +
+
+
SHiNE prototype viewer
+

SHiNE Solana Arweave Viewer

+

+ Статическая страница для прямого чтения shine_users из Solana, + с настройками RPC и gateway, которые можно передавать через URL и сохранять в браузере. +

+
+ +
+
+
+

Настройки доступа

+

Параметры можно сохранить локально и переиспользовать при следующем открытии.

+
+ +
+ + + + + + + + + + + + +
+ Быстрые действия +
+ + + +
+
+ +
+ Проверка доступности +
+ + + + +
+
+
+ +
Готово к проверке и загрузке.
+
+ +
+
+

Текущее состояние

+

Короткая сводка по найденной записи пользователя.

+
+
+
+ Логин + — +
+
+ PDA + — +
+
+ Версия записи + — +
+
+ Блокчейн + — +
+
+ Arweave TX + — +
+
+ Последний блок + — +
+
+
+ +
+
+

Полезные ссылки

+

Ссылки формируются из текущих настроек и содержимого PDA.

+
+ +
+ +
+
+

Содержимое PDA

+

Распарсенные данные записи пользователя из программы shine_users.

+
+
Пока ничего не загружено.
+
+ +
+
+

Содержимое блокчейна

+

Viewer скачивает закреплённый блокчейн пользователя из Arweave и локально собирает каналы без сервера SHiNE.

+
+
Сначала загрузите PDA пользователя, затем нажмите «Показать содержимое».
+ +
+
+
+ + diff --git a/shine-solana-arweave-viewer-v3/index.html b/shine-solana-arweave-viewer-v3/index.html new file mode 100644 index 00000000..446463c4 --- /dev/null +++ b/shine-solana-arweave-viewer-v3/index.html @@ -0,0 +1,3306 @@ + + + + + + SHiNE Arweave Viewer v3 + + + + + +
+ +
SHiNE Viewer
+
+
Готово
+ + diff --git a/tools/test-publisher/AGENTS.md b/tools/test-publisher/AGENTS.md new file mode 100644 index 00000000..d4ea31e7 --- /dev/null +++ b/tools/test-publisher/AGENTS.md @@ -0,0 +1,41 @@ +# AGENTS.md — SHiNE test channels + +Эта папка — тестовый генератор реальной пользовательской активности SHiNE. Это не часть консенсуса и не отдельный протокол. + +## Назначение +Один тестовый пользователь публикует заранее подготовленные POST-блоки сразу в несколько своих каналов. Все POST должны идти через обычный SHiNE `AddBlock`, поэтому сервер проверяет их как обычные пользовательские блоки и затем использует штатную Arweave-публикацию. + +## Почему publisher один +У всех каналов одного пользователя одна глобальная blockchain-цепочка (`blockNumber + prevHash`). Нельзя запускать независимый publisher на каждый канал: они могут одновременно построить следующий глобальный блок. `publisher_multi.py` сериализует публикации всех каналов, но хранит отдельные `prevLineNumber/prevLineHash` и позиции очередей. + +## Каналы в примере +- `jokes` — короткие анекдоты; часть постов содержит 1–2 изображения. +- `mindfulness` — короткие практики осознанности. +- `positive` — позитивные и мотивирующие напоминания. +- `friends` — советы об общении и дружбе, в духе классических принципов человеческого общения, без выдачи текста за дословные цитаты. +- `wisdom` — короткие духовные/созерцательные мысли. Не придумывать цитаты и не приписывать человеку слова без источника. + +## Изображения +`prepare/prepare_all_channels.py` проходит все `channels[].queue_file`. +Для каждого локального файла: +1. вычисляет SHA-256; +2. смотрит общий `upload-state.json`; +3. если ID уже известен — проверяет `https://gateway/`; +4. если файл существует — повторно НЕ загружает; +5. если отсутствует/ID неизвестен — загружает через Arweave JWK; +6. пишет `.prepared.json`, добавляя постоянные Arweave URL в текст POST и сохраняя `images[]` как служебные метаданные. + +JWK используется только подготовщиком. Не помещать приватный JWK или blockchain key в git/архив. + +## State +`multi-state.json` содержит один global head и отдельное состояние каждого канала. Pending DataItem записывается до `AddBlock`; при неопределённом состоянии глобальной цепочки publisher обязан остановиться, а не строить форк. + +## Не менять +- SHiNE Frame v1 и его SHA-256. +- `prevHash` глобальной цепочки. +- `prevLineNumber/prevLineHash` канальной линии. +- ANS-104 signing/deepHash без сверки с серверной реализацией. +- `App=test5590` и `c=` без согласованного изменения импортера/Viewer. + +## Viewer +Viewer распознаёт Arweave URL в тексте POST и показывает изображения inline. Экран «Проверка данных» независимо считает SHA-256 Frame, проверяет доступные `prevHash` и `prevLineHash`, показывает отсутствие соседнего блока как «нет данных», а доказанное несовпадение — как ошибку. Подпись ANS-104 нельзя помечать локально проверенной, если gateway не предоставил сырую подпись DataItem. diff --git a/tools/test-publisher/README.md b/tools/test-publisher/README.md new file mode 100644 index 00000000..d62d4824 --- /dev/null +++ b/tools/test-publisher/README.md @@ -0,0 +1,21 @@ +# SHiNE multi-channel test publisher v3 + +В комплекте сохранены v1 и v2. Основной режим v3 — файлы `v2/publisher/publisher_multi.py` и `v2/prepare/prepare_all_channels.py`. + +## Быстрый порядок +1. Создать тестового пользователя и пять каналов на сервере. +2. Заполнить реальные `channel_root_block_number` в `config.multi.json`. +3. Положить blockchain key в `keys/blockchain.json`. +4. Только для подготовки картинок положить JWK в `keys/arweave-wallet.json`. +5. Скопировать `prepare.multi.example.json` → `prepare.multi.json`, затем: + `python prepare/prepare_all_channels.py --config prepare.multi.json` +6. Проверить созданные `channels/*.prepared.json`. +7. Скопировать `config.multi.example.json` → `config.multi.json`. +8. Проверка: `python publisher/publisher_multi.py status --config config.multi.json` +9. Один тестовый пост: `python publisher/publisher_multi.py publish-now --channel jokes --config config.multi.json` +10. Постоянный режим: `python publisher/publisher_multi.py run --config config.multi.json` + +Повторный запуск подготовки безопасен: известные картинки сначала проверяются на gateway. Существующие не загружаются повторно. + +## v3: media + расписание + VPS +У всех пяти каналов теперь есть media-примеры: в обучающих каналах по 18 иллюстрированных постов (12×1, 4×2, 2×3 картинки). По умолчанию обучающие каналы публикуют раз в 3–7 активных часов, `jokes` — раз в 1–3 активных часа. Quiet hours 22:00–08:00 не расходуют интервал. Инструкция автономной установки: `v2/CODEX_VPS_SETUP.md`. diff --git a/tools/test-publisher/v1/README.md b/tools/test-publisher/v1/README.md new file mode 100644 index 00000000..774a1baf --- /dev/null +++ b/tools/test-publisher/v1/README.md @@ -0,0 +1,90 @@ +# SHiNE Test Publisher + +Автономный publisher заранее подготовленных постов в существующий канал SHiNE. + +## Что делает + +- публикует следующий элемент `queue/jokes.json` через случайный интервал; +- создаёт настоящий SHiNE Frame v1 + подписанный Ed25519 ANS-104 DataItem; +- отправляет его обычной операцией `AddBlock` на SHiNE-server; +- хранит прогресс в `state.json` и продолжает после рестарта; +- перед отправкой сохраняет `pending`, поэтому падение между отправкой и записью state не должно съедать или дублировать элемент очереди; +- при расхождении head цепочки с state останавливается вместо создания форка; +- очередь по умолчанию НЕ зациклена. + +В комплекте 60 заранее подготовленных нейтральных коротких анекдотов/шуток. + +## Требования + +Python 3.11+. + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +``` + +## Настройка + +```bash +cp config.example.json config.json +``` + +Заполнить: + +- `server_ws` — WebSocket SHiNE-server; +- `blockchain_name` — активная цепочка тестового пользователя; +- `channel_name` — имя существующего канала (его canonical slug = lowercase); +- `channel_root_block_number` — номер CREATE_CHANNEL этого канала; +- `key_file` — Solana JSON blockchain key пользователя; +- интервалы публикации. + +Ключ должен соответствовать owner активной blockchain. Реальный ключ не класть в git, права рекомендуется `chmod 600`. + +При первом запуске `state.json` отсутствует: publisher читает head цепочки и последний POST канала с сервера и создаёт state автоматически. + +## Проверка + +```bash +.venv/bin/python publisher/publisher.py status --config config.json +``` + +`status` при первом запуске также инициализирует state. + +Принудительно опубликовать следующий пост: + +```bash +.venv/bin/python publisher/publisher.py publish-now --config config.json +``` + +Обычный режим: + +```bash +.venv/bin/python publisher/publisher.py run --config config.json +``` + +## systemd + +Скопировать каталог в `/opt/shine-test-publisher`, создать venv и настроить `config.json`, затем: + +```bash +sudo cp systemd/shine-test-publisher.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now shine-test-publisher +journalctl -u shine-test-publisher -f +``` + +Service предполагает пользователя `shine`; поменяйте `User=` при необходимости. + +## state.json + +Не редактировать при запущенном publisher. В нём сохраняются: + +- следующий индекс очереди; +- последний global block/hash; +- последний line number/hash канала; +- время следующей публикации; +- `pending` для crash recovery; +- общее число опубликованных элементов. + +Если очередь закончилась и `loop_queue=false`, новые посты не создаются. Можно дописать новые элементы в JSON; `next_index` продолжит движение дальше. diff --git a/tools/test-publisher/v1/config.example.json b/tools/test-publisher/v1/config.example.json new file mode 100644 index 00000000..329cdb2a --- /dev/null +++ b/tools/test-publisher/v1/config.example.json @@ -0,0 +1,12 @@ +{ + "server_ws": "wss://t3.shineup.me/ws", + "blockchain_name": "testjokes-001", + "channel_name": "jokes", + "channel_root_block_number": 1, + "key_file": "keys/blockchain.json", + "queue_file": "queue/jokes.json", + "state_file": "state.json", + "min_interval_seconds": 1800, + "max_interval_seconds": 10800, + "loop_queue": false +} diff --git a/tools/test-publisher/v1/keys/README.txt b/tools/test-publisher/v1/keys/README.txt new file mode 100644 index 00000000..3b6fa3f5 --- /dev/null +++ b/tools/test-publisher/v1/keys/README.txt @@ -0,0 +1,4 @@ +Put the Solana-format blockchain key JSON here, for example blockchain.json. +The file is an array of 64 bytes [private seed + public key]. +Recommended permissions: chmod 600 keys/blockchain.json +Never commit the real key. diff --git a/tools/test-publisher/v1/publisher/publisher.py b/tools/test-publisher/v1/publisher/publisher.py new file mode 100644 index 00000000..013a87fb --- /dev/null +++ b/tools/test-publisher/v1/publisher/publisher.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from websockets.asyncio.client import connect + +ZERO32 = bytes(32) +FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1 + +def atomic_json(path, obj): + path=Path(path); path.parent.mkdir(parents=True, exist_ok=True) + tmp=path.with_suffix(path.suffix+'.tmp') + tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8') + os.replace(tmp,path) + +def load_json(path, default=None): + p=Path(path) + return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default + +def avro_long(n): + n=(n<<1) ^ (n>>63); out=bytearray() + while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7 + out.append(n); return bytes(out) + +def avro_tags(tags): + out=bytearray() + if tags: + out += avro_long(len(tags)) + for k,v in tags: + kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb + out += avro_long(0); return bytes(out) + +def deep_hash(x): + H=lambda b: hashlib.sha384(b).digest() + if isinstance(x,(bytes,bytearray)): + b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b)) + acc=H(f'list{len(x)}'.encode()) + for child in x: acc=H(acc+deep_hash(child)) + return acc + +def signing_message(owner,tags,data): + raw=avro_tags(tags) + return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data]) + +def data_item(priv64,tags,data): + if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes') + seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw() + sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data)) + rawtags=avro_tags(tags) + return struct.pack('65535: raise ValueError('post text too long') + return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb + +def frame(prev_hash, block_num, body, ts=None): + ts=int(time.time()) if ts is None else int(ts); size=56+len(body) + return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body + +def h32(b): return hashlib.sha256(b).digest() +def hx(b): return b.hex() + +async def ws_call(url, op, payload, timeout=20): + req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload} + async with connect(url, open_timeout=timeout, close_timeout=5) as ws: + await ws.send(json.dumps(req,separators=(',',':'))) + end=time.monotonic()+timeout + while True: + left=end-time.monotonic() + if left<=0: raise TimeoutError(op) + msg=await asyncio.wait_for(ws.recv(),left) + obj=json.loads(msg) + if obj.get('requestId')==req['requestId']: return obj + +async def head(cfg): + r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{}) + if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}') + for x in r.get('payload',{}).get('blockchains',[]): + if x.get('blockchainName')==cfg['blockchain_name']: + return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','') + raise RuntimeError('blockchain not found on server') + +async def block(cfg,n): + r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n}) + if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}') + p=r.get('payload',r) + return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64') + +async def channel_tail(cfg, root_hash): + r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'}) + if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}') + items=r.get('payload',{}).get('messages',[]) + if not items: return 0, ZERO32 + m=items[0]; ref=m.get('messageRef') or {} + return int(m.get('lineStep') or 0), bytes.fromhex(ref.get('blockHash','00'*32)) + +def read_key(path): + a=load_json(path) + if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array') + return bytes(int(x)&255 for x in a) + +def queue_items(cfg): + q=load_json(cfg['queue_file']) + if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array') + for x in q: + if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text') + return q + +def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds'])) + +async def init_state(cfg): + bn,bhash=await head(cfg) + root_hash,_=await block(cfg,int(cfg['channel_root_block_number'])) + line_num,line_hash=await channel_tail(cfg,root_hash) + st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0} + atomic_json(cfg['state_file'],st); return st + +async def reconcile(cfg,st): + p=st.get('pending') + bn,bhash=await head(cfg) + if p: + if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower(): + st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn) + elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower(): + print('Pending block was not accepted; it will be retried') + else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}') + else: + if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork') + return st + +async def publish_one(cfg,st,items): + if st.get('pending'): + p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash'] + else: + idx=int(st['next_index']) + if idx>=len(items): + if cfg.get('loop_queue',False): idx=0 + else: print('Queue exhausted. Nothing to publish.'); return False + text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1 + body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text) + fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())] + raw=data_item(read_key(cfg['key_file']),tags,fr) + nextidx=idx+1 + p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()} + st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash'] + r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30) + if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}') + server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash') + if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash') + st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st) + print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}") + return True + +async def main_async(args): + cfg=load_json(args.config); required=['server_ws','blockchain_name','channel_name','channel_root_block_number','key_file','queue_file','state_file','min_interval_seconds','max_interval_seconds'] + for k in required: + if k not in cfg: raise ValueError('missing config: '+k) + base=Path(args.config).resolve().parent + for k in ('key_file','queue_file','state_file'): + p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p) + items=queue_items(cfg) + st=load_json(cfg['state_file']) + if st is None: st=await init_state(cfg); print('Initialized state from server/channel') + st=await reconcile(cfg,st) + if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return + if args.command=='publish-now': await publish_one(cfg,st,items); return + while True: + st=await reconcile(cfg,load_json(cfg['state_file'])) + wait=max(0,int(st.get('next_publish_at',0))-int(time.time())) + if wait: print(f'Waiting {wait}s'); await asyncio.sleep(wait) + ok=await publish_one(cfg,st,items) + if not ok: await asyncio.sleep(3600) + +def main(): + ap=argparse.ArgumentParser(description='SHiNE test channel publisher') + ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run'); ap.add_argument('--config',default='config.json') + a=ap.parse_args() + try: asyncio.run(main_async(a)) + except KeyboardInterrupt: pass + except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1) +if __name__=='__main__': main() diff --git a/tools/test-publisher/v1/queue/jokes.json b/tools/test-publisher/v1/queue/jokes.json new file mode 100644 index 00000000..bcdb0b0d --- /dev/null +++ b/tools/test-publisher/v1/queue/jokes.json @@ -0,0 +1,182 @@ +[ + { + "text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java." + }, + { + "text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой." + }, + { + "text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра." + }, + { + "text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы." + }, + { + "text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья." + }, + { + "text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен." + }, + { + "text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается." + }, + { + "text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать." + }, + { + "text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный»." + }, + { + "text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть." + }, + { + "text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать." + }, + { + "text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя." + }, + { + "text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее." + }, + { + "text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой." + }, + { + "text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно." + }, + { + "text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд." + }, + { + "text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно." + }, + { + "text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать." + }, + { + "text": "— Что делаешь? — Ничего. — А вчера? — Не закончил." + }, + { + "text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке." + }, + { + "text": "План на день был прекрасен. Жаль, что день о нём ничего не знал." + }, + { + "text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее." + }, + { + "text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты." + }, + { + "text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник." + }, + { + "text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше." + }, + { + "text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди." + }, + { + "text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу." + }, + { + "text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления." + }, + { + "text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука." + }, + { + "text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры." + }, + { + "text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось." + }, + { + "text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта." + }, + { + "text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь." + }, + { + "text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес." + }, + { + "text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду." + }, + { + "text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа." + }, + { + "text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил." + }, + { + "text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%." + }, + { + "text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник." + }, + { + "text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном." + }, + { + "text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут." + }, + { + "text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя." + }, + { + "text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся." + }, + { + "text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием." + }, + { + "text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать." + }, + { + "text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается." + }, + { + "text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы." + }, + { + "text": "— Ты выключил утюг? — Теперь это главная интрига поездки." + }, + { + "text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает." + }, + { + "text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления." + }, + { + "text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю." + }, + { + "text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены." + }, + { + "text": "Микроволновка пищит так, будто лично приготовила ужин с нуля." + }, + { + "text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях." + }, + { + "text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать»." + }, + { + "text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона." + }, + { + "text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду." + }, + { + "text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым." + }, + { + "text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча." + }, + { + "text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло." + } +] \ No newline at end of file diff --git a/tools/test-publisher/v1/requirements.txt b/tools/test-publisher/v1/requirements.txt new file mode 100644 index 00000000..bb18ecc9 --- /dev/null +++ b/tools/test-publisher/v1/requirements.txt @@ -0,0 +1,2 @@ +cryptography>=42 +websockets>=14 diff --git a/tools/test-publisher/v1/systemd/shine-test-publisher.service b/tools/test-publisher/v1/systemd/shine-test-publisher.service new file mode 100644 index 00000000..fd7456aa --- /dev/null +++ b/tools/test-publisher/v1/systemd/shine-test-publisher.service @@ -0,0 +1,17 @@ +[Unit] +Description=SHiNE test channel publisher +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=shine +WorkingDirectory=/opt/shine-test-publisher +ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher.py run --config config.json +Restart=on-failure +RestartSec=15 +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/tools/test-publisher/v2/.gitignore b/tools/test-publisher/v2/.gitignore new file mode 100644 index 00000000..24b25a85 --- /dev/null +++ b/tools/test-publisher/v2/.gitignore @@ -0,0 +1,12 @@ +keys/*.json +!keys/README.txt +state.json +multi-state.json +upload-state.json +channels/*.prepared.json +config.json +config.multi.json +prepare.config.json +prepare.multi.json +__pycache__/ +.venv/ diff --git a/tools/test-publisher/v2/CODEX_VPS_SETUP.md b/tools/test-publisher/v2/CODEX_VPS_SETUP.md new file mode 100644 index 00000000..6f2dadb1 --- /dev/null +++ b/tools/test-publisher/v2/CODEX_VPS_SETUP.md @@ -0,0 +1,87 @@ +# CODEX_VPS_SETUP.md — развернуть автономный SHiNE test publisher + +Цель: установить test publisher на Ubuntu VPS так, чтобы он постоянно работал через systemd, переживал reboot/сбой сети, публиковал в несколько каналов одного тестового пользователя и не требовал ручного запуска. + +## Что агент должен получить от оператора +Никогда не печатай приватные ключи в лог. +1. Реальный `blockchain_name` тестового пользователя. +2. Solana-format JSON blockchain key → `keys/blockchain.json`. +3. Arweave JWK → `keys/arweave-wallet.json` (нужен только для prepare). +4. Для каждого канала реальный `channel_root_block_number` CREATE_CHANNEL. +5. Подтверждение WebSocket URL сервера (example: `wss://t3.shineup.me/ws`). + +## Установка +Предполагаемый каталог: `/opt/shine-test-publisher/v2`, Linux user: `shine`. + +```bash +sudo useradd --system --home /opt/shine-test-publisher --shell /usr/sbin/nologin shine 2>/dev/null || true +sudo mkdir -p /opt/shine-test-publisher +sudo chown -R shine:shine /opt/shine-test-publisher +# скопировать содержимое tools/test-publisher/v2 в /opt/shine-test-publisher/v2 + +cd /opt/shine-test-publisher +sudo -u shine python3 -m venv .venv +sudo -u shine .venv/bin/pip install -r v2/requirements.txt +sudo chmod 700 v2/keys +sudo chmod 600 v2/keys/*.json +``` + +`requirements.txt` должен содержать зависимости publisher и prepare (`cryptography`, `websockets`, `requests`, `arweave-python-client`). + +## Конфиги +Скопировать: +- `config.multi.example.json` → `config.multi.json` +- `prepare.multi.example.json` → `prepare.multi.json` + +Заменить placeholders. Не угадывать CREATE_CHANNEL block numbers. + +Расписание по умолчанию: +- jokes: случайно 1–3 активных часа; +- остальные: случайно 3–7 активных часов; +- timezone: Europe/Warsaw; +- quiet: 22:00–08:00. + +Quiet hours НЕ расходуют интервал. Если из 5 часов до ночи прошло 2 часа, после 08:00 должно остаться 3 часа. + +## Подготовка media +Один раз и затем после добавления новых картинок: +```bash +cd /opt/shine-test-publisher/v2 +../.venv/bin/python prepare/prepare_all_channels.py --config prepare.multi.json +``` +Скрипт обязан пройти ВСЕ channels. Известный Arweave ID сначала проверяется через gateway. Существующий файл повторно не загружается. Отсутствующий — загружается и state обновляется атомарно. + +После успешной подготовки JWK не нужен постоянному publisher. Для минимизации риска можно перенести JWK в защищённое offline-хранилище; тогда повторный prepare потребует вернуть его. + +## Проверка до systemd +```bash +../.venv/bin/python publisher/publisher_multi.py status --config config.multi.json +../.venv/bin/python publisher/publisher_multi.py publish-now --channel jokes --config config.multi.json +``` +Проверить появление блока на SHiNE server и затем в Viewer. Не запускать два экземпляра publisher для одного blockchain key. + +## systemd +```bash +sudo cp systemd/shine-test-publisher-multi.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now shine-test-publisher-multi +sudo systemctl status shine-test-publisher-multi +journalctl -u shine-test-publisher-multi -f +``` + +Unit использует `Restart=on-failure`; publisher хранит pending/state на диске и после рестарта сверяет global head перед продолжением. При конфликте global head он обязан остановиться, а не создавать форк. + +## После настройки +Проверить: +- `systemctl is-enabled` = enabled; +- `systemctl is-active` = active; +- `multi-state.json` создаётся и меняется; +- в quiet hours нет публикаций; +- reboot VPS → service возвращается в active; +- приватные JSON имеют mode 600; +- prepared queues существуют для всех пяти каналов. + +## Запреты +Не коммитить/архивировать реальные keys, JWK, `multi-state.json`, `upload-state.json`. +Не запускать параллельно v1/v2 single publisher и multi publisher с одним ключом. +Не сбрасывать state ради устранения ошибки chain-head: сначала выяснить расхождение. diff --git a/tools/test-publisher/v2/assets/channel-art/friends-coffee.webp b/tools/test-publisher/v2/assets/channel-art/friends-coffee.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..ceae66d97b4785a7ccb8aa052f9e21278f907247ad627bfea2acd2729008b036 GIT binary patch literal 2238 zcmV;v2toH!Nk&Gt2mkd?|CjA&pNK~5E%r-AJ%sQA{73m#>lgs~Y3N-rDG)Lk`JboNDeJ^yd)2hxxB-`QjLL`7ynK|vl+P)C#$5#<;Ad0bB3R};6D#t${)9Krn-WH2fx zUhK_&FGmg@EJLQ;prDT^C?mLO@OWZ>P~%5h4#e&v7`toms5Qjx<#9WBny=CJf=j^; zJ5IFjU%$TcQ@54G?d3Yvo?_!%+971bnjGDgy{rB~{;S;r%iMoY2Pk4m7%fAAaCHzDXKG8n#RG@4AKG74_ zrasm65dH=5vIb6O$zl_q`qJ;0uo}gZvJrb7Z_>Bm+EAR8Pg(IjqUuDBWvE9pn#xTY z)Gzu=3<*sJqy3X@kc~hJ$J!tjn{+}|IDo8uq>a8)IJwl8P379Jgn2g;q%pRTx(go)&It~!-zX~1LI77P!2zOs-ipSa_vG$0peWCmS0RI1vjM-yY z)eSzV+VVg}bT$A`EsnIR*3EcoY^hm@nq@ii2hJ*u0n@Vc8k;Is+e-~og6IDvW6wIx)p|IYq+2|OOaLo7DMENmjM$9f_tk8;hPA$OnxJaD$c7-N_)T_FFE zi&&rOs8H&9h%v!}()OD)-h3NaDq1sZSi+<9_}=#d}iIlewb6581;zE&$f6g5fFDR@;$n zJa>pV9FzS9lBGV-5zgasUPGxgC=()lq!?r%T@52juvr+UQP(_g|1{9EKt5+ zto3uy!Bfep9LWvmcwfhp;INMNT?~+^Gi2||9FH2;vn?4*2j7%L#Lps(fS2ZFdIaQq z3ti_8k}DR@y=$-iO)7^~=&hEkpj|lsAVC#;^Tfs}$cEpfQ=8Gig_Mv^aIbGorp+79 zlCX(76X5%x`WQ14k?se)RWYP6>H94}A@z6SQ@#16ocWD*WS?kX_k=_LrqnWJKG+Ua z9OHNG$NoU0Y)T^>1)F*21wkLz`2R#MGx%HG+`6EjFsp*qrJ0}Dpev!5(~<5rs8vKy zx{m|2-uo@m;KSi2n6Y>5i{FLz7P$1gvT1Nh?~tCjW4kJ8sds-j_icKtt-byj;S;Y4 zj^Ji+AYxkwqGxZm+7(S-EZp0qw2ml|FLT==yus_CHM{!ITX~_# zkGt*~Ygpc|#HJ9}r~7uM;LK2w)D* z&5S@4QJI6pUsY)&yl!GhBzP>kWvSj?v*u~23&dPhPz_BrsG`tVbDHV`nhf_tij0m9 zIH%4;+9*|VBR+qxAGhHfFDV4+<|Qed^gS)qefnn&Rx6BJE8dWa?=a_$!meSpA5n<$ z=TuD8sur3}u}3}Pl|u9#_L_K#46|Q=D^PQ97moSzC$1`N@XzJyV~uCv>jgUKPahct z@QaxG9o>wB{rptVt1ZVss)fkLwP2slm_gy)fJ`+;m);NlGjwf#;2FL`P>9?~iXOFQ zq8$_T{@e>{J*8~EyEmCQMzbf|k>ldH;4z(I7NjlhF{#q)%P&xo+~Us3?q&=m#MEIc ztB@%!91SbiGyg3o3QEU7JTnlN=AsmXC07ncEa`ei(`Ph}e%X`)dBhg8F_rE;9<>Ff z+!AZ*42zAMI>^5{#fEEdHnP%A^K-dReD>@;=oU!WxpnuY*<)0zo+0B5uL=dutPI#0 zmL7NYXSQw;aXV|C>Vs{i9xyLC7Mkcbp?OLWW)#plmJU?dkp70 z$C_C@vb~V;vA-)Fz7FvtCY)ZvpZX7`vM|_hc)K74`7YR4{Z?z$F}*es8#$iNAZ!cw zi>`rej!!JFW*xD=D;>TL@gpXjUm1D#qLPHY%eMkhBczrriD~_L@fNA(^V+fGO(?rA z9g-m|djUFTcKeaOS4G#ChhUBVNhpr-KPQ3rqLPHY%eObq_v=cbmO={_8PmuOWp-DW zwBy^bjEuwpsI`q_Eabq6j<1v{>9kOQd?KW5+md?GyDSD6WPIp(n5f6}{6&NfaB7MT z3XU}4kf%*^XMu;thnYYEP}S;Y9B-md8X?ndneB9H$eSKj!&E9hfqnT%Zp3FyDI5Ok M!rO_!0000000Jse6aWAK literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/assets/channel-art/friends-hello.webp b/tools/test-publisher/v2/assets/channel-art/friends-hello.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..149a980965b9a6cff04665c1c4cbbdae1aaba491a5a820d490ee583541b6afae GIT binary patch literal 2172 zcmV-?2!r=hNk&F=2mk<9q*ljx5yDFAW=`QNAqk+1e2 zv3_H}Isb#~2hrpAL)oNET!$8S$|-w`JLMF;#hvnsUgFOAMK5sXtRn^e&%HH?YEOtf z)&W5nYXce`Hsut(#hvnr<&owsumMuzii*rO1IvfcW?Z*%)9qOL(JAm2ptWKtK?8XI z5pi(DJYTI6pK8a}hl$--eZ63+rx<#l+OJ;Iq)B6YJ&paV9m9V*FeL|mvPbgoo|gwR zoHZ%XIi`J2SLVe$j|H?>(Nt4KSpc9|;>r-2Z=;K0UpYi~9X}vRu z9^B{koCswxF!CN?3-#;^5{Ex_yXYj|y@csq=Cv(KyiIPo6DK31?s+QSfqsK%-)$;* z#kKXjJxEYzvehVEY4H*0pwTJtj1oYdA*o!}iFUByW~+;iePsU#s^mDczI>(bEc-&$ z|5*CbDfX;=Xp+^&S{d$9O0Vq_no&@%pwE#doDsS5AAXI4qD5`7EAVb(DQ!_H@YrRd zVYrEDQ~UeR%kk4xvt2qVh}MZuwPWi$4{ti-1grEAGI+kNcOn#d-@RqJ>nh3p18Rvl>zXsWue;a zdG4bRkZ_h!>fAQrf}0#L4@g{Hq{IYZ=~8%~UUUDfb^FO`4?&D~w?h%=lA+a;G(rL( zAH9b%{wY;;=UP=I1Whm%a?t;vcQNMlOTlvsMk>UgyC273G{qE?5Yve4lUxIir-SUnBtfL zFU*N7O7O_c+jv<7Zyz51)T!k`4vtpewht30Ui902qA8oVG%c(R7FHi^OJifUcGKhc zI}PM1$8+=};WCuM@L)%`BYZir=EZ(P8#8lT(jk`-2Z(aDva4&{%AzZ66(u7Hz&>wN z7LAB&?^V5J{2PlspfzknodJU&F>dGzU2TmuoisV3YkB)|!;;o&kZ#N5cEKw_~Aj-ptV-?U_Sr&nl?h{tN4`@68VXw~Ilhs&knU-*_02R|pQpTNwTC#u_4 z7AxzwBYZ<-e?Q0@9O%@tamO*|vg{%!bqD8PoYZ7Hag5DYe|)hK)%ubenAaGF*k!)4ck)&YqGzi!Ybq-+&Yjic3!F2oh{07o&eE+2j|BG7Fxtt1o6*%E?d^kNeHSmwnZPLg(w_7I4`sNJ$453a+y z$3&-!horhjubC2=fP24S?P`uGVm(kVImbh+Xk(EcD{{tHQ3ydEu~&}VIQJ_rkR133 zWe`#|(a@C31x7OgW(8yTGwfG`E~~~d15G{=8J0bg?s||68)S_Q7ZpHi`^k^VvGw)e zgtPo&?9r88c4Zc5L{Q9wVuW{neJjPWu`7(uS#DC06C>?&e4KSFHUyDmc2p~taW=#Z z)K&}>3nW@HFziT!ta^V<>wIU;=~JQX{TY^LgP%;{bWNafZxcX;WO|l2j_}@{`5q$3 z%=|``2CA)}EtE-y`di960WN zG)x6FIj;R&so!!rBq_MjzQH*_R!lg4N5PccP_fYbT0Cs}yZ^GA zeLaTkA>M01;EKv~@-pZ zA``dM*l!0Rr~X)HmykCY7@=W&)8SOw+W7XjfZB%o1@y91kqNOQ8q*<|Y@zC#;gCe9 zJT~{q;VpRvX|9&ZkwTo49F%HVU#ZU@dIU-}HB26r= zCJIV4V-MfLv%k%+5aknY_s2{VgrbfFf34-aQm^mREpaX>cTY_2vt7-{+2%F4uO@6a yOECmh9>)NXpJt(P#M_3!!!()nz~)1AW34}v=V>-0001EzA`re literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/assets/channel-art/friends-listen.webp b/tools/test-publisher/v2/assets/channel-art/friends-listen.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..4e6bf9ea6619049ef03d989b013a815799272de912c39065a7bf0be560b2c000 GIT binary patch literal 2160 zcmV-$2#@ztNk&F!2mk=g(R(ImR$~h(|laM?IGvIr!U@Rbj5&4xa8OU0y^=p)kZd>C05_L%ufEhW|KDhtro|Yh+sJrka37h* z4?%Q%_w0|H*+r?g(Z^7cP|6$XD2aA(zX&M}Is6Tlm1E*T{R@AJd~7N2qI~-4&3ig7 zEBp7U_+=-i>W|i@@t+4xxK|&_20n2Lh^OZuw727kEE__@3Iv+JY##@GT;oKizOHf< ziCDW=IMFHZtDI*RpBcrc#)pF(e;_g!;M~WY`V2^RXgc4XiCuCYW0gY$FTuHwIrJEi z?fjsG2ssgGJ{${|n)3ZPbwYk(wo%y zuq+g0?cj8k9IQ?VVM%oADjOnqc6*T>?v6@UM7nq^dnftg1tcOqEv9mTl7+8j zE~#OZEA2srW|t4C9wo|rxJtJn34Bo8AK(U^^Au!Ibg2vdm&l6A6TJoa1U0`IfA*}F zSS#r;xw$^9eKj*yV0O89gb^JAmoxk?FrdFJN2ia<&J`$p&vMGuK;yAWq6hqjVvB+} zu4|N{%8U#?fI80^;{Sp`4`pK)g7hR}3ZBRAa!(LhFQg*u-)@7~;qBAS;TOm0OyUgEiPe~HwvyR|@7r232pQ&7$`K$?x1@(*K8p$77!UlMMBPAE+BC&r1B=;bkYoEAVa_ue9Q=Uw}nZ>MwvPL4~5_Yy4^q0zARqn1+A zRvaH25vJ@fMe+1G8qVv9ChCQ9cMz)p3o9$N;NVCcX@o=38_{TkH(=HRIsUqb_0X>< z7%ssAH4Ubu|Ay2r%Ox=bhGCXeSW>+bU)2SCARHd@&xdG8Pa$>G%bc65F?8`!)1sNJ zVpS2mLH+_}=8<+}G-`_eYwl@k&1pO5E7 zz3Z`fOIaaTxsn@Op2c5@cJ$z6W&OUu`n3AD+&Md`XIhC>#Aq;w?VwnHmV!MXaw5v1 z+HW4>IfWX}l^?pjdYZ!Tacgs;np;iHX&CcDZRDhF#r=E_85W>oS1nKU?n!&aj4eO; z>{!Gb!ghEmPZwg7`q~8b*z!`D+=%#Xp_%GqW8V_Wof?}Yo7;O{A8GwcY3NU{AoOfIvHuE779ZK{K=VoKm`>CLBU*$7RY5z?FjTdW{VRj#7u29q z_fm`4i|C>N%{O5!1r^FFphfnfP*N$r9t9$T7;BNT0WbZv&x))@hG$pG#48;eOlY%E zC$+9>tx6)eQs!k2_U<^7Qqu)HDZ0r%zH7KV+J93W<`3n(cR^% zh86nLDmGy2{?ZGf#HSeWxay^CHE822YK7qRZ=aor1g}${#&Vp^X#=5WY-?Y^=*k2w z6w@`;h|Fb3UaJHmILVg{ciMUBy$IN)AjSO(DOL>{lhHYQ-yq;)^&*XT-b`wvl&Rm+ zi6L=1wqY3XN{Eo;9xU)VJZzPXO&W_t+$PM#FOQ$O*Gj&~UPs8>y<4T`$&k26!V8@N zs0}5JSS&QB&M`rBq1hu;%Hb(gwEApuO%$o@O<{2JVE2~)$l&*CZw~q^z3`zi3ew6Y;Z(_Q zG8Jh6j5_|>JNLVX|0UP=v9eoLa%O#^*2TeJNEYMla{bsz)#(F=N$x->3+CbJU6Q8t z!o)r-#{3Be!CC0TUDx;-xBqwm00A|LA1$&K-lKsXh9HP z>Z!X8--$Sbh*FC>q(+@pCS}anxmT;|d1@)61@H5^^+>eK=Ss0D>s|_)-M7Qr>G0EW zc}q&5pO*&0vFtZDnJ4k7&{jFrtG8;#P?>9uhx-c=$295G+)Q{d6%&azDqEd1=LNtw zMUY1+sb3n+fjD8!wFhW>)tm5i_kE7!tOlb&rlovqHy&UdYB(<^=yDKr@3*e%I~nf+ mIfLTBr$!V_qo+of(!Y(KyJP30<}etoeD0&C6aWAK000213^C9E literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/assets/channel-art/friends-talk.webp b/tools/test-publisher/v2/assets/channel-art/friends-talk.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..2597630e94c6ac84f3d4dd09023f99c6915aeec442b4effbe38cc4d453b5adef GIT binary patch literal 2298 zcmVRt z{XgsnnD5Si)cXPXZ~HL)x%NR2^v=6j`q3%xtbJ&d{9EJdf{I?z^+821VebE#$ty)l zOYJT?vxX|tz56QB^+821X!@UOsYkE&29u3A9p>WJ52@4s{=nF`AmG78+Q-&$W?!R% zdNyeAF%i-_`d1+DtbJ&d_m{C&2kKY)rB=4T)Yk(zQ^!VOszbQFJ`1T6ObP{W|2U&z zNjPVoH3h5NnKU}ol0LLNe=H(A=QmK{fJD2o{P)lR@&B5j`xgD#v4D*~(87JbRWmGz z7rGY;Ud9V=hnEa!&~PLq=4_Q`KNfuAJjyUo>l6D&j}z``G+>2}GgKRYhThTOZE8W| zX_>y9w~s0f%dr410RBULiy%`h_@MfrjD}@r6NW@fOGLpW*JR%XSJl*neUGgYp8Chu ziBHAT3JNKEN7V%sy@tT=qUc}R8&3Mh@-ZU7h!r-vH&qI0?^nixg8qQgK&;K&)Wh(j zMfu+3NEQj4&W1a8DkK8m*dT)f!84iA$9$rgaH8&xzdQ*FUeWbIMK5UjprV(weBc27 z|B<2GQr;-4JpZ}CM=aHU(-3{8A~o57X0{pN(FJ zhe@kfE~0_cqV2^QH+4@BHd7HCT6l_iTF7r>w&T^j>R*c8X@P6trp7#|fh8AmdOhmS zev(PY&&9GK_3Ly1c22C5zf6@&8^z5vx1J|OrBJKe(Z0Oi+Y~k?okzsVc{4*1s!GU6 z8>c&$2kI%}dbT^&#hI?cFc>-~FU=<-3MVBPCENL0kP1ZMIDoakymdycY2U}R#J_3O zNUBTMObYC=ozQ6ZFnCM5J35dFt!G<8n2AoMBH{y_&QJP%Rle!lH3 zu02`-UPcNkAqUSz4zOi|;r)j+9{`Z;mfRHm#o5b;B7rgB^k=E(Vd~0vb*ce}6m|#@I1;h4TIp>@n zlKG;@;cm#^{Kc!9z<%SJ^)ujX6pN2jDVsM-hjYew1lu!`2s)o*l5k|jx214Z6fcc@ ztB`Bg?M|wo6K5lhhRMPgu-9YcZd!+@1EX93#H4GAN1o3}NYvRJX!>=zRf4py#=QB8 z0Ucx}!OghAZE%F~Fd}+7{BsR5*;r22AP!TjaOFT{lOw#%#(8^r(sv02R)L23me{jv z70YDS5491o>EZ8cR+(TK7@cGVN(jy#qm1%<+d7#8B^{Mah=aIFLX-s~?20As(c@px zuBl<4WX10K4ikzj?ga(zkN0%!DgD4EB*&?-9O@YyxAx%Vy%`^>s^K9DKv=fW7}ETQ zZbe)-I%^cNRys_;{gePb>%sPRQCyIBw+M?BfjPClZRMCvkSP5hb(fM!RH~5akPr9s zTe+v3K@QCpw284Q02dL=yUo zeEsKv9i~Fh_&lBL>9}w(9-ukYy6X)xGZd z^I^mrEG-4XVf7Fcqh>Mo*%0PmjLTh|&>o2zL$O52S!f&k={8NycCi_C)HYN}rg`K`r4Ti1bx zD93a)@ucpZzJS&%;H;kE_}G`bHn&_mi=)EhD~8T8=m})>*csvA)|1dPp@ED2wT0Wy z;Gy8e+3Pcv*2;~Sq7>ouIYYY64C`Yb&15!`1hKCN^Pb{PScfn$ui*ooVON?eRF1Znbvj37 z76l*Eh+dw!WOG-N+LD^Ghp!9%&RPx_a|V-TzZUfd1%AEau}? zY(hyqM>q*|??*}l>!>CGR_8J@LwmQtxfxv|7(*fqycz4NltB-;%TckUkul21Tu-Xe zCCS&|ixP^7?f)|DecP|q3Q@F*l*vWdzO+g{*)$+5ARP~oIbAN#RJ%x0&^DcI^@QUE zjXWCj<>A8d?9Sg#5Yl^VDA&K+@h91mayrY552>Zr3x4+}b_g&^FA~;hBjoP8KsKjr z<)=4tIe3&a`e~@`^x+LRUY=$pijm0Rbe0&-JK)R%4WQq>zehk{MkQEn|E>VwyW^KR zDYCBDOI-BwIwnlPzY6T04=<+JbqlEOR6Pu#X<$zKI@BF&DJK2yPV5k1lwKvQ&_~JL zc7Sb8*~?CDUB;kRraGLdK^Mm244!BFQ8(PxmBSc_40Bq&Zg_(3#5kR2F4h#$qk^h4}w5V UfB^r;r2m-rzxNmb000000Cl={U;qFB literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/assets/channel-art/mindfulness-breath.webp b/tools/test-publisher/v2/assets/channel-art/mindfulness-breath.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..3a46d5be88ebe587b680f45292493d763db73854535a1f3776a97303a9e50471 GIT binary patch literal 2220 zcmV;d2vhe`Nk&Gb2mk{nxM`N>AJ0WU)Ip7I(@idy6~e6urfr@`_&K&iO?zad1245GnrC!$1>CX{Hd5 z6|~oBV6ATOdsaTQN`0u~vlk;}_o-AGYDJ_!{63&I=Kt#-S|vW!coc-tpn~oX^1Iwc8TJ zH4m9ghZ1~p95lEDDvz2OqIQX6{!wn-&Ai775J-QE)zjslJMOi8r+ql~pUrp>@M4;= zyqLt3(wa*$ALZjQE7_-dGyJH*PyYAxU1eqWF3UECE+$b9dVQuh{j$|-w`JLMF;#hvnsUgFOAMK5W90RI0YPrHfN zYlu*RZGi7Wd-lV@^)Q60v_Teb0Z^cQKW>RQA{SNQRpuCI*|6A~1$V)GCBq)OgJ89Q z8Sb!9`vXOJ9W~kOzp|8y&j8GQV#Pbgnz#`CD1ohMfIMfXN-=1*t+PDbpxA_@584Bw zK+>u>I*Iap*Le(1s1*22u{a_!EsTY0bEuxd=}mP1kcWPXuL+Z!<1ffa( z9OzVg&~?5d*;XaQ)n{Y66KcMrI(m`c-mY_oQI(RR+mvJhkmQH>4o`<6nHk35to~M^ zdvyP5fZG$Zg77%X1Jggy;8rZRo18&{gWcF^zC6HWVKM1>Yoe6pDrgpK!+$-jgEC=) zP+HwZqfM;*2nyhiMI8DpE~&l2DreJ7@>auOa~mb4*2?M%i_O-2dcj=XMoKk%tZh^* z_&6bqEf1R_Qdi)xWQI`_5-G(#569&`q9$lnA$?Hr%=53#xAH zy5mR^<0K3P@%hAGpN2(za2s0_EdMT;>lPl1XVCdkt zyF(5I@9bRXvSwLo%SdVxTPR01ZXe8HtF0skxmzqo)gcU;qjq z_KyAVhBVawNLLFl9l^t86fu@n#m?Pj@U_)LvB<7roJeZy!cd__gpD7{svA_klGn|h z(IR;IEwZ*pGIwpU+jEpS3=(qOw_2g8g-~An(&T(GXZWVY|?!AOf>;TjZ=u>+F32IJPd;IUoSMr5Y4|tHL6+$TZ-hf;+$B#0yh%WNMSMZ zl0!e&^Wh~tXE^E@ftg|s6s2^7Pm}c#F<|yPcS9UhsdZ+ju0b_5#cEeESHDt`Eg-UG zmFe&d0bm}^%L8zJevAgX69N;)H`q77$*_j+G`~G#7uGp*`Gd+!#Jp1%<0|Ar1E5t2 zGP*>`o*D=>6QH5}gn5UpAZEQF`Qrn~aGdlD2bVexf>!LwIi*IhMhxUW4^xTCa9BJX zT=|nSQ}@Mz?(n&;%)3!8yAOgm-TBUKB4rf(c-f1Z1-@x&b9-jmVdzpW)7hMzP8^maQ+Whgt*x($Nne za9}G6xE9caGLW4ha3Z~|byt)Sxg zTm}!Y^oo5{Q$e3<^E?qMeiK_IO9>|n0iV4l4={3h$d>lEpPl0Rw~;h^)$$c#?!V6{ z7M*B^Gm>BrvoU3QOyl`)Cd?9G001j?;DbK`8bg_)#X)n2PrdvshLF~S0M;M?`8*?u z|6PnpFLV)Hr)>a2o4k>f%@}`PEsmvHn-BiH6Q-8S$rSKZwfFaCPEZNSSv|zVZ@Cx& ze^U}m-2_)D+dvSfu)U$FBoBWnvU(WY&0&$C-YcEB<_~HWDXK{ZP^;(HC)AneOr8m*9N0r(MG4?*26U%i4+x5(79d9sQ u!Z<{lgaY$%C6rNgCFJcXt3X`Ja_A7wf)%1*qap`$#gwMt;|Szvub=@q7Pz|M`60=XtGeArJ{50N}2XfrXugrc~rVyNV7V52)}A z2mr!s?90qPT#%KPrb8#@ggAX3%?H_E!1m|1;3>_VbEghgeq-i_frs0B zuKJu&H+fTVJ(%lyDQHowJ7Z>9dlyHSXpeM;_hV75ue-X5s0R@RnGLmVmUpHdW}(GD zFK?$??F$abX&bSLBd>YMH?ZY@^~-?fh0jc4y7PV~Am@uBU3m8uy~*zSHs-7@*@#N( zxmowBe}nXjE)u#t`GefDn^vb$kn{PNv@OpK!w(ZbEU?U2W#^8-=|fnAxj~GRzjjt%T|7RHcW{&|R`Pk?-8XE%yqn-Hp*?y-k^2V{qvX z*Nt930z^{ksNB~tZUpPpkaon*3L+haRcN^2F}C*4OAP%vtMlMf)UhnpLmYXALznslXO3doyvpv7>W*XRCGUwaPrwrC`xF{TBOu} z21SM9IeTmdrwGyNArt!l21fm$36A>G+$o6MQD~7*+T|;6qSoGKF)SykTcY{hA-_wf@oJ zo|GI6HcNK~-?jl=01>7=ubsQ78?+JNCk7jnbEWSU)@)d`>Dh1=@dHI%_p_knW^vK+ zHvbZ`*w5-TW5t~UgVrwNI)<%V4zk!nj;I{SzM5MR~w~pe`LkGFJjk~M7xv&8-(}< zKc+jsQGDUuX76KDCB>U*ZiV7H;JbZj@HqkW{hqaqsZ4U&iz)PKLwpYq(d zb-cGksaobqI|g+HZA*`xe^ikFsaTwti%AZxO}MuFN*NhuAhc?dm?{&uq8NiU(z_~B z2O)XpZmM57aqjip&_TJyQ?b$xqk#gM)Go)zmlMPv8h#;tRbOfV&o1ahJ@f1mFL$15 zO;RBG3R^TsEqx~5$kH;38PCxRbLYAQ=?iJkWI}L;l)p4feX0r%*v_Cqy}TWL)Nj4j z9@2_txju)Tiw%cD39Taq)?;E+>!947FQJ`=EYg(iGOb7gHKH@7-w}|cW@(E|!{+8i zTb$Gx^Vt7YCWB|ul!)WsWtyofw(;c$8FZS)EjPRrujEein!2oj&l(xEFuCq#hB_eb z+vAC^U>$)fl{aXClFcbHk4po#aJd2G5Fme<*B05#jRnHaIH@4CapnPbfBozO17TV#HO^_OB-(Pjwy`7%W{3s&4}-O+se*e{Ku9saL9R0p8l zO%^$IC^&!M??j{ajF;2aa@LcBh})|Wx6c}SLx+*)C4ao&e1bj5XU!X6?d;EMc~6Xc zc@ih9?ou)Ro|JFuGy20^Wh}>@2}U}~VH|$-YWm$RKs9!|K$VMh?LKDs7`n^J-KjhM zWy4FNxGZbYC}~B}7n!&Y<#`%jh$Jd~e*mekZT)WQ5&zH;wVxiK>@=WXyyA!@&d;iD z{|QmzU7Q{t(#By#)Y%(FSM%L=}#iT`n`d{lx@8oHPno9~C;|P4B7yI>pz7b`# zz4!)ai*m(rXs=E7EN5Pyq>eN`6LIPx5sshkKsp&4nKq5XWv)7TYbP`b)+fw!w`E{; zupPs_ir+k=vsHTUCJ{rD-Wq(WxKvZ%>Dt6QRwQ#302^i392#(n;zke}6C7D*S)Jx~ zD9s+mMTnX9%?lyT|F}BX8Er=8PGVSfs+?RaW~(_UlBAzTo^{f(dmH^t8^@RrUR&Pi z3V--o%kbi_L}d*@UR?v2Kl)4L?Mk09y0poPExV~$p+bi>r_FpzO^#EY3k|nsyBjT8 zsgih6&hzB+|2d>`YhwN^^eVMVt-CINuqiv#n&oA#I=-Bts4uLI4mOVt7QMP(N|TMM oSE=wgNk&E>2><|BMM6+kP&gnI2><{PJOG^mDu4k10Y0%H++> z(7XB%*Z(npoqwqI1O12VQ~O=)gdq?@C&P<7o$`}i@`>=`&iO?6ao!1^|FDm! z&4LJv^y5Oglci-!AK5rtmZBV4-zc7BqD7(o2*9WLM7A-uI%WUczK+RhE3Fcq`<9{w zH|(PmpOak7#WCC-XDOC+PP9sU?plZvg`_--EFJBdUBOl2LHBRt<*YYvD0#n~Wvu#R zXk>Ks&1e*j;rMh)>ytM*%`%e?EbY><<4pbnvxUIs@d_&9qX1KVB?s7kBreNN4waxM zOHMtf^Hw*wTb7{~O}zE7CXi7~fYADoSzS@G<5bLr`E@*ypARd=M`L)W+>8Vi3zTkJ zM#aH5fYyODk{#(gQO(B|q+0*cEQLrK@bIF1IIe)}I`P<{o1;D)S{3^!*#S20Up8om z7I(@qGX)dj#hvnr@Z!$-MEG%B*jKd(mZAgtW9pnF<-&W`7|?M4Y?`LZF_oqn18Ei6 zOVv2aCE=(Dwg3llfu_;F`}AwC>g-zc9BEbo+0hZbr80RI1B z4=_Z!jZ(Ryq>Yy1p?iuj7i3=@HUBZRyF=bDuY{a2H3Gf*RF{TO+s9@Nc?^}#8fVk` z4e_IB@NZ>T(7JUM6e~ANR$$ETmd3;8k`({B0YAh?84~|n3sjvxGZL6y`pO?Zyeo>I zF8+N#>QDokLKM!tOnAag^u!O6RASK`D6e275a(9GUl z<4HHzv1S{_qmzoNJPx-s6l-m&w^^8&;9`X9O0e%90BQHia4xn#K+?uoaarz;gDN+%56Q8x`L)E1$}g2qPA8-;Hy49 zX62Z03ZY*4gm|O!e_!H`qn4}S{lFZ;OKQ2kn7n|yYMC8cgRij0E_)1VV3ltce zw#{|CBlP-=qT#OEtvx2F=;*5u#vx97@caUMFzK3`kRYh(-lo6$-JDZ)*H~K1Y&m_a zn9u7!_ATKoYXXoiZ?k+KQYBX@C}L4ImLh=ol)A1Antiz+8?SPf>G)B{?v-RFobY%k z{VbN1;=OjE#B1!K(Djt_SV-2AT7mSDVn5 zj@2~H^Ex2m+_%vC^$d8iAgeNF$~?yIFKjT7GI0ZIlv-X;&C0=4yP zaG7o#I-E8BycHIjS-xifjhu6Q%#p0!NUog=mER?$l|=R-I}Pzt#bbu4*8UOZ4d9n6 z`kGZnztFE(syCx0?b(*>yy+{17XQ2bA_~xSV4BZM9IoLiiqWYJ*?xV9pI9@40qDOU+Rr3_Xn$rbN z0IZ}SQ5#U-b!zUtSLX$?2&ox@X&0phf!{LZwU7ae*xT!DXcpV@MQD+=n@fB4fqebIp63!o5dQjo&DIJYL%N7WazEsU>?*#(4iOh>{ceV}QH+lb OYU$?Ln?(Qs0001Hmz1Oc literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/assets/channel-art/mindfulness-window.webp b/tools/test-publisher/v2/assets/channel-art/mindfulness-window.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..0843b13e206e7847197bdfccb502cc4f4ddb7c16b500577d170a042a465a4efb GIT binary patch literal 2284 zcmVSOH@So=g)KG7Ac#O>vAJ9%7A-d9fKljdmuTooyh zL{Zv&j!>!CwK+!JC@3S!3JCHn1%V?4mb4m6*P4Rzsw+UHkZXzC%HnqMHD9CckvA`a z??g1_BHnxX4(sg^R{{H!5k4Jsj33l`d)PhNBKsj%sN2YWAHDY$6dnNVOz!K*9S02n z>N1nk9Gd|L8s14x+vgRqg?n=SS^~DOrEq{vQ9*JT=q{=1qeCXCt==_LE;7~=H zz0lqD5dH=5vIb6GH0)Y#sD-fJzwiYy_L7of?9V=N5^IqhDfQ#xdOiQiizG*bc#};F z5gdN+fH4mKcS040%ZCCFaIYo+ob=MalZ;;XWsC zD~a36;&$K!1!Gbl$aEesBe(=9up9%eE7md*9dFBjmp?J%0<8nO{C_dikaM*YTE_)mv^bCZ>(KuhOK6LF5l|;o`Qy5uL?6eDT?d1bd zpp{x$MV=nrGM=EKdCl-D@L4xFFiKmk8v3wByKNz2ydaUdA0GXP{EyC_RI67c8%)&G zkQ=SaKU+e7_iy^v-Iv`kjxRq|Y{K!Fg)yS*-}gBwGTQA*&RazjLmknEr<~T=lE=H9 zo)mrJm(De1b}{$YsuV5rYMntU_-r^4tJ5UFggkjfFb~3InWNa~b5iKjYz&coZOkP> z=)3xB?y==M?NBqhGMWAt1C{?sM^H=SUNEA>JuV1WO{u)1+>~jI^L`40jnlk-2bN1E zX#aCRoBwre&`uT=;_ML6P1PQ~b4u8yDc+Kxru`|)>L&+?reIL>Ox35G0784`4%dyH zo=bfL!TO87E%Oc4JoZj_)I}HaB$i_xNXgp*>RBWL0)g+d8El><{*I*=c|`79xv-Xv z{v6naKZg!8Q)r0CslY#`(kLdrOumh)ZL`LBb`xp)6J~Oua{&LnBCzldvKg~2t=sdJ zZV4m+!*vV2=HeTj!yPBjG^xf^&Y@F`s&)7l39H!%fW&2&O`pB~PGj+KC#|u3W&y1d zRT)1oSWZuMvU95Nm@s&|@NxkHL_}Wo(IrqN`bPKV@e7q5CofN7^{^g$MqeAe9DBPU zDkXm100nF2V&8LD#lCH5O6#<>inkCK_r9cVC&#_D@ zS!@0#jg9C3g9hpczhuMRxWW#x58YI1PD9e3;aF9o4)~Ht+tfZ#|Jmq1j{@j*?Av@T zT%eJ*u~O7}f_c+BV9o?_@V`WSQwt}sP*XU=7v)z4c(ai0S~<^iQfn!8f&L_V_BUy1 z`iPFqri)j_Y-hMaA@y}C8BhY1CVVJfU?AUCIwFzp+G}ZU>*MDD--exqi7Ejnq~N`Y zcUyfwDF$I}1tu+KGd#wmVCsQ^xyTgM3kn04^XgbnArAkEHSMyh#%%KKo>|PnXExKy z$qTdm;itb4F_F-|&Q8+C+J_Zs`>xZg$x zm7K?(()4Amrk~*mt{B1 zO26{G+2$)69_HmHq}$O#zyWwmg8o-h+8^_FtjV-Yp!<7~0RRIc1_V{k$q4%FWH@r= zgr%iLYPd5Cv;20F1%+tlJ|<%>!;wL93Vco)kuc?QX zG{E%ZumO^QRm5U>>(4+GUwj{xlAb0$UMkEVE48otYqW%z)DYnj%wsfHpxR|+Ll&=N zae7143Y*%2Uu%3<)aDrB8bTSRs#`q4QcbubH-lw-5);SSKKg(pMC7UX=U8B+5p&do z{crb}A2Ly`y~^g-=dBWxZMbOEftC;e1j})55#$D(&|IycI`yGBB?od8h#ed=n~a`m zUPvqN8~hHmH$c_qHL+dS z3eMk7sJ&(av*f!fA5vwpHvo$=KPo4=EOHerkt(lr%vb)c1t5H5y27-SdX_l=(pUW| zS0)9e_iv|EUGbL{ciT`)T|?rNHsR*UY%O+5(5<=X<{Vw3oq}F~!nxIO!iP6O?(b1Ze974bn?3XfgD-B|?d3KfGaG#fgcyTWgb41Lms zBw#Qea%>`4MpGPLKU>pLb|8iTIbqU@BCY8uye{6*rzij-xGq$jlo~Pq+PmHh=fwa3 G0001($YT8f literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/assets/channel-art/positive-rest.webp b/tools/test-publisher/v2/assets/channel-art/positive-rest.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..da9623065d105228b742b5733cb5f11edf2c9db1d5991495bb9ed073012eb54b GIT binary patch literal 2304 zcmV+b3IFy|Nk&Ha2mk=`&iO&5f6|_O zBBW?y$J333;w-N=>dZL`KP-40x=hZc9rC&N-4y?T^7VVZe1u6FJjuYio=%eq}C*h~3N%5sNZ!QdTu z4k{mu~F6>^PiV- zpC!1#Mb#k4I6Kuv%-gJ>oXwJ~-w;yvUtx>PFGx>@w@9w#k`L%x{71SaL%!nCk7n*- z7z+%4xvxFM3qT4fmOt%aGkNr>pAJfU&j@aRC@CzI_v5{AU<~jWjky$Ho4!##99iEe zpC16Z_bo&?v%XP28iY$wYGt14KM)y z|0hSYF;R{PFt0%cdWY$tkt(hAH#a;?bA-orE69d5LAWS{g$b=T!UDK_A%IRa3K*z2R51T$bQO4 zo7(I|p@r>)nGjEvjF9uv*E9!*`A`UjI7vw!8&|#d`3wE70+=t5dos4r-v(P!2t(ev z;{^oaA1Ana%`-!WW&GhjiOcsbmxRfOX{L8ofxE|u2UX7v(#M^4pWdtJ5Xr7kqHUc& zY7oZ0R4>c~n_3P#u5aMrs^B`rSyi0L;v?IcyVD;a#{>1DYmV5y@maH4)*AUI*jwBz zE=4EtD{6voGf}xDQUZFubn%xTEzG)OI9E7GeI9)sJ`|!6k?UTJs0FwM z_R3$E(32MIbsk2Ga+eP9=atV5(#M@*E?J6o*Q~FL%nb>b^o=x3JWtgZI(|$XxdEx{ zNh?dsC8wdJIgJxQWz(*6! z=AP_RADh&>3i^&+>f{%)$S*9eu{sthrxJ=3b|>Zc8D0+pH!2!XfTY#0R#D2rtv%v* zwaRZ=@ZjeUU@98Z4}2(J5`JZ+Z_rFl_gLZkip_R`;tOj*dp|=by?%zL9048}gbRL$ zo>l3nF#4NYbcf=KJ7`jVn^2aup{{*1@>?t(=aQqup*m~c%_nRlef8jzA+T>ET(`yn z5?<_8javndYl_-vm+$2`Cp!xfnr5`Tp-1!acP^;AC8MVk)0CHQ3;rXAW~Cz!UB+BF?i3 zM#dhx*(nKlPB84nYE#4)iL5dUnnkcRD=2vrXk!b-3Fw-u<0mY`!>nPowbkzSna2tG z04d{AnnQ51u?D3%iuzDW_eNCYk2YOr6cjt+1~k~F%C3xz?9RJVe=E7CGEhQOS{#Z7sgO3qkCu*N%vPhHhycks z=c2==&ds5pPPG*q7fsBpAeKO86m{0`SyhUiimQ*h-#c_OTpT&M5=OY=$F5i5xE{FDR0@Rt1tMV+9|!gm~G) zIK=zkpjrBi&F)Es;!6ZLdOZU7X{IPq3M%Y~l{TC;C z3PBSI;NCWK%ph5Cj<{wy+0J9UFCA9a~{rJ2%adepTj>JR73aD zJDBR9|D)bql8XOzU)$@>5wS;O4?1M~H{?)6c6+woFig%sgGhT*t&a@!QsDE3I3zpGi-km6p}3BXcxgD z4V$~cRv0~qvtsG4+4l_FAH-?>?0N3NzlmRM`gi*KTG$0zK&O8(Qxl|~$o)k8ZjDFb a?isc}h>A%TaWo6ykcQ3O;9vj%0000|<$h%V literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/assets/channel-art/positive-spark.webp b/tools/test-publisher/v2/assets/channel-art/positive-spark.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..d6dfd03f59df0a505aee1af7ed86c968103edfb559aab83de8e6e23f851305f6 GIT binary patch literal 2228 zcmV;l2ut@;Nk&Gj2mko}lz(vqgZQ1it|xCRiQCHJcJjELysjs2D~a36c8L>B{v~Y) zWub=|BjfB;re*+CZ4p@eL{>h+^?+iX+*_oud$Jg6YCJo`R;G-0gD zrF<|Z*4``yh*z`xQ)kn<`$SLNm(ip$8a_LDU3JAK{8l5JTp!^TB|CXsPTp4&xNLSA zwR4Hv%HnqNwtx=nQXj~49xx-g1Pg`aj49$=gEe=#^pU(G!n>3Wf5MA**$NS`cUt2u zQ)h_vfZW(*spmXM<$BdNc#l{cS2;l5SRid(1P!a4PTp4&x0S^0<#9Uj0RI1xpFLNA zB)&6o1sN@y6D=KSxo7)c=5{v{ta7eMPeM|vK}9|;S58p%t%UnEnGHQPDUo*HT%|0IJiNNjeEgh(qg zk}SR!KdH)7<2_ga_m%r|nW_tej|ki^$8O+zIt*1zz9$u%uXopQ)V8}*g(xgEP~Q`pHy0JiT_w#i(N8&^P=n zfo6v)k-Nrvj`Wc)q0);ehBM0h1gmJU>|w?W?KCeXaC=twzn2S=GAwWW3z@#m43ZjQ z;c!%S_-)r*vX)G4;`UMmTpnGZy!em<@lZE)3qcz6L_pPHAD64btyM*?{~H(RYgZ%Y zkU4&vXjzijsN*9OaSOe2P~~B^>g)P~adpmS;N+(_iu;T)ybxyvZdpM%K)8F!JS45ecRFEsB`=WuA;$L z;pyx`yY1FcPlIiRMdHU^P1Qp>^0u~gfEToj|#Vs@~X zy?GPAb`7B_Xf3$2N4gphl850J_8=yTGSj@c$PcxYjdlX;Vb^le27Tq5KdS)&AF~tN zC)BW#A-h<=sc2h%m(-f6jGHr1ts9-$GP<8%=h>a3IQbt6f6QmK2v$KTbPpOGNk_)# z!~cU3{b)!0kY3m%PY<_6_4O$B-z(##c=Eu`;!KYR9tKgIdm0>?STkQo*R_&2j<s@9%S;s@yzCwdPl*Pl*vHnf+00#v9`>Pa=U%4=C3O- z#NM$2Nve2(E5)_Gcbxic%zmUGW(RKr8j-OHm33}}?pkSCvrgP$qqS1`3dIRWugE|~ zwVWkb>MF)}t(WuC0*mhctg_e`yY{RLw`q}x zBcE^}d(&z(1$(?hAd(b!mG!n#+QNPuAQ{b;(WgK8LyC)fXNEW3jkhEF)G6vin~)64 z=@~Mz{Q)58nb3UjR-{<-)Ee*;EzXcR{tKA(i&M7$Je|zY1)L2r(;vKuNh1gAjA=#! zZ`g$1>k5PpVE~eMU__qVOH%1;Kbl+7<dXhT~IS17grMr3yipvu)La3CEUj8l`vms>2@rY=kMn?UTFO}A5KsU zP#-6RY3m5WJj^)Et;=MFT96}LTAbU-`M-UOqpb(Bg=szC%l?tUbmcQN9O7I=x(1US@DC#?0A|10{&omH zy}fIOI5PXb7oK1okc#~o9fV^b-15)ag&A1Iu7d67$^peM%)jXz7fw?%LB{|90001j CZA$w9 literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/assets/channel-art/positive-step.webp b/tools/test-publisher/v2/assets/channel-art/positive-step.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..e2f79d09baa0465c2a2102e4fe3794cf3d7eb3a5569ccefcf702ecd6334d0194 GIT binary patch literal 2152 zcmV-u2$%O#Nk&Fs2mk%NTp3*ZI#ukx+nFah;H(dJ-M1!M{F-%tL&Wb)!bwMN!wKP$}SHzTb-#hvnsUe$ZvK?^7kuiXT$S05YZ^MEs>1=6sTH&prGCRL!; ztz=w9E;(x-bTI0!$8=V+h>h3!(DV|`c)yCk9~pvnxsAK)stgK`{CPLW2d0V2eN-OS z`BcoZkU%~>P*9qLkP>H@MtGSkjXtCmw!b58i+tP^6k!vbnybuM+0~!Ju)>0bCRT=% z+&2r7=jPce$LlS($!e7|!clC=7pVk7-{DIbxrM9FsyPXP!Y2%Qhm$=9u zD5dX#c6^CXwPWiGm=p&R{)Ram2N|(nQm_eaRE9^J;ErJgo2? zvg3(schzHt?hI3Yxk0}Ch@(x2-b>($eQ1>YO=nxHD5e~icgiVyi#z2My~UmKiO>N4 z|02K^w_omL6Dx-=epLL%rx6|ToN?e_1!X?>UX+c@NfDc3m19C^M`Vp&ck*LPHDeAC z>1@4+V6e)HunBlyn@X9cr7pR}njlVWEY8_w*$Q`HSe&aa{`=wIvN&pUp`cU2mwPsq zji3tB29y1CKSen8iyUf4M9{6CP9Ivgr{ZPRWLuIx-naGz4-MjD_=>c9L2HUvopC2< z^~e5W$=flYvzmMnt<+8*wLxp(c77^D+L}!a zJ^g;`=+FEYNJX@JEA8XNXcG`fm0#RptJ@20AvId)^LP?=+uYeyY#5%>sh>9;Dzk&f zJ|i}KRNX6BWm>-ci8-#w_Ffi=*ie2mA~Y@FHj${;Ea&Nt{gJuvAFmBdR91bFusZF~ z-?a*K$|ABd7o%j5%l|0>KF`{@)9h^(ClG)fS5q89v4K3ux21ab5iG?Qi?=BWUQTB` zuRD-6KnhZ$>WQpq@kEd?k5yt^4`nHGe0D3r_=PALbe%#=_qI--dR0s_;q!~bnZyX8 z8w(sjGn3jb%)rHRBreU<{?5wWz^s=YIaE7oU)s%w8+d#x9*m<8zSX}cwy|J`&eoNme;rHvJ_nNogwbB8qLx35BOS^zNI&emfKY;p9-!=!jMNr6 z1<`&%3Fg57rs~=LO0MrOHvNjI~6{c^w{*hfist=TP+n{OBkhEgdWK`Bmtuov*S@3KrCdZRLlR zl>9Lz9a9MH;O!fDHCHdKW&GzSVpoe42?idtdvm;uG|pr3;LQTDqKss67RK)f*f@N2 z19OI^veaM2PU^7ea`$Jo-A3g0T0GEvVT%o)PQ+H=R#;#X7HV$X2;00P1J`T$PB7*A z&MQ5nmX6k#Q4Ks%ed6C`&!Fuu?Pcr~-0fLz z%NDUa*_Ov4a}~_s84>G(F$N_JwJ&{ZcU}2p8mg2zx*0+;t|2|`w(Je(aq!H;J1~?g zm;d6-dU=Gi!l8*~x92%_KT7AKX}7c{9vf~HOn>>tOd(WDQ7z6qIIoA4y^2u9@_k{o zC(8%R1wrEx{0(#U{7c~(=7^l8$51T@?B9_-ztIq3!$hC!q51QZYWwme;fU>A4Fh>I zMB$ysaINXTN{s(M=P!od~vMCS8~bg^nF2{3=NGZ_?J0?^*4+*1)77l+bQQON6Qo6B<5Tv@ENX z+XyXUKaau`ZXuGBcq`9MnH(ADO#$<bwJGo}9{*MVEGxCP zUtb4dV-c+rX>$uV$_M#^<2}3xRMASX$cy6fF%V*GC45Lzd+s>Z#(5l4{96t%YSn#T z5At&GcLAw;s`Qfr6@AFqMKQKnpg~`DcxK#M1^qKe>~S#{W8@EK|DtbOXyFYqHXGk@ e#;!BS;+Nvsaf4Q?>iBYF;l=X(CTG7yo>K0bI?g;ee=P1v)p96i@s9rst&KqJ(|QQN z`i@wiVx@1~UX*~HFr^n%hkWCJzt#1!{aE_Wn`7^6} zR6Bk!eVwVPS9Qs1k+``&K@*N_-xlS;n5#jsnh~Vh$mqL;bAZDZ?1r`oagqEqcy`q3%&tFQq6{~nM4 z4i5svvBggpZ;Ax=@NuD8#vXs9xU%S2XIRj!k1tkS^mKLg3I6?AcQwxj*%?;FGkh8)ka%5Zs@X?^jbHc^;uy-wig4ydNaU4ti~K*zrZ@I!8Hvu&^$BG6 zjnf(BpOg!<6&Xm8lgAEAFoh~>zSDQWZAqowGV?;AeO;|r+)#6`7Axzo1}~s-i6+ow ztFZ)em@R#vm*kIxv+1tHx{Kj4_=gCbJs&J4lFN$3;+3hRW1u7e8l^{rjN1K~t~i`W zbiCl+)P?}kZO~jQ5dry!#CVV=iE?I=8QdEj+t zpHZ>9Q(duk&^%Rp8_hA(l?7-n|3g7NhgTKj0<*8DRPz)Uvf|)6L!FoyX<@k7*q!DD z(_w${2v0q(j@;Qbu z=s52NAQg)1i=hH(Le(uC2!?;1z#lKQC}aC_!C&_{69i)A$&&M?sLVr^3Qp^&QjCz6 zjQ(ixaT}fP6v`bCpmz~G$sol`uIstYyusdK5oI+hP0+QLaZ2?Kc!wJ=xj8=K=?e1l zH?a+=a7jCnn4X*RKHD1tI?bDSl{gwNCS%W07g(j2&%@!X4ZMfu2P61i;Lv6^_UCsR znh=No-=$-Ek5PudfK)Tt8CGqg%w?BP^O2ur!+Q}K+GI1?Mv~eHb~l|3CTC5kayAzE zo-S${bN%uVOu{gF_P8zfCX4S!0p}1wjx9Wp@sgUva^sX-YG*x<^r zzY72a9#=P0Iv$9A>^@-YNV3|Eu?De$Q(M?V9W=hWVys+Y3@h~dRd1ggbMHHk3R&rz zPc{9e^bK(1Q~N!FsF`%WvlY$d#y_iXC+&@dHuizDBewUNM0dK^^Vu!bhpf(&)Rp)q z=1q1$45zu6>FFM7fVl3ID}pPMIQKM$HKE=2>KZ6Ydbkk_Tv^Ua85uw$+17H z+oPP}8w(H6a*$P?#euICY2 z1D?>UF0dFUnkPJw)zSQ!_^&$R^Ih9esyQ?r{xq2r%|W601II0m+QIvvfXrh1|9$se zwhLCq#96v3TZ5*Arh>Wqb0bWnAn$jO_f9Ju%%9a>#q{p1qdHFY;~=*$&OyjxuRsj4 zQtM2AO{|nu-0!xCn?qv-uqU%$f>N0K)(j>8Eoiq3;~ETT>n%N-j2#RrZT)_Qs56%{1XpExA& zu7=1s?ynLFg{cM+(a0-U0{}JVgZYSb0P>zD(|^a5sDqHHRGLf%a7*<ci gHbf;Kzj21jH}Q|LtbxK^%I>S;smaIELBIe20A8UsW&i*H literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/assets/channel-art/wisdom-light.webp b/tools/test-publisher/v2/assets/channel-art/wisdom-light.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..5c1efcc19b5051dfcd8a5ba4757d17ff7c049532019608824072df324cb8dd64 GIT binary patch literal 2314 zcmV+l3HA0;Nk&Ej2><|BMM6+kP&gp=2mk=^I{=*lDu4k10Y0%8Q`p4FZOyF1~XD_ah zjL@l%F?rBZ_K&IxDR^1vX^Nj?^d$ri_@br{;0!I?z}4g4h@Z}k2nV6AqGxt_jfd>T zKLX~tY(zYUwIq+N48fML;tAs0m)bscymxir70WazD5YKX5`03NwL6D>W9vk~t3;>1 zvO;#T^`cYXSo+ZIf!{^R^`YAXzOMkYt;7eV1+oxxF65ZI8Znmd|8Sk~Ze~?a{fz8) zxe^5+cn;x0Bwp8`VxJI`CuJ#vg)eCO+?JN=$|;8mF4jJ@N_*=cS|vU8kB9&O{{M|` zdaUY&KGN3b0Wle%CoP-7Fss7um5)EwZ-@qUAqe@Hnd|=~x152fJ&$qPexJ90M1zqa zUt=Fq(^_0YsBuc9qfkFyri$Dxi}w-vDoK(#;M?xX9gid4GewRZukT(mljl^~HrgSZ z*(q9<*L75em4h>cdL&UJ3oJbr01roU+G80UFK<+4x+6Qov)U&D{kBG5^};RUWl>Kn za1=of594+aB;Km4JJvTYnVI*z)=fT;8`0WEl?I)-P_I*X$!s?^60lB#v>F~Lv&4@z zd~fSc$dMUu%#J(R?eY(V<#X>w0?5JI-0KrY17>WGGeG56W1}W~h@e{`&2hmd@Y*K@ zVwLc6`g#mCb=rFd-KXCDQ*8>91H=S42d8MFwa@~S0wW+z{^2~QIOIPwMlsGmnoLs(YCjk^_;ND z=q+T|ckY-AT}4BPjzw-Dx7c@#JhSW&h!#Mf)YOW4J_Bi#nIkwM-C`}b$q`yej2}|oyu0k*hE$pF zy-|#{e#O#GS3Ed^B_?YfQ<;}bni+&xRYuToNZB4@3r~U~abXJkZ>xj&7SbSJe%yuB z0oBTk(k@Ph2Nur=6Ev)GykXS>v8&s2b+B96(zj2hi(vQ6fnL(}@20sZOUReDo@x1rs{Bgu}$IR`?yCIy&u?n5pxcZSy` z^n1n$yP{9bm0Xuh0FP>^6O1AI@JkgldrVr(9I8_36>end3vJicEn_b!ub< z?pj&2hYL5`gcx)oz_6)oUFcZ+JVSclW7!ptH%_+`E{Rm`J8~^Q5X(w>$C;DY_U}mI%k( zK}W*jg{bI;^m+-4h1#l-2KngPC-hy6ZhIL=yxAd|EYXk+G2*2M2!51J2G|q3u9!&i z;`>xWgHB}^bILkK z_eiMj^hmZZo{Mr8E)H9UABaDvyJnv|CNg$d6ZZ`RbCFaiVy}ZD-j}FJy*%(p4z$>T zoMzZsr-oteLd;*?yjn&}`$HeTJs>-u!kW8hCJmp_Bdyg&Y3(Rlw3Y@J8*&sM{14A-d~Der6BL3 zQLYdb*OqkTzb$8>nC_y2`rlUMO3cn2L24oEP&bp#nLHtsRBV!6tS6%hbrKx>2L8M2 zwbBcxa&4b2tsi^@3<>J{xMLM$ErdA;Br)eHud%c(*lX;MCiM5)V`JZ%jpe$lR*ri4 zNp%sz$!rM}!|KW7(c;U+8=Pv0jf!_>KbvK5QF3;9Y3VMaI9V-$BA9)CFVhnUacD82 k1n&q9+-uv+(>;>$fC}_W^1va^R^vzNRBT32000000EHK6P5=M^ literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/assets/channel-art/wisdom-moon.webp b/tools/test-publisher/v2/assets/channel-art/wisdom-moon.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..d2d44d132d23be0b1debdb572a2a05d505790b1bebb45d800c1cf7d221d778ca GIT binary patch literal 2198 zcmV;H2x<3HNk&GF2mk}Qb0!gzW$qmJ%8BI!)z|DBtxU2I@EaGn1AjU&m>v5q!L=s9SwPc9 z5Gg!~@)7&b8?W`D(jMZPXr@CrYr$}^=mV_eabCDU6kp*kF31MZ^*zoLNAlp2av-3> zcZ)k{B>%CVG#|JsSfV zJbJ0C&CUjr{n%7L*${%^Xx+P zSi6LZ(ULOJM^{=`3byRrVJZccdXC}yl_>QN+oGTW^WGa*vgEY%u=<0gTq^6%`^O@4 zYK4EdSV{IW`|`=SF0Ic>xPQIyM5Gl^u8pgJH<{-*(4RDg zk&6j#9P(|Z07v3Tj^YF@gn3Oox+5_=yrRRqIp^0T<9uO5{D1);Qq?P#bz!~=TFTzd zp*vQ5Cbm-#+s%dKl%|$9vpfzKOZ&kB=Yo4PYIDBGObl1&-F z99@&#Fy=Ak>rgz!Dl+2(qUw(f04r6j?`^>-%O*y`6Df*Dmg3g z>j&OGmT3GahvSd2&$9*GBt9cW2XITC(eHm>SG`@<;2y3lh3MRtaH1egm-1%|QUW}H}B zTYYj1vvsItO+EQ_g`Sq^H$ZvsCdu{g>=7@uW^3I7nUx+Ks;C#Pn1Q~)9VAs=l@2ks zf<)iOzLdkQN8$QjJ?CfXWT~s$FGYR7JWyKKwA$X)n`Ih!iy)Pxts+LaKy$QBv^hvE z_o30`$!7*k+U)n?~MD# zdfUFx%vrmT-TaO5o9ivAa(Mm~_wtk&rV>{$;>A&%LhqgJ!7GzQ$D^>%Tc;YC>( zjJmS?!U2D4yf!EKjv`^oR@qNJ8z-e|`asF~Nq(m>B5qi@H`Ybu{vJs_Bt#!tHISJ9 zX{+Yvkw0I|y)G{zGgq{D`G?DQeBitq{D%@;4nXZYokxSNX!}FAAwY~HT@6+EHjbTK z_HvLFCHK^HjA#HFy?hfR({2ovaj9WWPv6hTF_B;u5qGM-&l9$H2>unYUdgyJ@s+Ci%U&YGjBs6 zNSh1K~o(lo?0<9Xk#bTPteH#HhbrI!2%?OZwtC7F=;Gx4o9bq62oHYsB%vfJ8 znIj)H3!64kX_JE0Vv#*yTd><#ryD7U5bvv-h^Hy3Ff1RpiNV`~$MOb&hKO1?IR$CtKr0r@vtZ1>ed- zZ3P!<=NctF^>b{w_J=NgqrJw?Gm0-Hi3>50pQY>?&X)irtWxKJAZCM+p#VOCNkv{F zDXu)*#6kJJ0PZ2yYt`$iQ!P-x3uo=VNEd%AR;!$7l=#hO8YMmTbBz+7`nkr5On?CX z|A`S3bN46W4YCNcV5_tLFj;&>ztB*qRps}v(il#4L~)%?i?06Cxk1uJ>@5VqdFpa_Q{+nDOptcN zZ@YF0SD5DsM+Emb~k^7h(zD3%}0T`E^Qe*q8t5KNJlF&XGdX66ETvSqgf62~ao6!XjhL4EisaU$d?8(F<%m z(^s+=Nk;s-Dm66irPIC2hC;#F{EQ z=5Mxk7*14|NT(^_YsG?-Eya@$Jx)`Xu2K&sIu;7KNq>78UmfmLr!P=3S0!(h6@e$O z`1=ACSuKQr-CInL`fmH^4dS@0t9zOd#6eA*mI--@h}lXM`D_qeV+PLm+t9#wF|q^5 z3CnmJ06{=1@dGOiV5b*JS;2iYFhm&AzddnmYVEdtoKkpz_%(}$XSf!v-+GtMlILnb zwhYm(*^S@>j2ULKnE7Ify6~MGK)XRu2_fJlbU086uxi@d;QkKQKy>b1oUIIcpZBd_ z8FS+PK6*GqZGvyNIrA4Ez|+nqn(0*&Nq}&xjzxd4rA>gy)VKTwboF%3YLh}0k(8j* z;`I5!@7#z)w3E$-UtM@M!`C4*6n(%qB3Uo!%hK19WhQb_$j%{L(!S;%&DS+tYPv*= zy5TX#|NNHi96U=}(j2^xOm%4sd?1dCuij-R%e8fSMH?Tf9>6;wV=)Btq-u|B;KM1S z0_~>wc~uDQhc9);Pt=#i+1HuFh##7+U82aU=NC1z92wWuBe?&Hz|MRLfdyuoFs50$ zrytxqx^42FavT+iMUHN)Kqm9Ph?Tj!{$R-l44(~otll(&52wt$Oq%T(B+ims^8=A4lIk_g?8}|90@a%mE(vwEjNsgJ-&6NsR}@Ot*4E=x)3q_=%e2LCf6GYNLOQ z@U}iRf^wpIugw3^d#PvJ1Aa5X4~?0)3;({5ps(& zv;ykUy%6Ab3=+r2^hJ!ia7CAHKlhjj)G)#dG8qs;VxZ z6YC(aKhlFsW(?-bz7gep4g?t&Jr)qHnmedKNo%?HuG>Dybo@0Tr$T3Xa=Jj`I{s+w zZ*fXnj2?p^N+k_?^2BQIk2sb(5-~sk0z4(1Nj@Pf5P}~rf<#tAB>src`$9isRC$xa zo+t+haJF$7sP=ILCV0a61`-uzrh?YRDDg@716c$-Kk3rr%H~ zP&OKr3=%fjK|?9CDJ9^1spxwzBXCj_Z*jT$L_V&_f^a)de844TosWYl@{05101&fp zs1zpaoBqPL+~SNDY>J04IxoGZxV9mgw}jW_irR~?P>2B6lcnISbzQ?ZsB=)0)=At^ z2dnL#7tx}Ckmc9sdwxU^UkTydD5wi2pO)2>>5Yy$L1-B4$E=HT3}U zIsUiyZ_JP9Kkt2kKVZE#y^ntA`z-$C2&?T8So=g)KG7AAv_)g>5m@^~RzA@cjZye) z$MsMWIne0*x@GCf-V%Wo4B)q4Xo|<$BCZE!6%XsF|I&TI5l65#PWlo?0zY;3h^&2~ z{#&o)Iq<8Js3P?7R@GT|@*UUOBC)DUQG>ffxReNfNA;c3|P9=FaQjZb1E%EmP}6pDBKwysjs2D~a36;&$@5oxH9mZ!3x0 z%HnqMAOQaVi$>qr@K&P`003n69+&EyxKEX;H2`WGyuTbDX9 zJ8woo!;>>@y_bN7`G!_B+Dzf>Ci(3JfuMFXCjR8gyC+q@M^=-Kz`MyAIkj2C9ZP={ z^~e`SkBt>Cz>7jDja8Z>*(1EVv)q|T4Ej}Rr+t9?$M*c)$tjbjYm2>U7*IJ@6Y|Ug z9$!<^iyXL|0p+9JqzEZTs{A>)ScP|Q{nzAPtsDi?ASfnrBPw6#qrjsYU5luc(Y-Uq zI^L&~HQfS%s*86)>U;6bw08cS-m!)<8{q9Bc^+{9Jyx0&zw7HMw?{>I{^@JjY(XL; zS!2*Y`_%$RS*?(K`6IC&@y(M?Z!ze=_6enXxEFL0jbc4^$D*GqURIg6ND?-<4avyy zSUu96rLz-`9fUs%1{r|kp{J5mghgA{1}uZyFo5(bELiB$&nHnW&b5xV24ZnRzR`v> zl;kckqVpQ0k`qt#Xx77VGC_bQ5@^L+u{E$>;)5O2in;QKa{0R6ig=&Plb5dj#Tgx;@sl9sifq*TPlt#RF#|?DkjnhkCVD(^Y^a#+KW^vg8#w zo!)){hahKi+7s#A=!W71WJPSVx30g3%3INPqru7%cO2x02A(6i59b7VT{%~LkB10Z z?FUMWnxReWhFp1GI&7GO2V(BiN7Mt#_5ZwsPLO?Ll_biRuL7I?=hEuDQMmoonnhY) zI~ycc86dNRBXqB?axy4K6X~jWUX#>%22T1Mo1p>^ob@%f*XJh9BcYw_2aUM_ z0MIkmhk#F*vvmb2$h$P&a}oYPJq@dXw-bU!deTo1h5>>gse$Pjb>ij}Zpy zEFW1&kg7Jv`!AcT?KDX&T{8S5qGX6w%y(x{rTVLBXAB-7g5p+c!N8>EkhbL}CQ z(im)X=~fgY_V(5Pt*tlFm+4krr3+HH)O}hq>`d!v&sFBK*;;Y`YA>SN=L{Msr_s-d z%Z84vN#dDnw392eIlRgsbaCLS8J@m(g;p33`EEa!Lg{rW?L`7)^gw!I$)R_dTM zX5N@3h;Vde>D$K!8e6SCBCou3W~98WzX$snam)TWT41$+h)h-6Pi6wtDeX0$0#*_- z@7QMI0}H4OlUZN{9TgIP@Djg27~)!YQ&I;20*H z)9E?9FY}94w4sj_uMEIUuCLhDt2D}B?sg1(MOMle2}MI26G z@WAql%E{EG5&n3fYjWn^s^xv-S4RL$cbJN!G%O|3Wfnna>vSfTlsO(TcMIVJMRTdL zhQmd%a;+SrWEn}t=v4?wJ0^?re7g)9nLRS?r#XmYTspLeD*6?V1J1E9syr0uuQBJz zRH<89bkfBxe`i+1WE@!exAdz7DbuoMRiQ_jKe#KL*;+a7V_A8mbQpudEQV)z-WoP> z-?MPjyHk^Tg^{Pt1^(=j@1pqF$i{A)WoE65Ynx{30=+>hS6n;GE@L%?Bd5_M5>HDP z?cWE7v7%Y)$uqb<)zVOO61 z2B{Fm8tWza3ga+`*AG&ic>|@&+J&;Deu)qpQ0GQI@Bj{PAo|7CtlfrD%FTDZ(|Nte z8Zl*7`NIZ#3`6v2d^tfWea0}KkxHS{s|NMMKD%0EG@#uN1ZXbZ^H7`lmjLg!K57<1 zpsJ!v)6b2zaex3OT_A9jX3EJN3qV;?ZjVmR#0x(^uaoPBXOqjOk)-G`W%T0nkY8H^ zpPy;=;8fJ-*rAguET4tznk*9iV4y!S>^BzXC@FB_XO~HaWq^}&?;Fl1x7Kdi6^o?WKtUv$&0000K>V@S1 literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/channels/friends.raw.json b/tools/test-publisher/v2/channels/friends.raw.json new file mode 100644 index 00000000..1c8572f5 --- /dev/null +++ b/tools/test-publisher/v2/channels/friends.raw.json @@ -0,0 +1,271 @@ +[ + { + "id": "friend-001", + "text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку.", + "images": [ + "../assets/channel-art/friends-coffee.webp" + ] + }, + { + "id": "friend-002", + "text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно.", + "images": [] + }, + { + "id": "friend-003", + "text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно.", + "images": [ + "../assets/channel-art/friends-hello.webp" + ] + }, + { + "id": "friend-004", + "text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой.", + "images": [] + }, + { + "id": "friend-005", + "text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя.", + "images": [ + "../assets/channel-art/friends-listen.webp" + ] + }, + { + "id": "friend-006", + "text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный».", + "images": [] + }, + { + "id": "friend-007", + "text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное.", + "images": [ + "../assets/channel-art/friends-talk.webp" + ] + }, + { + "id": "friend-008", + "text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты.", + "images": [] + }, + { + "id": "friend-009", + "text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?»", + "images": [ + "../assets/channel-art/friends-coffee.webp" + ] + }, + { + "id": "friend-010", + "text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности.", + "images": [] + }, + { + "id": "friend-011", + "text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #11", + "images": [ + "../assets/channel-art/friends-hello.webp" + ] + }, + { + "id": "friend-012", + "text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #12", + "images": [] + }, + { + "id": "friend-013", + "text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #13", + "images": [ + "../assets/channel-art/friends-listen.webp" + ] + }, + { + "id": "friend-014", + "text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #14", + "images": [] + }, + { + "id": "friend-015", + "text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #15", + "images": [ + "../assets/channel-art/friends-talk.webp" + ] + }, + { + "id": "friend-016", + "text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #16", + "images": [] + }, + { + "id": "friend-017", + "text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #17", + "images": [ + "../assets/channel-art/friends-coffee.webp" + ] + }, + { + "id": "friend-018", + "text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #18", + "images": [] + }, + { + "id": "friend-019", + "text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #19", + "images": [ + "../assets/channel-art/friends-hello.webp" + ] + }, + { + "id": "friend-020", + "text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #20", + "images": [] + }, + { + "id": "friend-021", + "text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #21", + "images": [ + "../assets/channel-art/friends-listen.webp" + ] + }, + { + "id": "friend-022", + "text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #22", + "images": [] + }, + { + "id": "friend-023", + "text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #23", + "images": [ + "../assets/channel-art/friends-talk.webp" + ] + }, + { + "id": "friend-024", + "text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #24", + "images": [] + }, + { + "id": "friend-025", + "text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #25", + "images": [ + "../assets/channel-art/friends-coffee.webp", + "../assets/channel-art/friends-hello.webp" + ] + }, + { + "id": "friend-026", + "text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #26", + "images": [] + }, + { + "id": "friend-027", + "text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #27", + "images": [ + "../assets/channel-art/friends-hello.webp", + "../assets/channel-art/friends-listen.webp" + ] + }, + { + "id": "friend-028", + "text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #28", + "images": [] + }, + { + "id": "friend-029", + "text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #29", + "images": [ + "../assets/channel-art/friends-listen.webp", + "../assets/channel-art/friends-talk.webp" + ] + }, + { + "id": "friend-030", + "text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #30", + "images": [] + }, + { + "id": "friend-031", + "text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #31", + "images": [ + "../assets/channel-art/friends-talk.webp", + "../assets/channel-art/friends-coffee.webp" + ] + }, + { + "id": "friend-032", + "text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #32", + "images": [] + }, + { + "id": "friend-033", + "text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #33", + "images": [ + "../assets/channel-art/friends-coffee.webp", + "../assets/channel-art/friends-hello.webp", + "../assets/channel-art/friends-listen.webp" + ] + }, + { + "id": "friend-034", + "text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #34", + "images": [] + }, + { + "id": "friend-035", + "text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #35", + "images": [ + "../assets/channel-art/friends-hello.webp", + "../assets/channel-art/friends-listen.webp", + "../assets/channel-art/friends-talk.webp" + ] + }, + { + "id": "friend-036", + "text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #36", + "images": [] + }, + { + "id": "friend-037", + "text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #37", + "images": [] + }, + { + "id": "friend-038", + "text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #38", + "images": [] + }, + { + "id": "friend-039", + "text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #39", + "images": [] + }, + { + "id": "friend-040", + "text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #40", + "images": [] + }, + { + "id": "friend-041", + "text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #41", + "images": [] + }, + { + "id": "friend-042", + "text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #42", + "images": [] + }, + { + "id": "friend-043", + "text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #43", + "images": [] + }, + { + "id": "friend-044", + "text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #44", + "images": [] + }, + { + "id": "friend-045", + "text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #45", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/channels/jokes.raw.json b/tools/test-publisher/v2/channels/jokes.raw.json new file mode 100644 index 00000000..4dc39fd1 --- /dev/null +++ b/tools/test-publisher/v2/channels/jokes.raw.json @@ -0,0 +1,345 @@ +[ + { + "id": "001", + "text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java.", + "images": [] + }, + { + "id": "002", + "text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой.", + "images": [ + "../queue/images/joke_02.webp" + ] + }, + { + "id": "003", + "text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра.", + "images": [] + }, + { + "id": "004", + "text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы.", + "images": [] + }, + { + "id": "005", + "text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья.", + "images": [ + "../queue/images/joke_05.webp" + ] + }, + { + "id": "006", + "text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен.", + "images": [ + "../queue/images/joke_06.webp", + "../queue/images/joke_09.webp" + ] + }, + { + "id": "007", + "text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается.", + "images": [] + }, + { + "id": "008", + "text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать.", + "images": [ + "../queue/images/joke_08.webp" + ] + }, + { + "id": "009", + "text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный».", + "images": [] + }, + { + "id": "010", + "text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть.", + "images": [] + }, + { + "id": "011", + "text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать.", + "images": [ + "../queue/images/joke_01.webp" + ] + }, + { + "id": "012", + "text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя.", + "images": [] + }, + { + "id": "013", + "text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее.", + "images": [] + }, + { + "id": "014", + "text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой.", + "images": [ + "../queue/images/joke_04.webp" + ] + }, + { + "id": "015", + "text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно.", + "images": [] + }, + { + "id": "016", + "text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд.", + "images": [] + }, + { + "id": "017", + "text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно.", + "images": [ + "../queue/images/joke_07.webp" + ] + }, + { + "id": "018", + "text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать.", + "images": [ + "../queue/images/joke_08.webp", + "../queue/images/joke_01.webp" + ] + }, + { + "id": "019", + "text": "— Что делаешь? — Ничего. — А вчера? — Не закончил.", + "images": [] + }, + { + "id": "020", + "text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке.", + "images": [ + "../queue/images/joke_10.webp" + ] + }, + { + "id": "021", + "text": "План на день был прекрасен. Жаль, что день о нём ничего не знал.", + "images": [] + }, + { + "id": "022", + "text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее.", + "images": [] + }, + { + "id": "023", + "text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты.", + "images": [ + "../queue/images/joke_03.webp" + ] + }, + { + "id": "024", + "text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник.", + "images": [] + }, + { + "id": "025", + "text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше.", + "images": [] + }, + { + "id": "026", + "text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди.", + "images": [ + "../queue/images/joke_06.webp" + ] + }, + { + "id": "027", + "text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу.", + "images": [] + }, + { + "id": "028", + "text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления.", + "images": [] + }, + { + "id": "029", + "text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука.", + "images": [ + "../queue/images/joke_09.webp" + ] + }, + { + "id": "030", + "text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры.", + "images": [ + "../queue/images/joke_10.webp", + "../queue/images/joke_03.webp" + ] + }, + { + "id": "031", + "text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось.", + "images": [] + }, + { + "id": "032", + "text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта.", + "images": [ + "../queue/images/joke_02.webp" + ] + }, + { + "id": "033", + "text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь.", + "images": [] + }, + { + "id": "034", + "text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес.", + "images": [] + }, + { + "id": "035", + "text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду.", + "images": [ + "../queue/images/joke_05.webp" + ] + }, + { + "id": "036", + "text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа.", + "images": [] + }, + { + "id": "037", + "text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил.", + "images": [] + }, + { + "id": "038", + "text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%.", + "images": [ + "../queue/images/joke_08.webp" + ] + }, + { + "id": "039", + "text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник.", + "images": [] + }, + { + "id": "040", + "text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном.", + "images": [] + }, + { + "id": "041", + "text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут.", + "images": [ + "../queue/images/joke_01.webp" + ] + }, + { + "id": "042", + "text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя.", + "images": [ + "../queue/images/joke_02.webp", + "../queue/images/joke_05.webp" + ] + }, + { + "id": "043", + "text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся.", + "images": [] + }, + { + "id": "044", + "text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием.", + "images": [] + }, + { + "id": "045", + "text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать.", + "images": [] + }, + { + "id": "046", + "text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается.", + "images": [] + }, + { + "id": "047", + "text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы.", + "images": [] + }, + { + "id": "048", + "text": "— Ты выключил утюг? — Теперь это главная интрига поездки.", + "images": [] + }, + { + "id": "049", + "text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает.", + "images": [] + }, + { + "id": "050", + "text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления.", + "images": [] + }, + { + "id": "051", + "text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю.", + "images": [] + }, + { + "id": "052", + "text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены.", + "images": [] + }, + { + "id": "053", + "text": "Микроволновка пищит так, будто лично приготовила ужин с нуля.", + "images": [] + }, + { + "id": "054", + "text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях.", + "images": [ + "../queue/images/joke_04.webp", + "../queue/images/joke_07.webp" + ] + }, + { + "id": "055", + "text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать».", + "images": [] + }, + { + "id": "056", + "text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона.", + "images": [] + }, + { + "id": "057", + "text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду.", + "images": [] + }, + { + "id": "058", + "text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым.", + "images": [] + }, + { + "id": "059", + "text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча.", + "images": [] + }, + { + "id": "060", + "text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло.", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/channels/mindfulness.raw.json b/tools/test-publisher/v2/channels/mindfulness.raw.json new file mode 100644 index 00000000..f31a4726 --- /dev/null +++ b/tools/test-publisher/v2/channels/mindfulness.raw.json @@ -0,0 +1,271 @@ +[ + { + "id": "mind-001", + "text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай.", + "images": [ + "../assets/channel-art/mindfulness-breath.webp" + ] + }, + { + "id": "mind-002", + "text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно.", + "images": [] + }, + { + "id": "mind-003", + "text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией.", + "images": [ + "../assets/channel-art/mindfulness-tea.webp" + ] + }, + { + "id": "mind-004", + "text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас.", + "images": [] + }, + { + "id": "mind-005", + "text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные.", + "images": [ + "../assets/channel-art/mindfulness-walk.webp" + ] + }, + { + "id": "mind-006", + "text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца.", + "images": [] + }, + { + "id": "mind-007", + "text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание.", + "images": [ + "../assets/channel-art/mindfulness-window.webp" + ] + }, + { + "id": "mind-008", + "text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу.", + "images": [] + }, + { + "id": "mind-009", + "text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел.", + "images": [ + "../assets/channel-art/mindfulness-breath.webp" + ] + }, + { + "id": "mind-010", + "text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически.", + "images": [] + }, + { + "id": "mind-011", + "text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #11", + "images": [ + "../assets/channel-art/mindfulness-tea.webp" + ] + }, + { + "id": "mind-012", + "text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #12", + "images": [] + }, + { + "id": "mind-013", + "text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #13", + "images": [ + "../assets/channel-art/mindfulness-walk.webp" + ] + }, + { + "id": "mind-014", + "text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #14", + "images": [] + }, + { + "id": "mind-015", + "text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #15", + "images": [ + "../assets/channel-art/mindfulness-window.webp" + ] + }, + { + "id": "mind-016", + "text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #16", + "images": [] + }, + { + "id": "mind-017", + "text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #17", + "images": [ + "../assets/channel-art/mindfulness-breath.webp" + ] + }, + { + "id": "mind-018", + "text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #18", + "images": [] + }, + { + "id": "mind-019", + "text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #19", + "images": [ + "../assets/channel-art/mindfulness-tea.webp" + ] + }, + { + "id": "mind-020", + "text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #20", + "images": [] + }, + { + "id": "mind-021", + "text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #21", + "images": [ + "../assets/channel-art/mindfulness-walk.webp" + ] + }, + { + "id": "mind-022", + "text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #22", + "images": [] + }, + { + "id": "mind-023", + "text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #23", + "images": [ + "../assets/channel-art/mindfulness-window.webp" + ] + }, + { + "id": "mind-024", + "text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #24", + "images": [] + }, + { + "id": "mind-025", + "text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #25", + "images": [ + "../assets/channel-art/mindfulness-breath.webp", + "../assets/channel-art/mindfulness-tea.webp" + ] + }, + { + "id": "mind-026", + "text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #26", + "images": [] + }, + { + "id": "mind-027", + "text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #27", + "images": [ + "../assets/channel-art/mindfulness-tea.webp", + "../assets/channel-art/mindfulness-walk.webp" + ] + }, + { + "id": "mind-028", + "text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #28", + "images": [] + }, + { + "id": "mind-029", + "text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #29", + "images": [ + "../assets/channel-art/mindfulness-walk.webp", + "../assets/channel-art/mindfulness-window.webp" + ] + }, + { + "id": "mind-030", + "text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #30", + "images": [] + }, + { + "id": "mind-031", + "text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #31", + "images": [ + "../assets/channel-art/mindfulness-window.webp", + "../assets/channel-art/mindfulness-breath.webp" + ] + }, + { + "id": "mind-032", + "text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #32", + "images": [] + }, + { + "id": "mind-033", + "text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #33", + "images": [ + "../assets/channel-art/mindfulness-breath.webp", + "../assets/channel-art/mindfulness-tea.webp", + "../assets/channel-art/mindfulness-walk.webp" + ] + }, + { + "id": "mind-034", + "text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #34", + "images": [] + }, + { + "id": "mind-035", + "text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #35", + "images": [ + "../assets/channel-art/mindfulness-tea.webp", + "../assets/channel-art/mindfulness-walk.webp", + "../assets/channel-art/mindfulness-window.webp" + ] + }, + { + "id": "mind-036", + "text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #36", + "images": [] + }, + { + "id": "mind-037", + "text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #37", + "images": [] + }, + { + "id": "mind-038", + "text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #38", + "images": [] + }, + { + "id": "mind-039", + "text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #39", + "images": [] + }, + { + "id": "mind-040", + "text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #40", + "images": [] + }, + { + "id": "mind-041", + "text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #41", + "images": [] + }, + { + "id": "mind-042", + "text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #42", + "images": [] + }, + { + "id": "mind-043", + "text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #43", + "images": [] + }, + { + "id": "mind-044", + "text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #44", + "images": [] + }, + { + "id": "mind-045", + "text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #45", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/channels/positive.raw.json b/tools/test-publisher/v2/channels/positive.raw.json new file mode 100644 index 00000000..140c0281 --- /dev/null +++ b/tools/test-publisher/v2/channels/positive.raw.json @@ -0,0 +1,271 @@ +[ + { + "id": "pos-001", + "text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут.", + "images": [ + "../assets/channel-art/positive-rest.webp" + ] + }, + { + "id": "pos-002", + "text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое.", + "images": [] + }, + { + "id": "pos-003", + "text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас?", + "images": [ + "../assets/channel-art/positive-spark.webp" + ] + }, + { + "id": "pos-004", + "text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом.", + "images": [] + }, + { + "id": "pos-005", + "text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой.", + "images": [ + "../assets/channel-art/positive-step.webp" + ] + }, + { + "id": "pos-006", + "text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно.", + "images": [] + }, + { + "id": "pos-007", + "text": "Если план сорвался, уменьши задачу, а не отменяй направление.", + "images": [ + "../assets/channel-art/positive-sun.webp" + ] + }, + { + "id": "pos-008", + "text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше.", + "images": [] + }, + { + "id": "pos-009", + "text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?»", + "images": [ + "../assets/channel-art/positive-rest.webp" + ] + }, + { + "id": "pos-010", + "text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила.", + "images": [] + }, + { + "id": "pos-011", + "text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #11", + "images": [ + "../assets/channel-art/positive-spark.webp" + ] + }, + { + "id": "pos-012", + "text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #12", + "images": [] + }, + { + "id": "pos-013", + "text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #13", + "images": [ + "../assets/channel-art/positive-step.webp" + ] + }, + { + "id": "pos-014", + "text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #14", + "images": [] + }, + { + "id": "pos-015", + "text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #15", + "images": [ + "../assets/channel-art/positive-sun.webp" + ] + }, + { + "id": "pos-016", + "text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #16", + "images": [] + }, + { + "id": "pos-017", + "text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #17", + "images": [ + "../assets/channel-art/positive-rest.webp" + ] + }, + { + "id": "pos-018", + "text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #18", + "images": [] + }, + { + "id": "pos-019", + "text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #19", + "images": [ + "../assets/channel-art/positive-spark.webp" + ] + }, + { + "id": "pos-020", + "text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #20", + "images": [] + }, + { + "id": "pos-021", + "text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #21", + "images": [ + "../assets/channel-art/positive-step.webp" + ] + }, + { + "id": "pos-022", + "text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #22", + "images": [] + }, + { + "id": "pos-023", + "text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #23", + "images": [ + "../assets/channel-art/positive-sun.webp" + ] + }, + { + "id": "pos-024", + "text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #24", + "images": [] + }, + { + "id": "pos-025", + "text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #25", + "images": [ + "../assets/channel-art/positive-rest.webp", + "../assets/channel-art/positive-spark.webp" + ] + }, + { + "id": "pos-026", + "text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #26", + "images": [] + }, + { + "id": "pos-027", + "text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #27", + "images": [ + "../assets/channel-art/positive-spark.webp", + "../assets/channel-art/positive-step.webp" + ] + }, + { + "id": "pos-028", + "text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #28", + "images": [] + }, + { + "id": "pos-029", + "text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #29", + "images": [ + "../assets/channel-art/positive-step.webp", + "../assets/channel-art/positive-sun.webp" + ] + }, + { + "id": "pos-030", + "text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #30", + "images": [] + }, + { + "id": "pos-031", + "text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #31", + "images": [ + "../assets/channel-art/positive-sun.webp", + "../assets/channel-art/positive-rest.webp" + ] + }, + { + "id": "pos-032", + "text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #32", + "images": [] + }, + { + "id": "pos-033", + "text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #33", + "images": [ + "../assets/channel-art/positive-rest.webp", + "../assets/channel-art/positive-spark.webp", + "../assets/channel-art/positive-step.webp" + ] + }, + { + "id": "pos-034", + "text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #34", + "images": [] + }, + { + "id": "pos-035", + "text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #35", + "images": [ + "../assets/channel-art/positive-spark.webp", + "../assets/channel-art/positive-step.webp", + "../assets/channel-art/positive-sun.webp" + ] + }, + { + "id": "pos-036", + "text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #36", + "images": [] + }, + { + "id": "pos-037", + "text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #37", + "images": [] + }, + { + "id": "pos-038", + "text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #38", + "images": [] + }, + { + "id": "pos-039", + "text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #39", + "images": [] + }, + { + "id": "pos-040", + "text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #40", + "images": [] + }, + { + "id": "pos-041", + "text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #41", + "images": [] + }, + { + "id": "pos-042", + "text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #42", + "images": [] + }, + { + "id": "pos-043", + "text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #43", + "images": [] + }, + { + "id": "pos-044", + "text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #44", + "images": [] + }, + { + "id": "pos-045", + "text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #45", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/channels/wisdom.raw.json b/tools/test-publisher/v2/channels/wisdom.raw.json new file mode 100644 index 00000000..e6205779 --- /dev/null +++ b/tools/test-publisher/v2/channels/wisdom.raw.json @@ -0,0 +1,271 @@ +[ + { + "id": "wise-001", + "text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит.", + "images": [ + "../assets/channel-art/wisdom-light.webp" + ] + }, + { + "id": "wise-002", + "text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля.", + "images": [] + }, + { + "id": "wise-003", + "text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме.", + "images": [ + "../assets/channel-art/wisdom-moon.webp" + ] + }, + { + "id": "wise-004", + "text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна.", + "images": [] + }, + { + "id": "wise-005", + "text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории.", + "images": [ + "../assets/channel-art/wisdom-path.webp" + ] + }, + { + "id": "wise-006", + "text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас.", + "images": [] + }, + { + "id": "wise-007", + "text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить.", + "images": [ + "../assets/channel-art/wisdom-water.webp" + ] + }, + { + "id": "wise-008", + "text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее?", + "images": [] + }, + { + "id": "wise-009", + "text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития.", + "images": [ + "../assets/channel-art/wisdom-light.webp" + ] + }, + { + "id": "wise-010", + "text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день.", + "images": [] + }, + { + "id": "wise-011", + "text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #11", + "images": [ + "../assets/channel-art/wisdom-moon.webp" + ] + }, + { + "id": "wise-012", + "text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #12", + "images": [] + }, + { + "id": "wise-013", + "text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #13", + "images": [ + "../assets/channel-art/wisdom-path.webp" + ] + }, + { + "id": "wise-014", + "text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #14", + "images": [] + }, + { + "id": "wise-015", + "text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #15", + "images": [ + "../assets/channel-art/wisdom-water.webp" + ] + }, + { + "id": "wise-016", + "text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #16", + "images": [] + }, + { + "id": "wise-017", + "text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #17", + "images": [ + "../assets/channel-art/wisdom-light.webp" + ] + }, + { + "id": "wise-018", + "text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #18", + "images": [] + }, + { + "id": "wise-019", + "text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #19", + "images": [ + "../assets/channel-art/wisdom-moon.webp" + ] + }, + { + "id": "wise-020", + "text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #20", + "images": [] + }, + { + "id": "wise-021", + "text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #21", + "images": [ + "../assets/channel-art/wisdom-path.webp" + ] + }, + { + "id": "wise-022", + "text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #22", + "images": [] + }, + { + "id": "wise-023", + "text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #23", + "images": [ + "../assets/channel-art/wisdom-water.webp" + ] + }, + { + "id": "wise-024", + "text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #24", + "images": [] + }, + { + "id": "wise-025", + "text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #25", + "images": [ + "../assets/channel-art/wisdom-light.webp", + "../assets/channel-art/wisdom-moon.webp" + ] + }, + { + "id": "wise-026", + "text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #26", + "images": [] + }, + { + "id": "wise-027", + "text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #27", + "images": [ + "../assets/channel-art/wisdom-moon.webp", + "../assets/channel-art/wisdom-path.webp" + ] + }, + { + "id": "wise-028", + "text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #28", + "images": [] + }, + { + "id": "wise-029", + "text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #29", + "images": [ + "../assets/channel-art/wisdom-path.webp", + "../assets/channel-art/wisdom-water.webp" + ] + }, + { + "id": "wise-030", + "text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #30", + "images": [] + }, + { + "id": "wise-031", + "text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #31", + "images": [ + "../assets/channel-art/wisdom-water.webp", + "../assets/channel-art/wisdom-light.webp" + ] + }, + { + "id": "wise-032", + "text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #32", + "images": [] + }, + { + "id": "wise-033", + "text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #33", + "images": [ + "../assets/channel-art/wisdom-light.webp", + "../assets/channel-art/wisdom-moon.webp", + "../assets/channel-art/wisdom-path.webp" + ] + }, + { + "id": "wise-034", + "text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #34", + "images": [] + }, + { + "id": "wise-035", + "text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #35", + "images": [ + "../assets/channel-art/wisdom-moon.webp", + "../assets/channel-art/wisdom-path.webp", + "../assets/channel-art/wisdom-water.webp" + ] + }, + { + "id": "wise-036", + "text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #36", + "images": [] + }, + { + "id": "wise-037", + "text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #37", + "images": [] + }, + { + "id": "wise-038", + "text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #38", + "images": [] + }, + { + "id": "wise-039", + "text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #39", + "images": [] + }, + { + "id": "wise-040", + "text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #40", + "images": [] + }, + { + "id": "wise-041", + "text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #41", + "images": [] + }, + { + "id": "wise-042", + "text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #42", + "images": [] + }, + { + "id": "wise-043", + "text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #43", + "images": [] + }, + { + "id": "wise-044", + "text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #44", + "images": [] + }, + { + "id": "wise-045", + "text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #45", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/config.example.json b/tools/test-publisher/v2/config.example.json new file mode 100644 index 00000000..9f90c6b7 --- /dev/null +++ b/tools/test-publisher/v2/config.example.json @@ -0,0 +1,12 @@ +{ + "server_ws": "ws://127.0.0.1:8080/ws", + "blockchain_name": "PUT_TEST_USER_BLOCKCHAIN_NAME_HERE", + "channel_name": "jokes", + "channel_root_block_number": 123, + "key_file": "keys/blockchain.json", + "queue_file": "queue/jokes.prepared.json", + "state_file": "state.json", + "min_interval_seconds": 1800, + "max_interval_seconds": 10800, + "loop_queue": false +} diff --git a/tools/test-publisher/v2/config.multi.example.json b/tools/test-publisher/v2/config.multi.example.json new file mode 100644 index 00000000..7d003983 --- /dev/null +++ b/tools/test-publisher/v2/config.multi.example.json @@ -0,0 +1,52 @@ +{ + "server_ws": "wss://t3.shineup.me/ws", + "blockchain_name": "PUT_TEST_USER_BLOCKCHAIN_NAME_HERE", + "key_file": "keys/blockchain.json", + "state_file": "multi-state.json", + "min_interval_seconds": 1800, + "max_interval_seconds": 10800, + "loop_queue": false, + "channels": [ + { + "channel_name": "jokes", + "channel_root_block_number": 101, + "queue_file": "channels/jokes.prepared.json", + "min_interval_seconds": 3600, + "max_interval_seconds": 10800 + }, + { + "channel_name": "mindfulness", + "channel_root_block_number": 102, + "queue_file": "channels/mindfulness.prepared.json", + "min_interval_seconds": 10800, + "max_interval_seconds": 25200 + }, + { + "channel_name": "positive", + "channel_root_block_number": 103, + "queue_file": "channels/positive.prepared.json", + "min_interval_seconds": 10800, + "max_interval_seconds": 25200 + }, + { + "channel_name": "friends", + "channel_root_block_number": 104, + "queue_file": "channels/friends.prepared.json", + "min_interval_seconds": 10800, + "max_interval_seconds": 25200 + }, + { + "channel_name": "wisdom", + "channel_root_block_number": 105, + "queue_file": "channels/wisdom.prepared.json", + "min_interval_seconds": 10800, + "max_interval_seconds": 25200 + } + ], + "timezone": "Europe/Warsaw", + "quiet_hours": { + "enabled": true, + "start": "22:00", + "end": "08:00" + } +} \ No newline at end of file diff --git a/tools/test-publisher/v2/keys/README.txt b/tools/test-publisher/v2/keys/README.txt new file mode 100644 index 00000000..fc6739ce --- /dev/null +++ b/tools/test-publisher/v2/keys/README.txt @@ -0,0 +1,4 @@ +Put secrets here locally only; never commit them. +blockchain.json - SHiNE/Solana 64-byte JSON key array used by publisher +arweave-wallet.json - Arweave JWK used ONLY by prepare_queue.py to upload images +Recommended: chmod 600 keys/*.json diff --git a/tools/test-publisher/v2/prepare.config.example.json b/tools/test-publisher/v2/prepare.config.example.json new file mode 100644 index 00000000..345ecaca --- /dev/null +++ b/tools/test-publisher/v2/prepare.config.example.json @@ -0,0 +1,9 @@ +{ + "queue_file": "queue/jokes.raw.json", + "prepared_queue_file": "queue/jokes.prepared.json", + "upload_state_file": "upload-state.json", + "arweave": { + "wallet_file": "keys/arweave-wallet.json", + "gateway": "https://arweave.net" + } +} diff --git a/tools/test-publisher/v2/prepare.multi.example.json b/tools/test-publisher/v2/prepare.multi.example.json new file mode 100644 index 00000000..6833b648 --- /dev/null +++ b/tools/test-publisher/v2/prepare.multi.example.json @@ -0,0 +1,34 @@ +{ + "upload_state_file": "upload-state.json", + "arweave": { + "wallet_file": "keys/arweave-wallet.json", + "gateway": "https://arweave.net" + }, + "channels": [ + { + "name": "jokes", + "queue_file": "channels/jokes.raw.json", + "prepared_queue_file": "channels/jokes.prepared.json" + }, + { + "name": "mindfulness", + "queue_file": "channels/mindfulness.raw.json", + "prepared_queue_file": "channels/mindfulness.prepared.json" + }, + { + "name": "positive", + "queue_file": "channels/positive.raw.json", + "prepared_queue_file": "channels/positive.prepared.json" + }, + { + "name": "friends", + "queue_file": "channels/friends.raw.json", + "prepared_queue_file": "channels/friends.prepared.json" + }, + { + "name": "wisdom", + "queue_file": "channels/wisdom.raw.json", + "prepared_queue_file": "channels/wisdom.prepared.json" + } + ] +} \ No newline at end of file diff --git a/tools/test-publisher/v2/prepare/prepare_all_channels.py b/tools/test-publisher/v2/prepare/prepare_all_channels.py new file mode 100644 index 00000000..ee91cab6 --- /dev/null +++ b/tools/test-publisher/v2/prepare/prepare_all_channels.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +import argparse,json,hashlib,mimetypes,time +from pathlib import Path +import requests +try: import arweave +except ImportError: arweave=None + +def atomic(path,obj): + p=Path(path); p.parent.mkdir(parents=True,exist_ok=True) + t=p.with_suffix(p.suffix+'.tmp'); t.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf8'); t.replace(p) + +def exists(gateway,txid,timeout=15): + url=f"{gateway.rstrip('/')}/{txid}" + try: + r=requests.head(url,allow_redirects=True,timeout=timeout) + if r.status_code in (200,204): return True + if r.status_code in (405,501): + r=requests.get(url,headers={'Range':'bytes=0-0'},timeout=timeout) + return r.status_code in (200,206) + return False + except requests.RequestException: return False + +def main(): + ap=argparse.ArgumentParser(description='Prepare images for all SHiNE test channels') + ap.add_argument('--config',default='prepare.config.json'); a=ap.parse_args() + cfg=json.load(open(a.config,encoding='utf8')); base=Path(a.config).resolve().parent + rp=lambda s:(Path(s) if Path(s).is_absolute() else (base/s).resolve()) + gateway=cfg['arweave'].get('gateway','https://arweave.net').rstrip('/') + statep=rp(cfg.get('upload_state_file','upload-state.json')) + state=json.loads(statep.read_text()) if statep.exists() else {'version':2,'files':{}} + wallet=None + def ensure_wallet(): + nonlocal wallet + if wallet is None: + if arweave is None: raise SystemExit('pip install -r requirements.txt') + wallet=arweave.Wallet(str(rp(cfg['arweave']['wallet_file']))) + return wallet + + summary={'channels':0,'posts':0,'images':0,'verified_existing':0,'uploaded':0} + for ch in cfg['channels']: + rawp=rp(ch['queue_file']); outp=rp(ch['prepared_queue_file']) + items=json.loads(rawp.read_text(encoding='utf8')); prepared=[]; summary['channels']+=1 + for n,item in enumerate(items,1): + refs=[] + for rel in item.get('images',[]): + fp=(rawp.parent/rel).resolve(); data=fp.read_bytes(); sha=hashlib.sha256(data).hexdigest() + summary['images']+=1; rec=state['files'].get(sha); txid=None + if rec and exists(gateway,rec['id']): + txid=rec['id']; summary['verified_existing']+=1 + print(f"[{ch['name']}:{n}] verified {fp.name} -> {txid}") + else: + ctype=mimetypes.guess_type(fp.name)[0] or 'application/octet-stream' + tx=arweave.Transaction(ensure_wallet(),data=data) + tx.add_tag('Content-Type',ctype); tx.add_tag('App-Name','SHiNE-Test-Publisher'); tx.add_tag('SHA-256',sha) + tx.sign(); tx.send(); txid=tx.id; summary['uploaded']+=1 + state['files'][sha]={'id':txid,'file':str(fp),'uploaded_at':int(time.time())}; atomic(statep,state) + print(f"[{ch['name']}:{n}] uploaded {fp.name} -> {txid}") + refs.append({'arweave_id':txid,'url':f'{gateway}/{txid}','sha256':sha}) + text=item['text'].rstrip() + if refs: text += '\n\n' + '\n'.join(x['url'] for x in refs) + prepared.append({'id':item.get('id',str(n)),'text':text,'images':refs}) + summary['posts']+=1 + atomic(outp,prepared) + print(f"Ready {ch['name']}: {outp} ({len(prepared)} posts)") + print(json.dumps(summary,ensure_ascii=False,indent=2)) +if __name__=='__main__': main() diff --git a/tools/test-publisher/v2/prepare/prepare_queue.py b/tools/test-publisher/v2/prepare/prepare_queue.py new file mode 100644 index 00000000..ef0bf554 --- /dev/null +++ b/tools/test-publisher/v2/prepare/prepare_queue.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +import argparse,json,hashlib,mimetypes,sys,time +from pathlib import Path +import requests +try: + import arweave +except ImportError: + arweave=None + +def atomic(path,obj): + p=Path(path); t=p.with_suffix(p.suffix+'.tmp'); t.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf8'); t.replace(p) + +def main(): + ap=argparse.ArgumentParser(description='Upload all queue images to Arweave and build prepared queue') + ap.add_argument('--config',default='prepare.config.json'); a=ap.parse_args() + cfg=json.load(open(a.config,encoding='utf8')); base=Path(a.config).resolve().parent + def rp(s): + p=Path(s); return p if p.is_absolute() else (base/p).resolve() + rawp=rp(cfg['queue_file']); outp=rp(cfg['prepared_queue_file']); walletp=rp(cfg['arweave']['wallet_file']); gateway=cfg['arweave'].get('gateway','https://arweave.net').rstrip('/') + statep=rp(cfg.get('upload_state_file','upload-state.json')); state=json.loads(statep.read_text()) if statep.exists() else {'files':{}} + if arweave is None: raise SystemExit('Install requirements first: pip install -r requirements.txt') + wallet=arweave.Wallet(str(walletp)) + items=json.loads(rawp.read_text(encoding='utf8')); prepared=[] + for n,item in enumerate(items,1): + urls=[]; ids=[] + for rel in item.get('images',[]): + fp=(rawp.parent/rel).resolve(); data=fp.read_bytes(); sha=hashlib.sha256(data).hexdigest(); old=state['files'].get(sha) + if old: txid=old['id']; print(f'[{n}] cached {fp.name} -> {txid}') + else: + ctype=mimetypes.guess_type(fp.name)[0] or 'application/octet-stream' + tx=arweave.Transaction(wallet, data=data); tx.add_tag('Content-Type',ctype); tx.add_tag('App-Name','SHiNE-Test-Publisher'); tx.add_tag('SHA-256',sha); tx.sign(); tx.send(); txid=tx.id + state['files'][sha]={'id':txid,'file':fp.name,'uploaded_at':int(time.time())}; atomic(statep,state); print(f'[{n}] uploaded {fp.name} -> {txid}') + ids.append(txid); urls.append(f'{gateway}/{txid}') + text=item['text'].rstrip() + if urls: + # Current SHiNE POST payload is text, so URLs are appended as ordinary lines. + text += '\n\n' + '\n'.join(urls) + prepared.append({'id':item.get('id',str(n)),'text':text,'images':[{'arweave_id':i,'url':u} for i,u in zip(ids,urls)]}) + atomic(outp,prepared) + print(f'Ready: {outp} ({len(prepared)} posts)') +if __name__=='__main__': main() diff --git a/tools/test-publisher/v2/publisher/publisher.py b/tools/test-publisher/v2/publisher/publisher.py new file mode 100644 index 00000000..013a87fb --- /dev/null +++ b/tools/test-publisher/v2/publisher/publisher.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from websockets.asyncio.client import connect + +ZERO32 = bytes(32) +FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1 + +def atomic_json(path, obj): + path=Path(path); path.parent.mkdir(parents=True, exist_ok=True) + tmp=path.with_suffix(path.suffix+'.tmp') + tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8') + os.replace(tmp,path) + +def load_json(path, default=None): + p=Path(path) + return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default + +def avro_long(n): + n=(n<<1) ^ (n>>63); out=bytearray() + while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7 + out.append(n); return bytes(out) + +def avro_tags(tags): + out=bytearray() + if tags: + out += avro_long(len(tags)) + for k,v in tags: + kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb + out += avro_long(0); return bytes(out) + +def deep_hash(x): + H=lambda b: hashlib.sha384(b).digest() + if isinstance(x,(bytes,bytearray)): + b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b)) + acc=H(f'list{len(x)}'.encode()) + for child in x: acc=H(acc+deep_hash(child)) + return acc + +def signing_message(owner,tags,data): + raw=avro_tags(tags) + return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data]) + +def data_item(priv64,tags,data): + if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes') + seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw() + sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data)) + rawtags=avro_tags(tags) + return struct.pack('65535: raise ValueError('post text too long') + return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb + +def frame(prev_hash, block_num, body, ts=None): + ts=int(time.time()) if ts is None else int(ts); size=56+len(body) + return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body + +def h32(b): return hashlib.sha256(b).digest() +def hx(b): return b.hex() + +async def ws_call(url, op, payload, timeout=20): + req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload} + async with connect(url, open_timeout=timeout, close_timeout=5) as ws: + await ws.send(json.dumps(req,separators=(',',':'))) + end=time.monotonic()+timeout + while True: + left=end-time.monotonic() + if left<=0: raise TimeoutError(op) + msg=await asyncio.wait_for(ws.recv(),left) + obj=json.loads(msg) + if obj.get('requestId')==req['requestId']: return obj + +async def head(cfg): + r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{}) + if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}') + for x in r.get('payload',{}).get('blockchains',[]): + if x.get('blockchainName')==cfg['blockchain_name']: + return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','') + raise RuntimeError('blockchain not found on server') + +async def block(cfg,n): + r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n}) + if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}') + p=r.get('payload',r) + return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64') + +async def channel_tail(cfg, root_hash): + r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'}) + if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}') + items=r.get('payload',{}).get('messages',[]) + if not items: return 0, ZERO32 + m=items[0]; ref=m.get('messageRef') or {} + return int(m.get('lineStep') or 0), bytes.fromhex(ref.get('blockHash','00'*32)) + +def read_key(path): + a=load_json(path) + if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array') + return bytes(int(x)&255 for x in a) + +def queue_items(cfg): + q=load_json(cfg['queue_file']) + if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array') + for x in q: + if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text') + return q + +def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds'])) + +async def init_state(cfg): + bn,bhash=await head(cfg) + root_hash,_=await block(cfg,int(cfg['channel_root_block_number'])) + line_num,line_hash=await channel_tail(cfg,root_hash) + st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0} + atomic_json(cfg['state_file'],st); return st + +async def reconcile(cfg,st): + p=st.get('pending') + bn,bhash=await head(cfg) + if p: + if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower(): + st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn) + elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower(): + print('Pending block was not accepted; it will be retried') + else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}') + else: + if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork') + return st + +async def publish_one(cfg,st,items): + if st.get('pending'): + p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash'] + else: + idx=int(st['next_index']) + if idx>=len(items): + if cfg.get('loop_queue',False): idx=0 + else: print('Queue exhausted. Nothing to publish.'); return False + text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1 + body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text) + fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())] + raw=data_item(read_key(cfg['key_file']),tags,fr) + nextidx=idx+1 + p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()} + st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash'] + r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30) + if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}') + server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash') + if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash') + st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st) + print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}") + return True + +async def main_async(args): + cfg=load_json(args.config); required=['server_ws','blockchain_name','channel_name','channel_root_block_number','key_file','queue_file','state_file','min_interval_seconds','max_interval_seconds'] + for k in required: + if k not in cfg: raise ValueError('missing config: '+k) + base=Path(args.config).resolve().parent + for k in ('key_file','queue_file','state_file'): + p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p) + items=queue_items(cfg) + st=load_json(cfg['state_file']) + if st is None: st=await init_state(cfg); print('Initialized state from server/channel') + st=await reconcile(cfg,st) + if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return + if args.command=='publish-now': await publish_one(cfg,st,items); return + while True: + st=await reconcile(cfg,load_json(cfg['state_file'])) + wait=max(0,int(st.get('next_publish_at',0))-int(time.time())) + if wait: print(f'Waiting {wait}s'); await asyncio.sleep(wait) + ok=await publish_one(cfg,st,items) + if not ok: await asyncio.sleep(3600) + +def main(): + ap=argparse.ArgumentParser(description='SHiNE test channel publisher') + ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run'); ap.add_argument('--config',default='config.json') + a=ap.parse_args() + try: asyncio.run(main_async(a)) + except KeyboardInterrupt: pass + except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1) +if __name__=='__main__': main() diff --git a/tools/test-publisher/v2/publisher/publisher_multi.py b/tools/test-publisher/v2/publisher/publisher_multi.py new file mode 100644 index 00000000..fe33f91c --- /dev/null +++ b/tools/test-publisher/v2/publisher/publisher_multi.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid +from pathlib import Path +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from websockets.asyncio.client import connect + +ZERO32 = bytes(32) +FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1 + +def atomic_json(path, obj): + path=Path(path); path.parent.mkdir(parents=True, exist_ok=True) + tmp=path.with_suffix(path.suffix+'.tmp') + tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8') + os.replace(tmp,path) + +def load_json(path, default=None): + p=Path(path) + return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default + +def avro_long(n): + n=(n<<1) ^ (n>>63); out=bytearray() + while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7 + out.append(n); return bytes(out) + +def avro_tags(tags): + out=bytearray() + if tags: + out += avro_long(len(tags)) + for k,v in tags: + kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb + out += avro_long(0); return bytes(out) + +def deep_hash(x): + H=lambda b: hashlib.sha384(b).digest() + if isinstance(x,(bytes,bytearray)): + b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b)) + acc=H(f'list{len(x)}'.encode()) + for child in x: acc=H(acc+deep_hash(child)) + return acc + +def signing_message(owner,tags,data): + raw=avro_tags(tags) + return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data]) + +def data_item(priv64,tags,data): + if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes') + seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw() + sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data)) + rawtags=avro_tags(tags) + return struct.pack('65535: raise ValueError('post text too long') + return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb + +def frame(prev_hash, block_num, body, ts=None): + ts=int(time.time()) if ts is None else int(ts); size=56+len(body) + return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body + +def h32(b): return hashlib.sha256(b).digest() +def hx(b): return b.hex() + +async def ws_call(url, op, payload, timeout=20): + req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload} + async with connect(url, open_timeout=timeout, close_timeout=5) as ws: + await ws.send(json.dumps(req,separators=(',',':'))) + end=time.monotonic()+timeout + while True: + left=end-time.monotonic() + if left<=0: raise TimeoutError(op) + msg=await asyncio.wait_for(ws.recv(),left) + obj=json.loads(msg) + if obj.get('requestId')==req['requestId']: return obj + +async def head(cfg): + r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{}) + if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}') + for x in r.get('payload',{}).get('blockchains',[]): + if x.get('blockchainName')==cfg['blockchain_name']: + return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','') + raise RuntimeError('blockchain not found on server') + +async def block(cfg,n): + r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n}) + if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}') + p=r.get('payload',r) + return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64') + +async def channel_tail(cfg, root_hash): + r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'}) + if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}') + items=r.get('payload',{}).get('messages',[]) + if not items: return int(cfg['channel_root_block_number']), bytes.fromhex(root_hash), -1 + m=items[0]; ref=m.get('messageRef') or {} + return int(ref.get('blockNumber')), bytes.fromhex(ref.get('blockHash','00'*32)), int(m.get('lineStep') or 0) + +def read_key(path): + a=load_json(path) + if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array') + return bytes(int(x)&255 for x in a) + +def queue_items(cfg): + q=load_json(cfg['queue_file']) + if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array') + for x in q: + if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text') + return q + +def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds'])) + +async def init_state(cfg): + bn,bhash=await head(cfg) + root_hash,_=await block(cfg,int(cfg['channel_root_block_number'])) + line_num,line_hash=await channel_tail(cfg,root_hash) + st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0} + atomic_json(cfg['state_file'],st); return st + +async def reconcile(cfg,st): + p=st.get('pending') + bn,bhash=await head(cfg) + if p: + if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower(): + st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn) + elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower(): + print('Pending block was not accepted; it will be retried') + else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}') + else: + if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork') + return st + +async def publish_one(cfg,st,items): + if st.get('pending'): + p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash'] + else: + idx=int(st['next_index']) + if idx>=len(items): + if cfg.get('loop_queue',False): idx=0 + else: print('Queue exhausted. Nothing to publish.'); return False + text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1 + body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text) + fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())] + raw=data_item(read_key(cfg['key_file']),tags,fr) + nextidx=idx+1 + p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()} + st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash'] + r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30) + if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}') + server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash') + if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash') + st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st) + print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}") + return True + + +async def init_multi_state(cfg): + bn,bhash=await head(cfg) + chans={} + now=int(time.time()) + for ch in cfg['channels']: + local=dict(cfg); local.update(ch) + root_hash,_=await block(local,int(ch['channel_root_block_number'])) + line_block,line_hash,line_step=await channel_tail(local,root_hash) + chans[ch['channel_name']]={'next_index':0,'last_line_block_number':line_block, + 'last_line_number':line_step,'last_line_hash':hx(line_hash), + 'next_publish_at':next_channel_time(cfg,ch,now), + 'published_total':0} + st={'version':2,'last_block_number':bn,'last_block_hash':bhash,'pending':None,'channels':chans} + atomic_json(cfg['state_file'],st); return st + +def channel_delay(cfg,ch): + return random.randint(int(ch.get('min_interval_seconds',cfg['min_interval_seconds'])),int(ch.get('max_interval_seconds',cfg['max_interval_seconds']))) + +def _hm(v): + h,m=str(v).split(':',1); return int(h),int(m) + +def _quiet(dt,ch,cfg): + q=ch.get('quiet_hours',cfg.get('quiet_hours',{})) + if not q or not q.get('enabled',True): return False + sh,sm=_hm(q.get('start','22:00')); eh,em=_hm(q.get('end','08:00')) + t=dt.hour*60+dt.minute; a=sh*60+sm; b=eh*60+em + return (a<=t or tb else (a<=t0: + q=ch.get('quiet_hours',cfg.get('quiet_hours',{})) + if not q or not q.get('enabled',True): + return int((dt+timedelta(seconds=remain)).timestamp()) + sh,sm=_hm(q.get('start','22:00')); eh,em=_hm(q.get('end','08:00')) + if _quiet(dt,ch,cfg): + end=dt.replace(hour=eh,minute=em,second=0,microsecond=0) + if end<=dt: end+=timedelta(days=1) + dt=end; continue + qs=dt.replace(hour=sh,minute=sm,second=0,microsecond=0) + if qs<=dt: qs+=timedelta(days=1) + usable=max(0,int((qs-dt).total_seconds())) + if remain<=usable: return int((dt+timedelta(seconds=remain)).timestamp()) + remain-=usable; dt=qs + return int(dt.timestamp()) + +def next_channel_time(cfg,ch,start_ts=None): + return active_deadline(cfg,ch,channel_delay(cfg,ch),start_ts) + +async def reconcile_multi(cfg,st): + bn,bhash=await head(cfg); p=st.get('pending') + if p: + cs=st['channels'][p['channel_name']] + if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower(): + st['last_block_number']=bn; st['last_block_hash']=bhash + cs['last_line_block_number']=p['block_number']; cs['last_line_number']=p['this_line_number']; cs['last_line_hash']=p['block_hash'] + cs['next_index']=p['next_index_after']; cs['published_total']=int(cs.get('published_total',0))+1 + ch=next(x for x in cfg['channels'] if x['channel_name']==p['channel_name']) + cs['next_publish_at']=next_channel_time(cfg,ch); st['pending']=None; atomic_json(cfg['state_file'],st) + elif bn!=p['previous_block_number'] or bhash.lower()!=p['previous_block_hash'].lower(): + raise RuntimeError('global chain changed while a pending block exists; refusing to fork') + elif bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): + raise RuntimeError('server global chain head differs from publisher state; refusing to fork') + return st + +async def publish_channel(cfg,st,ch,force=False): + cs=st['channels'][ch['channel_name']]; items=queue_items(ch) + idx=int(cs['next_index']) + if idx>=len(items): + if ch.get('loop_queue',cfg.get('loop_queue',False)): idx=0 + else: + cs['next_publish_at']=int(time.time())+3600; atomic_json(cfg['state_file'],st); return False + text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1 + prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(cs['last_line_number'])+1 + prev_line_block=int(cs.get('last_line_block_number', ch['channel_root_block_number'])) + body=post_body(int(ch['channel_root_block_number']),prev_line_block,bytes.fromhex(cs['last_line_hash']),this_line,text) + fr=frame(prev_hash,block_num,body); block_hash=h32(fr) + tags=[('App','test5590'),('c',ch['channel_name'].strip().lower())] + raw=data_item(read_key(cfg['key_file']),tags,fr) + p={'channel_name':ch['channel_name'],'queue_index':idx,'next_index_after':idx+1,'block_number':block_num, + 'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'], + 'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()} + st['pending']=p; atomic_json(cfg['state_file'],st) + r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num, + 'prevBlockHash':st['last_block_hash'],'blockBytesB64':base64.b64encode(raw).decode()},timeout=30) + if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}') + st['last_block_number']=block_num; st['last_block_hash']=p['block_hash'] + cs['last_line_block_number']=block_num; cs['last_line_number']=this_line; cs['last_line_hash']=p['block_hash']; cs['next_index']=idx+1 + cs['published_total']=int(cs.get('published_total',0))+1; cs['next_publish_at']=next_channel_time(cfg,ch) + st['pending']=None; atomic_json(cfg['state_file'],st) + print(f"Published {ch['channel_name']}[{idx}] as global block #{block_num}") + return True + +async def main_async(args): + cfg=load_json(args.config); base=Path(args.config).resolve().parent + for k in ('key_file','state_file'): + p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p) + if not isinstance(cfg.get('channels'),list) or not cfg['channels']: raise ValueError('channels[] required') + for ch in cfg['channels']: + p=Path(ch['queue_file']); ch['queue_file']=str(p if p.is_absolute() else base/p) + for k in ('channel_name','channel_root_block_number','queue_file'): + if k not in ch: raise ValueError('channel missing '+k) + st=load_json(cfg['state_file']) + if st is None: st=await init_multi_state(cfg) + st=await reconcile_multi(cfg,st) + if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return + if args.command=='publish-now': + name=args.channel or cfg['channels'][0]['channel_name'] + ch=next((x for x in cfg['channels'] if x['channel_name']==name),None) + if not ch: raise ValueError('unknown channel '+name) + await publish_channel(cfg,st,ch,True); return + while True: + st=await reconcile_multi(cfg,load_json(cfg['state_file'])) + now=int(time.time()) + due=sorted(cfg['channels'],key=lambda c:st['channels'][c['channel_name']]['next_publish_at']) + ch=due[0]; wait=max(0,int(st['channels'][ch['channel_name']]['next_publish_at'])-now) + if wait: await asyncio.sleep(wait) + await publish_channel(cfg,st,ch) + +def main(): + ap=argparse.ArgumentParser(description='SHiNE multi-channel test publisher') + ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run') + ap.add_argument('--config',default='config.json'); ap.add_argument('--channel',default='') + a=ap.parse_args() + try: asyncio.run(main_async(a)) + except KeyboardInterrupt: pass + except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1) +if __name__=='__main__': main() diff --git a/tools/test-publisher/v2/queue/images/joke_01.webp b/tools/test-publisher/v2/queue/images/joke_01.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..ede941550fc5848d6d0e3761267ec8d8872a85495f68f2762e76e0dbb46a0ffa GIT binary patch literal 7664 zcmVkB^uk)|QKdSuJ_MiTLhyO7(4*ZY$2l>7P-IM+Y{hv`E zVZO8f2mFVTp0M5j|2_8k_5=Lm`X~Bt?0$BBHa=j{v3`TLZ)eBqWiT+Pqc#{9%21fDZ`eWxYBED(GJ6FV`#pW}acUW!vY}|5XLnv0 z#4Lc!i%0Xw4Z9vGUgiZtN&s3mxDi@Z(FVYpfx5k_VRxdpX)x!J*)fivrNsImLlH<`;@)%Vpi(U zm#mgck$0ig?;Wt`0kBT17ucGK8cYnKLHv(cjjw~#g>H`RyMiQKrF5Nz4c~W{bxGyhFp{x0V{0}Rwxc+go~k<^V- zEWHAm+_~5EkHnkj%uH42^RkTSBl*bF%{dT@8cr0DkF4AM*H{uD_W5&Hvo1r-Aae|? zCADcpoV+=Rhdg~7oo%pF^ws?;AuY|?cV-%%?8cGSBDxlgQrJWSHK5+9`A04Jf>Y+m zcM4%q=U!=eJno6?&S8_j7t8z66nsVd@%>{dv7pOhl&SqWvC^~|zQCnL%ad-)5 zo9e89Q1qtF4IHBwkZT)uMLm3N6YJCAw#~<&K=^)^HZZ&sYWdi5Ro76}l9i_jR0uE) z|N9R(;2hT$x;#HEDNHQ?Z}EE-Z*$_}Aui44?r#;4jzVmIe#0~o92L*lb%)ZZ?tgeN zSki^qwpYd_0A_x5pokaolEDN}AtwQo*<*fn{JncDgNlpQi@tNJ9> z#K&GQS4*C?c971gt*#<0BqLSgUH8aDuH<1X^~GX42%2i+QxQ-A7R$gv9MoERm%2Ov z0RFY@5u5Y*E}y8P`nL}Jwm*-DM*+Cx-ztO5m!#HPyOmFD#; zows&HNAu6}Viklkh>@zCnIPR}Ei+fc`02wWq6>Zm7j8tJd&)Tq!VvNBzguOo={HH} zCN}^k$E!T0NZ?QV)Zp%%MliP1?kdga6(P?l%3OsD#laQAk<9xA8uUf*{^=B9_9#ru*& zZDZk|H}ub_Cxui-;fTlM0)_0hhMOo#pVVpmIJsW%PLnnVj)gtY)wz*;Z>ZtEJi;{F z4-tc4@PEvr-)r!We>pv5(5KyM@T2~K+s$!;l8|rRY$tu1(|JBQ%*PU1=;KXr+Ja96M znjVQT#Z^6as(hO#r8671gWko9GqL)y?P38pIGyB;5;c=*&(Ox8 z4Q}n>mL?A6jJ4_K?)!XrvNPmF9^@P^nWWF>V%<+G#=6K6{s~yOV0M6qMO+R-B(kXm zwWf7T^_4Q|P><6;|0#sVso(N81u%XJd}!vUhA7zA}l(Q&O9?bESuRDB&&DNg{4n)a*-Fp0EV>*+W{7nG{d+`UbsUaHu@GM z`c7_mIe`%i9vcnm7)`{a(h-|`nG5^2M!kD7rbEvQ1;~h|pmR~66{_8`?NNTYMOKDW z?~S2uzX=-&^@Io~3BXFOWgTp-7~Kqiz>{WMTqwKAvDD>)TSVPpCe%#ro;0~DxC?_6 zRyy>poj{cIeK!IT&F}5-j5tv4cXtlChW@i?*3j;F?!J?%Soug}JH^EoGgk5N_u|L- zU(^v$J6a|=1lFPoY4+S%l^*ii^)_>V!V4z|$bV9D#ZulI4km9SFOw5O;^IlcrfK&_ zv%$LT)0bx#PhUksu;LbxI5NaJOr*lXFikrX)mpQ`>oaM#`N@|FwX{2!vFSJ4j zWVkMHPQ_y#%NzsrvkaZngNx1+YwvKTFe>4kdEc~cY|=-s zd<=jj(C?|m>zoAW@JNKr3J6}m*MX-8Cu`}*0=fY2VXK?j(`yIoSW}zry<<9}YEX zVZVwk^B?sHRF!A7-oPr$%$;vfM>REnZxkDI;|D_EyP&!SBn+d$TyrCH-3-U7q@OlO z1|xIyijbh#rJ?aXc}Zfm=GnqgsZU(1)R4lDPRhQtEXaNw5u;2_2GJ)RWa1fa$eFOG zCEzH-no+u-aix%~Z$Ql%YBV@kyy{@)@06$Fo@>(OZDyZ)u}{C}sxO z^kOZg^Fy4XkSfFptROA}d$0jiFRvy2uOI!H`-9ne7{d7tw4;t(Q|TdRPIsveUWqMF zPg?LZx+oF-%vtOLF@Y}N#|I>KJwAqt#@Q#d`o>Cs+KB#`l^%>0gO*H#mH9xwLm|7jrw@1xd&-q0qDTM2tC9hC{_xd>`;u}x`uG~r8_wg z7;(eZHD>~9&5d>#YRw?ha0McUX!AD=SvuC0p*L?FwJmFV=7>2kk}mml1=r6m-+%yv z!7XwolwU@=VJDcZHVsOqd+oXm*h5!|$k0VSwU!?0k5Fyp2r1XeXhe~pzkM~c0&lw> z7?4$^-z=L&h_>4im_R8k|7z$CVnjH>Ojf)w2XSYa|GxA6_X@H@Yi0Xfox}wvU!m~v?bLqjPwIjp6sXb)yfEF9D(N) zykr)EA_u9whYgyMJz(2Jbg z?+#<|&9AjI6HYC=^Lt-8CBe~dD(DmJXwpJbW77Az_*%f@O$9gdAlCJ(jk7zCyq!bV zo%>3UcRUsUAkUu`)TH~W*=Oa)Z6J|i(KfTj*dv?Cjo`qT)L3lbiPikvX^Wg)W-4ia zZtdi&js{)t7c(0fwW?lkk70lODB9Mqi!L7rd<7Cd;)`!U@I-kVF?QxoyG0^E4?)~@ z^_GdztB?4~3D+lIn$`)DxEM2z@C*FVmYUM8PwC@~!WumNcDE-})Lo8oyhsZJc~kyl z>OP*)w2>dw7Sb*D8#<`m5ScTl7~kEHbogNt0q};U|J%j)-oL+k6Hnk@UIx(FTUFgc zS#{2+y>|qsO8O%apy*Ce46J3hE0q^Bq#zgkffpFxlV7d=t?9bgWF>+=p1r^w0sgef zNX%F*U+Xy9YwAmPJme~NcqLO3$Ldb08t(+4KEk;+rZF3f17AJ=*#^afT6?*lHe!MFx zR&_}Iw(#soy2nuCZNl(uDM)j_#y2{;4QQU9xv<9I*%eJHskp`HZ{id+={d2Q3w6p7 zjq5GP=*Y~6fM8THD#C<@$8GBAIdXJ;K~gotxeW~-B^q|OO*jfP`z52F(|7zhivrSJ z`etCm)9{q_x`?kJPRSs*7flxD7LwdUuf23xQZ!>fhd;x1u_Svy6(7{J9@N(F+-3>i zCTadubpA;*j3Zb-8AhQ@;k`;x7bE0!sL{D@LT~NX>_{CS+nBM5fo$gY;MBRSHdw|Z z;C`=8E^(?Jf5cP>J~RFo{6bKiE_$;}KAP5YwDUty&snMERPnE?lk`pYZpUDZ7k;Ucvi{`e=<{Y^du0SqK)Nk#-4 zK(#rCkq>g*xb~=Ip~*HwW{b;n-$fj8FHvA-X2zOc8o~QzBKH+&{!8pyw@f5D4-{wIPIQ_jD6dI4xMzqLQU>hv$`H}vh4I2%{ z*Jrz}i3wdvRxX{}+3wl8Q9wAd0{2OV=VEza)=IYTW5|8^Os92D= zUx(`bz)BO(4^>Q5sJKY8t=ZzDA2aAHY0Nqg!m!fN-HW0CKrTcsZ1PF<{)X9V6>G!j zun`q>VPFOhI2soe4Bxd;Rwt>Bn177wgT%VS!2i<3Sr3V7fSU4<=O=)Tk{d6xz=>o6 z9Bd)K&`x+jrhJ>bTckS3dxTmg+@W}%0UIzi4hCFWOfY&`9lC3<)m8qE^_@CX1PwRR z6Lv@Fddz{(uoW9218;Jk;s_p)A>n^irWqFxD1qs>;BmX!xgCW8g_a(4_X8h0l!LYY zL_h*sG%(bzA-V(+QjQz1A=V17a;QLNd$XG!nyKgs(NfX;u5hs8(uyYrpdC-wahNPtb|f_JC5X zfC+f*6^peL=;|}M*+8`0w*nU#^pC$Xu|(4+K}ZU`@;g;N#@HIz4C$OQ(n7fP5itg_Uby!# zMms47BfEvHB-9ExQch`mqAo!gr|7Hv3pZu>JG}yPvKs5fHk740*iz zm$^g;g3Ht8{B6}7i1(iH1bDb!H^re2j68TyD=gz3$@J>!AXK(%=cORoD**T4W(L}> zsEoHT53cqlw1oJzU=Xgy)RKFwff>pQP2n-pkyS_6YIY1qnEMt1*%G7hp2ZU6iBIyr;T20--i~Lz%zt<1`uGDYrZzZ4KRu77TRQow1Ga5kF!T% zQZKTYqeO%pdP;hqe5H2$q|!5Kj{HCE^irU+8=Rk0vh?iOxxKx9_@2Ode(wvj((@Br zPVk}iM}#`Ws`y{HwdN`=b_<;2^zc{HwzR5C{3z9MJXrU!YspA!<1kJ|!?yj+^cP<& z4+fefe;nNWX37gEKPTweJ|ju?s?U&-H1@I2KbU}nVQ^AuFueO8K!Sdl$pP#nTio$I zPje)$?uw(7?=_#$y5n;h(*=7qim=#WQw0o|fH}!>;EMinqt+81y)6hlV z7F@VLC%iiAsYb(3P=vnQs?(V_BhjsFSq+OjIe_xPdEvX5tIl1sq^<&60~`a>^3vy> z!c5AsOR1HG)_O_}N5XljpWBB-tH8B>(bd>O+xh97Hh#5YlsEDW$r|Jk{upP{jMCTr zhAZ>4yr2+_kP>g_qpA}n$6Zbxz05Y0^}RIfQ&P>q(NsXK6R76YUuaZK~wMD zEvVNQ&776{FN3%LsR(dFq93nF@yfm#J8C0x8zq3{8pFVvg=mMtzQ&I^gJW?*wAbN> ziRKdjl-`4nfGtu~%*y~YqSeH_85FZ<0&gNBGKVk5b8iJY^30q{-vZiZe|WQ8xTqWebM;(q*y8fTkXZ{Au;{GG%3%$7HxqaYf)5!!ot$!b## zqX(wnpeA3kH&Ch9p4Ljo5U7Evnq+S&M&-Jadx>jlfs;kv8W8oE^=C^h;MAPJw7!4M z-Mv_SjmPwO88QR$1t5ZJv*w@0XN~HmkE2`YwR7~|Wt@2ayd(%8pDG{_JC4E@n7zM< zbk_uBKMC81%DEzn<7lgk6;l$+h?2o4vS76ysLvk0XwT`7lOpX;8qG9ND~F~O)bZ#| zyJ$CPC=@)hz{_avyXa*{(YHaAM=tr4t3B#xa|g0#(aI)+!F{9iM>Q1y z=GaTAL4>DkEfL4x!%rrpi}gK7>9HK@Pu()|_mI*(Z2mUy|c-jMQ5?Sr=t zSNf8P4V7Gw#ezAxTif$s%Of)wh6Q4#TR>#^)4<0p*(`NudaH@|T6+8Wck! ztZI5uoVi>YThTVbX?9IqRpiKXP=te6qD078v^1mMcVMzVjyTPEqno` ziz}DYwL`NAQ&y(f2-H`>0!G)MYOazRep0U0R_jN;u=?9A3uwM->E>#HyT(y##}ji| zOV-z)yRU8a|MYF0X7G-hQvC^oJ{HCvFR{kU`+jZUM1}isFg*{nBZC>%ZaJ}E%kxf8 zva&MG=?X*jBd`+IiIsdw#%r5}0brL?BsS;!)?e2U59#VU2Jz(IJzf+<>Ad^I_zG;! z%~S+MhzbuN`A6>bT7G8nx{?MPr>;LKR@~T}Bn7C?Nzs;}lMhkbp9pUoxj(Dn96(_C z$^GIQSW{UE0EGt3(Z(|vr7b_dik5I3eEyY*dmh!g*{x&E4p>U@-{B^gZE2mqHS5nv zJ8c?}{ueH2;`fB>+K{422>BW~62y|x=^SHy0hI_@ zYht@*l<}AzWuu-0VmM92Sia2NfIP*A-7I0O%k0RfNFJ@*(N6(9dOW%l)n1mK<{l(2 z;Z}HtRGpe3fU9QJ1Gf&qGygCE`LMn8GV@BFBocQM`0Zx)Z$vlnw~PIKFb}3;bD`tu z91@>e>6(qmqJ5o3&wIGU-2GSp{Kgh}JP#V34Z#3$4g{pw#!78r2u+3jpROA}gV2w> z#1UA-W&Ethr@s|m_Xk1wDn?{TgPUUC(|P+2)>*dDeB;&H@&A$D3ur12PR4@e$txO3 z{!DtW+EQV)1&%pY%{=gQ$R6gEvRIydM;;YDDI`If%Q@y0e}9fNrNgdkQ{R+0J~k;g z!;%uN#FDn41Za{I;nt>5w`&zbtYO$h^JPG}N&Jl8JSVsS0jd ze{VFGVjuup+KbGd+@RTp@Yod3wICn*xawtXroEIzr!8PGB;gE-&rOCYdzz#pDmsH* z@!E3{vy9nRXO7j}h+ZsoOAPo$eNwaRd{zH+)*B(;7@NB+!u7Kwd6M(%WJsbQ@g)*b zyB{A3ud?pH58sG$>Yd5MU%7b;ZA^BfVwcOBT)vorOq^mn_-5B}>=ct#9V=6GLh~Pr zH3J=OQFF3X$2;6@^%no^2>J_J#aZ>mzc3=XQyoY!9xWf`^&%?_GK4txAAR{+e}Gz7 z+D=L&d@h{+y@6)nr}zsA|M_*fY-X3rw<545NDv0m_wk`ti<3#MA`Zg?Lp~diB-lAP zM+Av-^@0dyfyV*rL}dPQ!KNFDA$Qn%Q@%!He&+Dy-y&BlG_~8n$oa0Rh1-2y3H)VP znxA04*kyQFFH}h6>Pnx{;sm6ZnQY~(Ds@M8DHY%MYGwGHkU{jZW{Tx2#pbm&14Us4*2E zNbOby5_OR?PdO^Ei{WCxt?>Jn8j-oAu*^$5c!k2Zn9&~CuahaT<4I=7b7i;0!^PjAH4@T( zvnA8n9Tzc_S=M=~5VE4&4s5uZA`*5j)&x`**)!BvZxmjcs72ZPhW4B|cz{tS=PdN? e-}y3?FMt350H6Rb4MCrm02jbQ#_hl#Yp?)>yDj_x literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/queue/images/joke_02.webp b/tools/test-publisher/v2/queue/images/joke_02.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..98b950245a4a2f00671a0e76f78b4d4430102b7d23693f5865efeb7dbc0a65a7 GIT binary patch literal 5834 zcmV;*7B%ToNk&G(761TOMM6+kP&gpA761TnVgQ{1D&YX(06v{aol7O8BO<1hJE*V| ziDgUwc@gRa1^Alc2czlZa0ff4oQI$XOnyP%TfGi>gZUrI&p>?E{BPXuaXoqFx7SbB zdGY^4#y^yQ=6ZyFPyG}9C-x5{JtqV$jKcl!^wudom2f6@Qfd02jc zdGUHbuov{N>HqG($9`n}GJk*ngZ+1-52(NLf2;Tk{$>4}{4at|!(~mvZVv zbsLJuFJ;P^d(QnafCBvgFf6a{{I2Nk%Kzmm=F;ojlcFNepTtsFN6+I{ukH$f*n}E; z<+jbOi}A=)?Af!Z*B+=I_5Lv2KQgl}4A-u!M%@#ijXCY1Ks468;; zo+CrqYGDUvYoJ^?2>mnm!oTWz>};^vi|5zSjiUV0VC?cf zmeKRkHDFI%DlDMAWwZaNPX;3V2}>JNVbI)hO-6R3s{~tr5>>Cqm3BV z&J6aQVe}cOLmU1i;|NpXl$g3X)oxy>L$s-PDV6$z>7k*`hO;)1K#wT4EZ%#IvZb4g zk@?1_$Pc7NFG;fHEZV<4!(nF+zZzG=om<2DE^B#X;+8*k-RG3wiQ;%6y$5a_SsJU} z6`X#}xyQ``QGyl&KF^EIg%-OJvSWhh-3OQbIb2zRNO_y005l{oFC9!Hu&uE-N_K zzXaE9*Pp;tZGsD?C=ME!E=W^2Sn06J2$)u%_^Ip01~6yY$ZDC^dNj#P3jT52Z_E z#3KhrL>~EyHga2X?J{T9mDH&-+u-cNJwityf~^^Qdw=dLmTCt4c!($}6%CKRXh{xDHrDBSqA$oPacyg=+x3IyFG!vFc`)HhBb5b|GmzHA z-kBH^1IK$ki3MK>Zo zbMRQ0ihi@3H~8*pJXRQ|Px&}p=Sa)iTkV*a31FU|_ysTRC7-#?9qL-#xf(^~y&1iZ_d|*p_u#o(Ovw z3$wwRde-wUZLX4n7WHA(*o?TDfQ@q7tS@Raj<-SVhfaeo3Ii2Yw?UwFO01X%IybhW zwuGM38{;5I8`chBgX7k`NglT>`)MF>f#ssRl&s&1F)=}B>Us7Co~shUgEw9=DQ-gB zcA3bSZ5BCd$33E@Fne>iLoo`H8Q=!!rBNm&dtBM|XCujhUfY{2<%iM=v56E7v|SQ- z57n&#Ij0Hh_JG>(hgO@J4BHDK?(=iW2WZjfARZ3pM2cQ(Mcy6dgQKuS{yxQ`mXV-K zU&mj}?wl0l^iDlfqke1wJ2}@VWH}=nX)h1+yK=-{k?l3{UojeaC2c^{b(0ph0PAKZ z)IipXFop0-f-tEdRH2XYw*iH{2WY$hSTO3^?bizAKWUAYuoaoGn>!T^kWz^bje75zUG0No~OI_9ye|NijSh1v!bmPS`7|_!+*3{fj>jIY?3dRdqnwrD-0w zBY23O1C?`bJzKgf->Se!N(=PmH0<`Dp2K~R@}=%%L2Sd$4J_C^n77EQdCDICRC5i*+VhJf` z%h{gG1OFak%PXzDp!w!xj1DX9XOM1Ba|8lfgcJOC6n+tm1hg$geyB1IZ!6}1EU`rA zjBbjIfl`j=-#ur3p?Mta;WIoCS9q1U2qJ-H<4#b6-~^9*INJ9RfKLz!4z>xViy%iP z!t0ZUDL|%WSxI6>(W(0z#z)4T!rZN>ZM63~dW#h%wU7Bgp2w8@!lU8X+I?ELTjjM? zp5cR*PwvSC-4oVFARBmxQ3h*Ed2q`woVVPXB5#BNE2kLs2gS(2HlZAR2ZYKbNkBe_ zr?A|diu}c0+m@m^vpem$}$(t6~8>%7jCMDb@wrEMLP2g>+0_TjkJ&jzO)n_ z@{}xSe4rG%^FuOOr}q#0RH!hnA77AObUp(T=_qjWkbgNm27;+!_Ox9p{tvW`V(v0& z#I%ozTD5pvfZqG=HUC06%z@x^Qg>Ig<8O?r&e*Wg>4WWG;O4b8*uxzTy60Kg|@i-G73;d@{3n^13zSy!slCirC9EDCKe%c!`DI z1I_)Lm`qN09hIU2xq8eqfBpo-6m?U3Nh_SRF;_eES{ySIwH#ym{`Z_XiS;yJ`I0IUeKFy}~weJZXC*5^GCz4+0>{7T&gUxhd+#1-e z@W7Z6d$=2n!6VbqM&v(Kq4R`)h1Zq8-O2fp`4Z@iy4GkW1+aZjCN=}Jhv27o?;@w1ADNt=RyVjEXtap1gm5llwW_nu^}tg)4!b zBmqzMT&}wd2|MS-qFmgMvLA*vF{M>i@MkYC{3^)OJ>`ZR0m$ZP@-TRF2v62k?78Bh z5ISEes>%wF_3lAhYM9UjfjlmXQ-MyPceKWIdL9cw51nOx{XhHi<7H_w9z*E^_;@*S zRnb=+G!yVdq_OQimcUmZu#L|wNC{zRjDppZhMCsT&)1yz3JbtcQKjGKx<3;UYvL=MP-A(rRWnGqkAO5Qol~H=W^eOsiE3s<${o2}=OlbeS(UbB z^Ga!noZZEHc>7#-F49c+P{h~QSVmrEFjkpcoXXYLw|l<**BX(9HH ze7<;!kn@VhFkdfRXbPHE#yPjSNEj00D_3bw2Nm6G^yw%oU@{Syhfid$r!`lLNXh5cHXKO>$POc7!c_bmdc>`8x(g0%D|5neWWIrEKSvG?h zQR-f-PzulJ#&M?y#WQ6{)2QaS{%rl3bZpr5$rlG5)!&t~elOH!mz&KVgdpm4qq4u+ zEX-`tFL)BGwPE^LPCN7uX85qbaa4KkO75!}J>_qx;rptKd@yweeUQ%~#OgZJdWh`D;p_;r7P3uk+u z0Hl$-9ViaqZ#$l?X8nNoU$~*zL)Ua?RXE%>3pFEmzS9Q*$#8}~f7qq;?kYuhwUsxg zq5RxU@1`2dE};f%S;!_@NnCCNB)dStGw&7j49TNtrg2|?Jh zqT+^$YgC<2H$9&$Pk*10}puV02UFx3|^5`p7oMP^fxTIS#Nx7%@ z@rW#W`9tx)6DjLgGpss0I~&z%T37s#JUgZ3%YXBv>7Kj2P5#^i*^meZ z7eLkXbAga~-=dyyz>;O1V&yWiB2Nc${uF@C7|V_fX)0QZhg9iy)dPgUmF z<%z!i{$HpS`%;$B8GZ+QKJyI!8Vhb)?XOIEVo4s137P7OTfXzNS!c)|A4UCVZv0@H z^x702@jCpPQ)fV-C?+#u3uv$gE9$>aHz>lIgAc-o_xaxe;kF~+hH)YUHsb8LwS7942DN->Et12c8T0M;r0N`wE6K z*;sZS_4tQ%n#vqHac!hI8TvV^ecDMz-)A&nWJfi;T|CPynQY!qoS!em*GA!S7oCot z3&lGDbhR*Ju`OA(U!BKsI{yUC+yY0|p|UdnBPx7S|5{g`R(7vB9`z927w3O|gsjPaq7`rw$q-$R@X!ikjf9p_BKO3OUh(p(F{Y!Cd?0JO1H8HV4j+;V(1nE{rY> z?>PC=fKN&O6}YLUE_8ewT>JDkw$@1IP#EVe(Q*umRltI2CrNKNp?>~O-ki^5ZxbkZ z8YB_?=koS_w0;V8N6yFrdYQzO8#fG3WBLpoKzQZPykR04MbrKw7xKJ7jK2QzZ~(B@ zC>a_D%XWhATsw9o(f`o`9yjpDd;q%itnzaE0h}LF*Qr<>AgfoQ&4DzF(>tsxm*kML zIXt7|I~&jA-sZLB7rOC#<_b-HGV%{h1nmvd4@S`C9m}6lGmz2+meeCj})&G$n; z)t7hHhL+T2TgQLQ>~!1X2+8UZmXNr5{z`|j@O3tr{QfH6lAf2M{Zmrk7)VsCNmhk3B4f@=NR zOfW$`&_nv##aGp~R?a^dLE#`4DQKgC zCu7WCsBtkCWwvbLvAIL&5GOh(!)AFiEx8A14-)<*BlyiJe|U6O|Jc>%$V=E?P^;}j zji4gW4AQ(V1aJE3`{>Oc#j!nHs)cwl>w2?{o5?7M5S6}8lxO!aKd+X z7o}8zC~KvOa}BJu0CreWKbTCB<$X-KGZVK62sp11( z;d{Lt51RPeKFZE^48q?SW?vV;Egm#HW;WpVygmeyYEH35KNM|Fv1{K@=)1$uW_n%g zP}fd4e|4oxJ2q7`$o`|*!@Y_4A4&VxQTHR6cZhbktDLuIK64U!M-g=Frra1_igw%o zxwqL!mG06%jn11c7YCW3o{>ZiBwNfGnyVZ?0>=>RrUyya%6otqxNdkIEQyXQIqbfi zU~1NDjU8@8DH{^j&$E`WhZfF&XBE^mo|2iz57F!>7>d_io{~!IARCqLGE6X#KL7U2pH! zCkOY0KY*9s)^CaX+^E-F3zVtJj7RYrOGD&YX(06v{ao=T;pCZZ=*`zX*8 ziDet0FaMI`u)XEVX7zvG`rX_g=0naC(&wND`CshaZ(e{MF+a6Gw>KZWvi#rVXQ4iO zK0`MSa(xo}5&OS9Kj3)7z*EHC$9^+lC-cAUUH2cz`gQhm{imvr=s)Isth{wS3)mO= zPxKG@|KL9?zL`I}|G)mH(GTc9>3{8irT7i~W#2$8{BaGabed)4>hr3(+c-LB0W;iu zC2Cd(52<6SZES~8!Yhbb;R&|4&^^+ru7Jf^$qD8odfBmSaPd|oBax59x3WV1+zC5y zuYdfp%lxQqk-{N9honszslPyI$+)lvMieUWG;t>kEr6&9XbC^UI0_L0I9mUj?7DM_&KE_1LC@C<9Z2DaksQ6A79_ z4ene#`VF+PBA|Z9E?Bt5_6G=G|=sGC{1CZq)TkN^<@chao)j zH-}PW@9Q)9K}YX7qSQVA5wj)1@Bo2Er31Mrv~^_Hke|!59Wg; zBq0(o1Q7BYD!BZVuIv%g$_dBcC_M`byx${k2t!$LjHQnV75TWneO83|K_=Udc;ihI zm=VrkPg~%mtN;N1`U{L~gouSG`fIhX>i!{CJ7q1Jpm ziXT5l!o!ajxNv1c`Y6*cwUDcb*l+W_!5hPTdG5%>V@7ygrJ)%C*_X5J8soU|@Ys6; zFaFsGRq&88{HQ1Gxpa;)Lc-)jy3w_}Q{9QVZO#Gf;&`4?ccSmftx%&p{bPOo|0 zY#?C_of*fyG|O-`P6oizN8?+k048;Qk^qSJHzQaU)1BwY`%L~D88N4Q1BIXM@G{ss zBeK7a>dfT%4?#$RcAi8g)nMg~oE*|a_icum*!qu>rG^&*p`p`cSDsdn#kQ+8u$c2K zw*f6CvTsr0`N9>Fg|Zsayn9QQq&HkbC(<&llGKOdt|-r)wx|~h?f>5fhO(7t_f{l2 zE=m`{eCdM3?u0?=duQoT@g4*3`Ac2lFE7C{b5B&9Pv~r0BzTcd|5KFGGy7RKu@i?M*w~_ z!yu|bNm0m~>>%L(xK0D<+nEiQ1G=-6fTsGS^2N+4m2-jz%?hM%lMBd*F&_L@AnG4Y zGX{YGuT+EB$tPg-S8sR)r`!{%jJ7mb3yy%XDuRsHfY{Usue_`G^&Ue5(G*s;mpOcb|A;9y zwjenzPl+{_*$hN7m;4&Lxz9|nNujLa`;hVb63Xl?_+O{<*JxRYTeKrb0j|^Q3^L*C zu%$zVz7&gU)=d{>2(w$L1P!UuC5;tiD68*wL=sdQV;q(j5lN6PAPh5UY+rf$D-6zvOq{+<^9d2p`e+1)bbnJ2$q$fyC z0%tLH$B{nlH9V<(Hj##{oxLz9&J}*9a3)Uz0;CV8@`VYWH?%cjWXA&atK;{%X>|GbafmN+~yE zHTzj$=8DIejn@pcw3M+(T#bVii+uNoSwRPIaCLyHRz33k@CN=|JrUx#dd4<7n5dOW zL2h_K+L-8LqA&{Bvwy2SqsCs62;9LCu(sAC2sUYx3Te)ma zR(}TG?PG*tIUnYQVN^79YBQePin%&tkj(H~1(6~?6;RM`2p@avW3czS2Q)lHl)8kEfUCcY*zO`;oe%MO5V*b$ zat6P5GjTKX4*t2yyziYNAN-do)^pSvL(w)54ZJ@v8uTK9cSH6SKBU4cLr zHE&_mNmASY60KyBl{NGKdHPsJFmn~sPR-&h|3r!I+5gZ)H<}yTf5=fLtva@Q%3kZE z$-58EuaF*nL)~Ni?l~QbJGobXb!oJK=h!}_tH~Oz$?r`MBcuI|xcc^qb4ih|w@W%^ zM1dl^!=An*(%tC^#lK)SQt0vhRhlEDl6tL0S0!)vf4a5tp z$QRHnDnwJ4_|0Y5FqJNBR}}d8ovWg4H593+pqHDVfiU6{xBJ`0y9owqB03yv%vyausk(W-AuE;S}CD#TYC0!G3nxT{?6KiozUczY;hibuopD% zj$4W)xtHm^x!1Z0)(r5IIU_WO^sAGotVC(<{oV`|WbKqQd27KDicR^aL0)7BhT;RR zl^lwb4K#=kH5Bm!la*+LyLGACal(Xr63Huiyfe^^Vi5A*$|+&U_qWxv%)zpt*+?yT z*br)r>bM3ltj^`T*-3Ksh7Pe@ga}(ukCgQ_N;-!){fF;)VOye^u(euh7&WR{*bg-p z&B53)z)^Hq@r$4k002Q#%#){cs6MAP(nxsN9-}!3vR2J-lgyNG5giEBdy;w13p3)v zXq5uw-K`1jTYpF&`2{^ytzAe?Ze(r|BG(~CXA2* zu8{%bvnUlu1UH_i){7%4+_7ZI7!`tR#f^x4-8Z%h=g)qIqESUP4NrFV(BC#=s@_Wf zHJEGMPg8J(`?t~f0PT;$YxwMQ z;8cRarCMfBhadtHCEX9Ug_c8d&cbk#>(%!ZNQw82O)ih0AI%2*dRs=N;Rippg19-R zX!AM?z{lk%$ad3&qp_qvD*B&CWr<|uNL6HUpbmU7cOSU-&?CXNCqACRE5KFb_M)1* z@0Y5Fj1kb_kn=HBr1Mk*0?mi~5KMwkcq#oMm$;#~<+^fVk#hI?y0zs>xp}Rws*I;r zWrkE1N{`qo&fK#}pc_-CFy=ZwB!*F@kz9cFuKGTb0ORga05K)*tTZKK#l1f?RD^4E z_&savNWS3Fy&JM>%Y7cR8w|p|3^W!bqo1W%0vSZCuVrKmaKnrK$Q4eP+I27rIjmJs z2K7#1KQhkG7&FwI7*|+7v=`a3E zTkP43@8#LQx2tYlBN7E?Mlutv)4H&6eWma&h?q;JYXWgxMOqrB8$xrXpfV^qg6VSi zH^?WPRBOOA*7%Yn0N>@j;!sce4(udyOzJyD+KX9mt+B=Ex{sjc36E~Zz~h-MA4 z_=l)xhz7>|9{vX4ZUfzZ-{aQ$X(}&$Wq4(S=_xzn3ViOpM}w`KJOCqEaTfvbeyQiq zSD6vMHu3wS8zXDhmH4gdL=5kwSBam7IeV0X!|w0p zzyyBaWreW((=`t@}UWDS@maPu3QxB4atMQvHWC9CWFc^c)3we?%w~}yl5WH zS`FO9x-a3Nw4r<^X+>M*OA|=(>3a#;OB+-G_ATzu6wR*`n~mA|ddqi+OvxA%YRw>U z_?K^JHy?#%GhX}ZhfkO#qP@u~6i!uCn(jHymM4#ZJ<5Of02t;h1|GylRmb$TVmC~N zTP?6FJ|R_~>snCw=T_wYK)^x0g&O+x?J7Asjk-FM5*x{@OP60%J> zo4yK`+Oy4K@DTb1x9?)$ha%Z?eL|^3-WP-!y*B6LlGoE48*3?zGH-iOEiCVlgLHKn zZ=Q9r?oT5_8N4+dYDf(dV~05#+=P-T*Y0n~VQ=^vVSuIuAzo(w0<~yd6NWiKys^Jr z&X`WiiasEa0HLjVllaLFJ<)Cg-B<4AlAB5s4lHuAY@0@&_}H%h>7zPjHC#djr0|wv zPsFHyO3-2Jb?S7&e@k*tM`_U>PJ4Iad*|tha==chB6fXlsUGYhEF0}>To|pXW3XZU zd;n^ygsXZ=+t73-EyKjIeBkgPHRs7PX}v>1C>qQ37846CNGb&6aq@JLZ4Ow zy}el2^sWg3YdG|f=2(AAr_Kd#eDLD?Sem z%HX8|)J=ycBUmD;Eu8zPIV4LjPSPzurGPALzOp;0$#Wz+37GM@2nNOu3 zWJ_1aM{U6B-bXfH77OrLXbgr_s^GYbxVQX(aog~>RNF-Ja)xtZD}BG`aK!qOz+dLs zck;pR&ul=lA;@@iG%DEOnN(>m*b8MT=JlKI!&lnLNq5bt38X4@k9*r28@zOq$EmWb z7T!QmJ$**lNvAH@b(vxm1*(sD&B9x$m@f8HIkBudZ( z;T{W}Dq7O83~;gCw(r!ZOF@>QPhZqRl-M<6CY0EgZTa=7#%Ih!23mg3ZMxpwmJAAD`^WxzkH;xB4~ gCSFz9myfWFP7z+|-~Qq}&!-oqW2{LfIF|Gg*r-|VZ_05>t+Dx+LxTg-J0E+xdc zKvUB1uk>B(GSuUobB-yxGpw@WuNrANB1>PF26}(byXW_~l|SvHG|#*_u^-gZA_OD> ziO;3x1G>+9?+$UzgdqnZ6M@6VhJx{x6hW4m_lFq>AT1cdxnMW9OS3-k=F@X_oO6zG z&7!a|&N;_8<6OdwwrAcP+A9MbzSmkhbx^qbck~C8J^kCo1Xr_M#N2*>W!!QaGELO3}`cJ&~rWor8 z@sd)LdJ@k@S*bU%x;>PZOYY>PK4yTz#k`L3g=IYkXC36wYCr*%_cmz9VC=+UjsA6O zuFa^JkX>BQcsQ%Q=sqh-#*k=3?&=Ucj@hn?5B|e`t$*2&%)ddpO9pI?#HBxeoVabD z8VN4&Y*aEs-|pB4B&?~R)!bUqCcnC?$|yI!kAMD@21hJO%usyuiY%1xzq<_HQVXkLT5kEhVDlbhFSJ8FCf>OhU%szBwq`E;AIlHYY!6Mr z-kz>i^6MRE)Xg5ZC;t6UN48?>MQw#`otod1cWJd_l|eIR$yLgwZmg@QN`Ud1D?asr z;9rmk#&pFBu4C{FO_a&O?ixBOonJL`>8jvZAUdP;8Qsx+^E~a8f1_(>LCy%Jk<6bWMD96tCKLi+du(^fFPr37_$+3>~nx8Sc>zuVw!*I<5C{s@C+&_Xp13vRN^bQ8G1gYS8jK=91UJ8~0NMh2K&to$phU9sGUwokxl2$0 G0001boY#N= literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/queue/images/joke_05.webp b/tools/test-publisher/v2/queue/images/joke_05.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..39ee42cfc2d4223232bc6e63486b7c8a83a9b72487a198fc9ac7c53e8cd7dffa GIT binary patch literal 4014 zcmV;f4^i+^Nk&Gd4*&pHMM6+kP&go(4*&r0LI9lsDvhJZwD7&(NALSp^zvTFb>aE}3^Zwvps~_z=seEt#?f&!F6Z!Y|FZrIR zzxIEn^q2KF?JvMz^H1&QnucaoF@L<6^-V3j{JguYW)#yLu?#DUc?T@{`ErcG|AR|8J~@7pT=^nbuvWC%O4wR9z!?(`W|8(g&5 zqDg*`GUBs38UR{?=rR)&1ak1fKW6svt@!3GrMmw94)BGSZqc&2-BRJ_b4Y}FABqwF zsF@|IW61I(3sp;F`HV8=b#M9Pq*hlzzmI2q)(Gwxr!r&Y&!bds!zHg0L{+qmK&dV# zirNMNn|pq+*<*pW(d=)+d+l&3d6gDsDQCp^umgIN)Y-6&nU2VWGFBw+?!6n7{V@SP zvg6(`6Qw4giTxh8MxBCQbbOuYPVA&&g=t}Pdk`R%Ut3&s{LI$H0oFAiIyYE=mgoys1$=nRS)TY_KII{ zG2nwA3fiA)vQjyi!)$Vj=hZ0bxYi$ZM!WFN?Y&8ZmbDU9XvI>VRb@iIb8q@rwkf_t z!e%vo|HsV%r~N1SPx;iW)Q@2JiuXF%QfM=sez6TB?+D6kHV5hg{TO@Cc1CoB>1Bl03ddRmY4DvWH*&jt|_r>M634cFPIeOATTd-o_ ze1FDV2ZnjE5rK24TB&^?P>q_3_sfpaYV*Hh|BMfJO)vL9@lyJ|T&@lG8eAOwWS*v^ z6}^6yxFR%xowoeg4!Xz@gLWc(QDTKDhPIhw9E=gd#hKcvGipvG#nAy_D?d8?ub@s#^ zoF!94y$*@VG`Ds9ZaMFD+Fuu$B+(ZjzHJ2l7=0tK&+s!<(xqwXnp{RSW9g`Y(Q;}> zM4-Q63v@8{&w`FWU^DdhZ+$iR(MAVBLsVB-3Zg3~qvH?m)xWM$!KwR@e!Bcexh#_z z2qp1GnKT=4K6#&E*4lpkm%sBST^rR4lzM~=uVDQi$+YD?CEW9W`C>8<*0(*yOP__$ zAH*Y&pDNi*miZN5FMEC``2SyUztD9jT@*2Rh3plAV2(eNWR}Vwx#F{QO~1c)WkCrm zHKksHx*=dWLju+B`wCR#%98DUJvC+wQvr(mVx>itd_N9%OT4-jSI@Q+D)g15d#skyXJ+AFhmnUyc-~0x?vVY^d{n%~KtR!^ z))~~Xk}Vh>(?IZn6^2KiBxcC%*WNnA?zcpiDFvY(+KS48E`hXp*bP3Pu%(StN6o)| z&=2ZpRC>CkHk?>^&y*Z(1y`r55-4b$_zyo*)LYPx#?FW>fYS1HASjd1+)@DEgwrkZ7d|GjChmeV!0 zw+Tc(CZ(j|^_OK7e$r6!oNu@Yn}W^@GOyn7(*!BD@>c({UXAF}MSF;gsuN25t320f z1_K5CfncL>$V7>KkbT^`gH?0Y9HSjomeLQXBwy+MPCq(@HxDLa@BW`O&J}QA9 zzZR0c%D!9P!2cREfGE{|-;F%_)B*AX!@b5ht)6KapPjvXSQ~bsTa7$sa8@4+?|S74KddbfpF;G;=0mbsi9X)} z2<;G!PkzX1hg5ZANPEaGUF)ozGxPt?iaNae zaX{AZE!fg6N#ud}!LeslpDqL4gpLZ-dEix9x;X%W|IlvnLuzXfz%tR5!5x{ z7l@$ylyCDU-{D~2yZC6x{`n!X<}JE(rUNBT!2`-*CQ9x77WGf@r!3VGm!W&V0Mz*U zyCA-9Rq@lX$riCM&?@-v%10jQf5_0(m#W9rd_1L16t(W%z255I9-ZJaXVe;{x8AyH zh)HZq*QxVh`SX* zm7j+bobB2`D!g$?**%UHr+2~Q!;*BYsMAEV7k2>BRTSi>roeB6bw> zj9oA2_+6IeJjS9TGig&Hr+t?0PS8N>thIaa7J<^{{1fu{;K~j;DM*FBn-jQ(>vH#$ z0E;4N()wf*0*XPfaO{PZ-=QtIC2$I?Y^BXa=wr_pdXm19?s)Uycfs>P~3 zD=S!@xeBnRdxgREN1U+*{moU$XP+!~8{ybuq6={k-M znyRumq8dYlGelcpq5bjf-xv9oFGB~SSrEy&5GG2j5FN&b1t-^>G^~BbkhhKabNtEe#uQka|7T=}UIZ;0@)r5b{m~CG??UM$eSu7X|K$o= z$F5BJO-G-3h_HJS4$sYjK5|Xa&`$J=xT$uWLq71m0V(}Q1CET+AaAdrxr;;$%+YA! zKh*>fZ}?NNzq7e~D#b`9XS-hK9S(q;6?fXSd>d@^C79x2ZCI!hvaX88u2=MaCb0{a zShzPRH;0)r>jKvg(B%LB4IQs{0zNxR-p=c-Tvy8$gi{a3FZ|^)h}8VZwmHA7RCN=? z^-&g2THhFcmB#yD>TU=MXTR1sSD@o(vX3(0HB{`*%J@@o&nL+`FQR@<;SDyri_=xa z^S95>=Yu7X;*GELCyUsQq!l1|D-}Yn!v9xh;#UK3H9wRY*9B=x&OnOHV=%g4wgvgE zlR!iZpVLB}F(mc+a0|fKD|o#`hAkP)`9#p0hcGcN z*fANm_0{hag$it%xT&QDFV8QDIBiEr5Hx~5D@ z<=5W`u5ym*L`O@h#c;RxrZ+x<%VhMafZ3Yc|IqRk!-VF za8cg25ImDPRTyvqoP!uvs?#G~7UFX%5@Kx>6%oRre`4f$76oA{Q+m$PJ^7xHXuYms&0&uC961CB U)vx>aqQy0>!R7tO`5XWM0O{xnVgLXD literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/queue/images/joke_06.webp b/tools/test-publisher/v2/queue/images/joke_06.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..9a60ecd9b67d7dfe66f16a290ef39ddbf9b59e927deaa2c00591363451c464ac GIT binary patch literal 2814 zcmVPG- ziDdxLO%H8q81HC$K$eD(dHlDBKZ*TU`rbR8Gv;sBhvE6R{>#K4hWdef2hExbysi2F zK#y!b&);jSjqAQOUKgH+><9eQ`_KKKRecZrP3$53-}~`gKrqR5-f7UIRi$gCk~_!SVeWbRZFs6`paRn}pk1b?NN4|m>n60u3*hx)tM;9K_>O1yvZl&R+}CN#KakD=}(7v8@=P%1^l)+L?= zDF2C@o#}^2vUQeD?|wb0rX)6P%3-|1=9Y`9nWZs({$3>N7v7n65!$b#DjQFHuQL-~Og)EY4h0POjo7YNGwWi*^_9gfza{AOQaTcnamLH>FFr#vb|oi=l)uw(;K_`4{M_g0`ayx=*08GzMi zum66R7E5*q5Z3YiMdp6;v;3=PcMmy(&d+m_-UiGUWU<*}j4@v~d-@>}l0O=}(ze2q zV`oR7_0F9m`!+SNt7KkDt1_7-ZS(yyl&mhdH zzCeyp1ScoaJ~-N~j*0K)?>eFH2=bA$e7!#%n{n1RV8Vf@|XNzvgMQPQRO# z?F+$QPtW%GznaLz?(

;j_;fFjjri@5-;ZQl{T;;C?TK8&^M5tl$~H*ObUko7;|7 zk39x7i#5D|E#(1p-xJiORS6_Q@+W)hvH9N+>B%$>s`!uA_1okrxP~#eHsRcF%ujF5 z{G7*lv`#H8*aPrxdRSEgjO~QmO^i?N{v`yRgFTwnh#Vy*$bDgx>MkkvX6Qc{dj}LV z@&K#GI5D)=5T?T3GM3F7WhfQNTzlJFfcGl$^rQOHU)I>j50T-5MmmqO)X`W@hX|5`d{Vz6 zStw6_LGekna$n=AwspIDfvi#7C94D8CdJhav}Ri>W~{_D_{aL+zDAel z5#w;bDPnL#8s1{e8Aj_@I>xYPdpNp@=#MI2YK%_<1O??(873c#J4@WEob%vf91ii5fvbUtwWOP8M*M8<^CN8D{=@_5Kh*_Wg;5uo@6C{o)rP< zU;QE`yi|%|vqsl7BJ+$RL`QL-y7kmQx}DgeK0H#`oV>jx2}*;K@P3HnJXl?%a^A2! z{-3?Vnx3Z8`}AVJGMf$IZ8m4vfz&GS)fS^)p3EBu@(zQnQ8YS=3Op6Db}Kj>6@sd8%6Uwyhm~W8WImDx@dnCODNCsz80L*xtQG$g%9biih2!y z-ccH&<@dbpwzcy512Z#h{9%-dRqDalRW)q#2PVh(G>yPOs@*EYhbC%V2aDhwH`{rI zFEfsHyj|Lk&cHxI3^zLT2ZHRKa4;R|i+2^SP;3=laQ`<#T2pIgEZTYZPg4K(EGB`4 zi?0b6Xi?5bbD)ZE=gl(H#)C>@Vdgh|Qda~$8^2m>l`ZTWO-3*4JU&)3ZFC~2*oP2Z z0+zv0@Rw@L)*EYh?_)jF^8CrS1Ta+Mo* zdB(6X*vS(~_|wcn{8~G|w=k|aZ9G`rK{Dn-|CVGxNe2t{uY%An@+~P&D(OCMev%mN zth|?vl#{q09uK}ZMi0CJ9ux`qF^6GOmfB97;BHj&e?)?O<^M-vyxa>{Xfl6-tuZ5< zby^ChK?KTp`WBH+Odo8jpAzSk65z!Q(f@w7ue{&krp zak!s1M67KTa^V}rENyk%?Vz%1^`-*-0S?Op43YNtHq~rR9g=_GyF-dCwdrGNKA1m% zPq)bp1xhxe%y^Bl4&vsIUgvnY_{})~Iu)!u>p*$Rq0l5OITP=YyF4SD`2}RZmQ$9a z9GpSJwmRK2W>05<>N$tUK7V*w7h9`P6L8810mgq2mbNSO;s|cN@ z>L|5>Fn)Jqb2!*#0fyb-RxjxfCH>nL5TY%0gr_@9*9YV*sA%aJ(Io$8P<-8`>-$6s7>l}ii6J&$n{YG29d~Q}1S4|tkv zs90qKW|KiyNGa;H+KaX?O<8S7ud^ZliFRpg5ewQVFjgv_p-36)*8!a5c_!2zz5xH> z#`-+oe&3%?(rz}DfOh8qb}e*g%V2{GYbFHGd7G@O&c336^JAbVk_ee-^&#)hBw?sg zWVuXeVUL+Jvsi$^Tvo73au6wB*RUd`xAfn=(3&xlDDZTsF&)Vp_?1UF9Ux$%$Rrn_ z7?93z-fiT0)r3<_*oYrP4$0{qsl=qvdX~53pHyAYX#xlbdCD?!!946XMdo=YW5O;X zk`X2}Rd-Qz=z-?kvY)+2rq=I80dmY4(ra$y=!Fv!8tsC@UfKPDGEMBCxpgbic3*UW zAmj5h)0po7NC8J_6Xhc{GoIo78SN=k7tnRVH~elKk=#ajaR2}S literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/queue/images/joke_07.webp b/tools/test-publisher/v2/queue/images/joke_07.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..32b4a27bdf9ea6f7fecfbf13e5f0100ff18056e1564e3992dc06f0964fff7e35 GIT binary patch literal 3166 zcmV-k459NzcszF-{!e9eB>{>FR1dQ1M@>M#8#{LfrBp!b(2 zv}f*zrRC)|Iy!+FBar)H9h`uELf_BYf7x--P7Wzg8aW5_jnebi9>NFps#DN~xwKyg zXpR@%SK8{n)DdwCfAPmBw|5IPBOKSk3h|s?Zt+q#j0^0AGVhvLm(xm|MsmwLHl1{n z7yk(yX4brz*2fOS_k@Zv|*aQwz>#Ui~ zPsfI)C~$aUq8o5oA9itasDu(9OAl3EMdb!KmW9q>9bh_zgVM01jhKUy^eI7ZJNub@ z_>JwhUT{zuE(6o|2|<>cqOwI|wb&t$d(pn_Bk`QY0fm0?UG5yuBg|jDAu`Mp(Ym;5 z3}d@^RpMy8;!7!i3J&F|EWeK@c@+qcZBg9NH_veHcye%*z7-N1`6o{yAOQaQk>gk;Lx2DP000000)3cu zf-n&tP_YJ26cu5a&JB)hRLQj_8h%lC^7vLKr8kX6mfrn178CtWSE7O3x-toSU=-_9 z*aySkWJN}H2QZ=IK!b2MGtoW_qAOVPkYE(aqdGN(B)=en%_EHiQVFyQQ=U+VS&>DPAn#?&K zFFq`_EW%I)-?d@~xD*Y``C?HQfonCX7R9SFP{N7(S1MJ?^S%k|ezyu4pSL?E&aoQfSe7JHz) z7{e>DQE(IW`$1FR8{ix>e=;q=lBfY*XHHo{pWg0aUFJD;(7HNM17y?mrxOn1%o4}U z@@2Rmi9?N8;+SOaOP9)Dt%%y^1}2giZCsp((u3IZ_1xc0z>q0iZPeF=3s;ol!5P*% z7k%Im!NLyRwyO>==APEl8o6q80^X0faGJ!O?mBk8_HL-yP(Aoi+`7$fguR*geey`h zsHb&e0~6e<(oDv=CD0+5j5i^4qlSpQisw+mS@W3{6kik0z@>5A4scv21pMo)lV8wQ z>`OX$Lw$Tm=z3?`U7$!6CNQK~u+nt;Q9K z!4|Fp%?;Q-f6mm?JERbz&|N>t&jdu4X~L!Rk7>4RI~`emeop$Wv*A%HG;47~c+!ue zB|w6)Tr0~ZQ;P53rF4h(ZPfTtaxx5@j~xQ#a}-eg&{qQ3gU(fhYrwfZfWGOZ72v3v z?AU4vGaLgXJP?J;D)x{E*GU}B=#j~6q4!7f(yJdxx!AcmGrQG&!SlpBk8*4Q5jcK+ z0=h&68>jEf2^8l#8bw)!kL?%(06=2%a`V)iSR>*B4*rzrKuX)m)}Uee`)yuxQ8`2+ z*{VIiLRyvuH&bYL1fF{H7nGfR#TNTjkJVTF-UT9B8ksnMBzpyXUPS;Uk|zSJA63Wg z*N!=|auhsB6vcq(XFqZjNIj6u#6#{)ppye7v>G(|HTuN$#x(0f8YME6T)OckUmBmA z_ixbp{w=W3*Z)jgzO+AH={^!>S5nCf4+FbGUk=IAq6Se38S?R?+N%t6;RQY~Q+nba z;rr@Ajq(hA$^b}|8Ja$5N(h2n;FmoAJ)s92KBfJqw5ZGAE=G0nZDA4XDtfT~OX2vj z3OIsXceK8+TXXkEl-w9P)abc4j5a`X^bj9yhp5CgTYkjb4EMMPDH?T3dFcUyQ46Cg zz3-!_{oRh|#~TO7x#j>h_JbZ7G**s{?hz=w6WcB`j-_L%lxqVV086I+DGVtz>CV{% z;2W-g?g^1O+r!*=6E=!p-Eul z_42aUvm@D_j|;J&B)b(rr|RLP55brHEmAHML0W4xGNcbonjIBR5`k^!*T=gx(3u0C zwRwQgb=jX+pqQ{*5b&XX){=8!gf5b2n|v7s5(M3qxB}tHZZl#9Fe1q(>5*SYB@0BCWK{*=D4J>lGPIJBh1HoQ&jDR`sg;37RNe!Pm7u%08uaWj_iA!L z?#=Tt_gxb;9*D9TMOGNJ6rD>~)vW4JqDs@3{weu^E9X_==#iNBT=W2cb;=zACwOAv zQlBW%NGJLzS_^WjAs5-(ayb@vK|z6vmbO97tg!7*jWcDDuF&FK_FhRM0>erg86XeM z*VHw%S-1hUmx{-TY9`eT3-HKFd^toMF_JX8-XSC9EAVWwUfw(YR^rs9wbQ_H0)d8z z`TX(oqP7DlYhVh$=O9{9Gh$l6BHMPzV|x8;z)H({ofuY_iJQ~K2ytjx$xW90l4M-*&adX^2^rcrl|h#YsWNIYy%wO zN_)YYG+H?e@(a>;$z-Ss!H7Dtc!fjpjeFE1@GJI3r-@SBY60y=_)R$5%@~UMDWwZ3 zERKedYRjFp*b*witn%Xl;?C1DmU@zHsfaU%q1!vLKNV-63f{)dH3FZ0RJ}9UNSyJ` z!Ni&e-kTYqP*bFyHNMfvjzkYbwkKNNM69lFx`?KYi>MLRBbI0N0^dndyQ6lk?3Lr)T$;n`>{E{HGju~@gXlLEBJSG>0I4=ZID2He~wZ6Dw z&Bz^oE8ymc>(+!#VhP^RrkHXAp>FD`=)3Q}h(5-qk);=u_FcTj@!ZoGSf z)2~l#xzJ)I*m%1bCp47dIR>eZ%7dwFs51Z-F;i56ex2D_@D?;Pm+qA~|1WrTc|rY8 ziJ2b$R>ejh52H$o-4f3;^6O^s*&{xLDJS6g)Y>G&(BMhc>!4`PKB3~xp)$DRzV^9j z!N)wnm*-1iEr-F5#hPbqq?r--=g-3=;ulj-P`u06mj0nWce17l{IPfl3Pv{b!=oC& E05;$sVgLXD literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/queue/images/joke_08.webp b/tools/test-publisher/v2/queue/images/joke_08.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..9a8ef30892e713705af7052954d8fe345d05bd63cdfb3397a94040cc20399a26 GIT binary patch literal 3540 zcmV;_4J-0eNk&G@4FCXFMM6+kP&gpK4FCX;J^-BoDv-{0$K;bWIX|^{@BH`VKgGPHZ<6fZVxHuE zo4=#{ze+yL`LTb;^1ShY^Y8W^;eXA4vVYC>QPFqkAL@OBznlMfH(-$-*;Rppgga7T z1}Zr_sE#PQ*bsTncK8~3@jPKR;i31T=ZFcU-3}d`WrEwdFAK&Zy7mJ)QtTKWXy)xT zN6cqHGySK^CPQS%r~^|fEmJIWluB;3P2fG6i@V8m5olT+fy-Ix**&9VPsa|bG)d_^ z+q?AO%DaA=iTnaoDRW#T^}W4hbOM=Itu-~6Hq07e=Lhr&_@hvu$g5pWlq;RAZN0TB zw4Z>Qs89JlQI7Ty_|+d- zkHp~D{%^eYZXrJQe<<;W^Hv%if4CQHROgE}w2My$U+EusWE zkC(X& zup9(KV>NWbI501s+%X)Hn)OG?7d#thzjy;cTapQn=*QR(pvsy(yDO(0&G)B!?ZgvpqRA1Nbz@KCm z6RrBtt5~&T((QDIPY3B4&+EFdY*xnv1Q0&O!XnX*$yE7V^y|$tqmow8xtuet&){%$ zteBtomH{%21EM+bbQ;QKMM1iApl_oQd?nGY@>#LJLpYatix81@sZryK$S*T&nFez* zp8?d>vG;+tWOs-Qw)2uKJ_bPN$s*g1g>br|gn~^g560TmqtrGA6Soo97H0}IwS`fJ zbl^4|Wizob*PXZ{+Lr^b7Tt?VRifUBUx1aKeYJvRTNwWDnuVl%+MfjQU5fK8P(a)G zfbZwA%DwIfytY3=Y~O5-4p@f*&?d#6Zgdxq&Ta!5Q^ow?%C`b_Lu<63rJD~zL-GXm zo`>FagEIMfL%CG~xFf@DVhdjlAz6*V(0tngQKEwn%g zYo<$6fP`_OXQ*g)@Le8`^ork&ef`_s=9XZvOvugTmiqY+7!_G^d)&FH_>|<`E7B^V zww#Dy8|iBf%a(Ds?^<&mO^QW&((>-|piLZxB^`>&&gQ4%u}2_Ak5FubY*i(8l!*KJ!$r;BOv z-yl6a&*tNP?-9oY>!G-eG0SS{Ae_K>8k8vCKm-XZ<# z&Y(vvUs~WG=udO}R|^PZ5gkuQdePgnc7Uw*g`b1W@d}b%N?}9d)=y=fs{%mv@iKK~ zHgX4mENr9N65@!h+N+^cj3LZ<@zbrn7TE|CjNtX_%ISqNyYTDDNqn*+rCp>4 zA^W4T{ILJ-G%p*~?RQuvdPiql8__-{Xf{xGxR|m=aT+>s5yEVXvy%jgH)9(sZ$Lc< zLvornRvtJwI@e6TF9YbF!YfgWw_qi~goIVuINta7X)48? zFRr|6Q=nB)p8vy}c5utkoi`+=beIb|vl2KBmSxcY<8vatA_M5T;DeFw3a*Vz85WVW zwy4@PQhOI&Uf1SS8Z2I+IHSn+>m1W{#~$!F%q)mB)$eEP+=&{hAY>#X@yltGi<;CV z8eD6Vf5FUQkuvg?LIq@ujfEtF0+e&{^VbZR%tMK;fUU zS}~S{2fSId=I$01^?RQJ^jE^$eu1Gn-Xsh_Qq1eugt||3c26dR)h&WnsjwS2kt6zF zwkGh**Hh|MAj|Pc5&$HzY)*c^J&?KF7=9~VuD8#3!Ju^49r<=A70{1Bl&d1Kbx zp|APZ(@^)n4szYO22BJQ7Eh%CWKq#>`c=fNdQMRM4*TvFHB*Bp=1RWH57vicjbJ{c z=D4&smjo)DZzlW&)i^K@Wfy-QYEeQ64xQ)g6fFgjYp~tV#nP*6zoB$(= z>2{7iy_t~~{1hiFO7eAmIW_bXf+8CuDO{6kvUZ>)%-cj6p!t&=YE#G|KVYQBlsgtN zh7>|oxhH2^uQs7}I!22V{0NX|REgch4^Dj8v67g=eb>KKuW|}8900jg)fZ5U3aubs zEI4`bm9yKK|HL7D^_sT=9h!r65L9RV`ZugboJ7zD%<_`L?YmsM1>6ZPU}?d#l6)bn zmJt%&d3ac3Mndh`wx72qrIfo?Hr{`p)3k(bG-9_3$!sfeYA54a3V0+~#Il)!KH-@I zxZgTUrn!|shbT1KlVorzQgnG^w6{Hx?%zLXn6v;z{Vo-nnW0{ZJQ^Xw0xOoiGmJ44 zYw0u*AQMku)Zt#VBmc@3%WVnFnr-m%cbFRzSfx_CtA*v2Z{e$RwTE*~Y-pvK-${u7 zak>sn1;RqcM^hud0)|vxcOQrd+9AcXTvqzLJSruyZRhhE@48eduF>JRjkj_l*$0zK z8;s6MewwEgPmW-H7yXf#d5~O&l~OGA_5YXlX-Cv9F5HfOnHZZ;)*8E%(-$_Ys48MK zuDFbcY;`vondwlTuNQ`~6KeWMMVD~R z=$IIPq+p1)@4U6mvI{=+*d^Snk%Sk3pGaU`oF(UJXTs!a)Nfy3N!h)~!mI zBZtpBK2w7yQ!TkZR%%iQfFXW4U7r9jLZ@`07y!J8CdK^duGYyH)u_k^vEjw%Ug=wbev#N5tiu@F#>NbR=3Kc|Slq9svO7(OrN8nvlg OZt-N&urxy>rvLyp_w?rg literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/queue/images/joke_09.webp b/tools/test-publisher/v2/queue/images/joke_09.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..d0e667a40a32e6fce020f3469b1b5c29d26d6df2bbee4c1ddbd5011025596430 GIT binary patch literal 4204 zcmV-y5R>mxNk&Fw5C8yIMM6+kP&go15C8!1KmeTqDvSW206v{Unn ziD&@J*tx1NI-3vpo+6`#ZEyJ=sJy^A-+7AtsP&ihrTx>?1N(ncFQG4&XS0|05Bjy{ zyhZYt@XYgT=)kY}erWgW@PGV|P%mL`73=2~r~fC)x58)8 zy@3Cle`)`V?7#irkuRtJX+8qKod0m&l|rQU5ets>cvv@%i`SYW0vx$+6K+?1ZnhtQ z4|{mw_N7k-WBq=qF`*=1P^xc9OLrT(s{it(s<>ytjRkV(-ZXRVdrvn8VXRxhplhdz zY$0T;So&oIbh$XHp2uR190+N*bv<}BU-tw4p#-ja`={yqm@yYC$B1}^hl{tR7hajX zwea0tN`;xR(U5{@dffmA>=K1cxMX^+jtuhhQhXj}{6#Bxeoq%;2I|2v!$e#!Yn?f& zngadYWSeDRPhj+%cXx!WF$bmS#<{1--cJS1vxV2%l`3Um-}9g>3#o7Hy?!u6Oc^-4 z>?VZ;_)qKS{|5fGg9?-z;5JW7!T%y8!-G*As&^p2FjLxmK=;sYK15iL%dEUU;iePu z$KlgmcLC7bGq@GGCs%cX)~cwSesD|%O+{flZ3-u21{kHYp$$3pW6IwpR_h#|5x(lw z{4eUZ^p@VTOB`K#slz!U%UC%1f`}=ixmIA5_RLMxVrT#W{{8vG%D}iqZR0K!TUxiB zZ(hRV3VA89xaW@cci)mSS7`ISj!Dmpx=hi%Bwm;KfU~Qx7Cgz;dv2+MsGU+{nkpHh zn*AC+ht2OAv_lFjDZ%5PS|*;q2b!IcJvX-oT@wBZA=gPCn`)g@g?fEUp9_wESp-^p zKI%#Zq1^wE`V_CRN)wTlmwNHrBdb;p=|&Jr-tjdO@hf@!PT}D6LqDbsey&>jPeWlE zHTYF81r+yQ{0?N^L*}}welvTeUTgLG`g(Hp@<5Jx#0B3N-h**P3^V-F6(hI@!53k5 z0RcZ@X~8%4fmE2tu;;uhuPT?MBLL?jFi1qzoeVX^BvdxmVtu2;6#&kplYdYY%ALO^ zmRQotR*(es%=C#GAm|Xs;XnzQprNY`f7Y(u1x`Y z;V`LEau4^zui#eCZ0M$;K93EV{se)rc6i_hKWbb4$~&fd;x3 zYIcj-rG7INZJ)ex2lIexAigV2xl1s*6vZMbKnG3ROEJtO>k9;IT{cTlAZvE2rh5EBDtM0cMCP!#c}*Fo91NF>;AH$b^@ z^o2aI2Z#VbGyOUVt?Ds2`vC1F$O>w13~7Hp>YKJn;#($Zaf!S~9`lJ}-qgG|*8Q7( zGZ{g`R1)c~ck|-3_ANerV((j|4#|)8cQp&RB`}08(i^;+1zVNpN{(b@Qb*e%*=xl_ zqy5*c#kYUvT>Xp|L!b*%l1fD;IIcO|aoH>Z*vcXj9Ix4%&-7WrDBAq53qMOvkVw3D z^4Qqa?AangYn(4iFL@u3@y2|kD{>w+U`DaM4IeO*;wgh$k6)OtnEZ25ZxE?TdSfR;be$}qL0K=A*E4x_d&zabgslk@+ApX-#8_X zgT&B=$$Pnl8y7w_==Yz8WXlsey^u-4o11wB0zl3~0P#Pm#KurI(>|HTiP>mjeH|B| zB){6$6;V1FWeNEQAFO(?PB5xF9VuIZWmaj{x-BiOyEYQ(eONRvDKOmArMg_)x3kO!aT+fbFmP#RJQX;$m4G!UMM_^wS{(^d%PW*U~u zKc$WXFWIC_hIloQkkNx9Uu0jO`Zs@AZ{chQGqP%|@(|C|6ubg`FGm6kvSFsuFT7!v zjXg1cg?xHRNm%gb5ZcwcD0mv3(26F=+jwM8V%st>0<*8jn2R| z`V#mZXeRGB952$}F@IZ3p51jv$BXbPLcOal<_qN48;F&tf^+q5$`+jxyw&b95vrsW zLd#~Q3WRp% zRE+s^;%buFfcHfK-6Q|BGC@6aIW!(R^qof<3C(0M{IxQdRl@k4R9>^Cq zB&=*?h)0kkSD~;vTNIZiR{M;Q*{aHgfC=18tT(ERuWYIBfCL$cdpcdqd!|Y5>Wd5u zTd#K#>|<(r235MNB~)B3{xKba>jbdo-}J|hb)^^Ly0W~rqHu=$YYZ)f$T?iVjD74M zl>-I){SCstlq$|pNu~5Mc`8J8B8f*V)yIv8=nQ*Q3O@P#S7HJwI&<; zAW4SPsm(FN5L#N@>io&V1+XjunN5Zo2zpi9!Nd@yb9hzl;Pl#IsqJh7ra}p#lyq0N ziU}nX>ZgiRnsi+1MLenp60YU-HKBS6tf$*GY z!W%U2YYyt>wYE7&+BeAO_o4AVw2^nW5#N)Wdznf6Cd3ED%GZw0v4{RAm;3^#XGvAA zv>ndPUTuj7qqzwW>4`uHxX>!((3bhIImA$`T4*NWX;)0!JErok z?hK)ydZfyK>uyAOU9q2?7gMFz6%aCUE|`LYaIBjOcma#<3Y|l2#y*dnD4i)_!RmiA z0i~T^lJkXm&W|`q{C1&;?>rb-D2hJh_YHkW=cP9as(Zq;Ye!|O$(={kG!tY| zqJ)z9sbVsxP_(Nx0-VX^4|!1h@xnJGx))27ZPSyld(GRyy1!R6RH)@1U2KGU--ic0 z^nxyG(A%X-XCtv}C1T^fNU1Uz;dt9bY|krXNzY}?E%lh*EA2F-5E6V5eZ>bTSSDVv zcC@r0cok=3N=&JQh#u-3bzrj2XCbw+bD^0$sg3A8hF+~#3dn+l|HmRTHDWDD!)_9T zV=?3n&Gu4$U`fhALTt%Q<&*1)?WU`flXU9!Q+{?56KW`huKcXpyZR|Mluj8}a5yf5 zJFUX^8GXzZo-r%FAe3)GB1A)w&O%DR2f;wVHfY8(0TEc3;k)qd8TwvvVvX#1JGz#?c_s+lw z%wwP{_alBrDD!Q`VTg6Xu4(tIiewa+*To?6$zfcLhyz*SEWJVxawm>xr~986z@k+9 z{f#Rruj^RA3gGe-@Iq*rT{s*M`yA4HnJr;g>Ezn92T)l|uf|^-Of%1q)oRTFb+8_$ zdcd|O@d}Zq$9+ZsJV%rT_MEimE@R%v$5#Kc?OCtzzi$31{Jh;?_&{>e$dQn5Q%DNS zUw`NS<0!<I^p?(M?(KRd=$MyR62evqiu6-8N>z>)J zj^xekX-h;Gt8yhpDKyv(zW?ThUso@C+v{GczeM+OrP+A#flwNn*cJNx8jpzQ!NsI2 z6|5H4O=QZyq>nl0wb=m+R4>X)0b65SF2SfXSr4S4*yp=$biScw!wZf8E$>u${Ma~j z!c1*-KX<8R{3R95y6q{T$LV_wWm=s@+&y)J8g6ZV`jC1KpL-W#bQ44vus?nN%|8Fp zK6Ar^Ru4nn+mc_}X|%;u`d=vhzwOoz+$#B3t`m17SPON7n7n8zA%}UjAQ;4f?SJ6A zymkkb8A|5F`kCtk^94muWWUIVxw}kS`r#kP33}zb4I*PJztz*l&rB{QBn7@|^Ft#; zz3gUNh^cLEnS24*158(fgRitmn$|bW=X8I;et*e6+ zNH62c5|CJIFHF+LH0sg!7%M{SDw^LSTgp3|ksy7iOmTdSJVSHJQJ{+J6L67^H3a}7 zQz>P~AJ~LxS3KD^k2}-4I`*{32@8!w<)q%GDAN1e$4t^H3*CO`?a-feoHRWk%|hDW z;Po8B!*~2s7*6-<-$39{;iMf4LBd2?SckGl*+Pzx1@YnFRs9vy#Z0Mb^ZXTk)LCwB z%Uix$z;Um_1L7G?URD{K=Q43hGHAoVQ{bQBDhU9t@Fv`0=RHt!Eo2Prbmb@JNgk!H zMx4IiKXpQM8z*tB1;2H)tpj1FIA1XytJ_01BuT+42k0&_WtW%8U73tH^M=@5YxzM4 z0v4vkguW@J9&MKn=`D7+tQp~<*)p`>Gk4-`?^J_uU&JDj663cki!qC+tVW`M@&EwL C#xr05 literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/queue/images/joke_10.webp b/tools/test-publisher/v2/queue/images/joke_10.webp new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..6273c934a2b71b97abb485ce5003de5136b7ddde6c203a8873dd3b327603b106 GIT binary patch literal 3698 zcmV-&4vq0rNk&F$4gdgGMM6+kP&go74gdhKKLDKpDu)1|06v{UoJl34qM|AFy(q8~ zi9>(^$N-JX|0m8*FroUuPu3rA(*EF%c+ES>R{sb8W#OOWzhD0cki<*ff8>s#9>4Q? zjEd<#tKZSA_kaA<{WSl5C1Qat{;+)E2DSf=Nn^pL_4}gOEz_yXE9*f2&{QoPlC@bQ*z9}_sdYJ3o zM~^`swdg`T%M*<2qMIC($T;ig?&&Gy`Ls20XeNQVG5kNf`dX8

<@`(00@yMNbVY zGBD*|M!ZeadBvo3d@c`rp|2)0nA$>WK!X_i4e~AVWgK@sY7<=%3Q)&K*0(joi?{U; zIi>qBkV5U6uPwv(Kx#+MkiXVbZafutmF04*_MXu4M(5k?pdUcK4={X8rO7PX^&vCD zjt`<1@ES?fN1%=;LDrV*{64zzJn9hdvMU0K$CWIS+hY`-1BU5h?uI_4a|nH8HEuVC z{dV7$Rj`ja?$W=vv2{(8Bmbx6>yf8dg?3Y%%mQ-zSkaY!7pPzPKdB=?J_A}S@H-@# zYPvx6`x0H5tm(JwRDiK^P}vc*N#KZ3Gywkn#}J12?^8(_`3!5_Pe@6vw;hfXu(RM4 z&Y|`K;Uh3Iao+@SraO1XO8|fXO=#mT-cY^xX`TJe)V4X{S0sOE z{Z6Sz=pfbH@IAwb4q`=<8=9I&bt^T8caZHcNe};FoVAa%AM6cn^JG+ViCyOz=z?_L zp45frLZzaMRsAVQ0L^ z0R{&*x|SexyQ**|Dk#tjTrHFeQ)JUA+3Zm3=ljjN^4DLR#~p zT`K8(y=BP{nX*W{s6u9}(xi!{PV2!sBYC1b<4ql7M^7hcPMtNIoz;V%QIhOZVL_CH zyP_3PfyURr05-yfYGCr!@eySH{MY$?oo|Nr+8*SLvtd129Z_O-75mu5VpGQ9dw2-a zou|VCLs84FhtC>-h?nl^h8NR{&Z^eioae7~QY;IDi4%C4rIZD8vXROl zISQ*2q*>Av>G*8+Fm}C9O5)@9^ELUBKq#ZG`!%E{h13zdO_Jl$-xn7>AD$hMkgp9^rgAJ+CDpRcvQ z=YV@0ArXnzh>>9ajv;cg6JjLR4_9aO>p8mV!kyM^n3E3nRJ01%j~Mm+?);}>G}cJ& zX)kQ%t{Gf>TsR9lld-y$Ra<1u`>bS0?ug3_RGHm;FdAGPaXwL|*=MDUs6fV@U<8Po zSyO5Q%lHGbD|iDZ;(vLWjENMkfq5@#_(85ppPCQlRCZ(5PkoGsx;6Q@qSm%-d1pVo zj|hj)oxc?s3_y8yZwYcD!5bWB`N87RSjZDNuRl(^#Gear`lSDDO|Fx+K_VkHz2{6A zeH|&9hg|gar~?YAzjR*&m$hr9R|J|HKsJ&B4F#)Kcn;_aj~^UxaMLE25qCpGzgj?V z+{k9qRYk3u1ZYaJ& z>C8ME!HFtuDw-1b$ZLU?S8I-d?6)3%u5#%TyMCa&*j``L!UH2?Z9`|uP?Kv#Rhki~ z;sZ!qx30^%u;%Hc%w&pAiE24LM||cj+Mb^MCRE(|n9DDAveg90j2Bb#M8=%O!BUJ-IlzBwLVC%mE8?*E1e2{;_ii3;jqP$jDvch(ltB?*)@JM` z_a>mgt8RQl(BKI*s;za@wGtMo&Lh|D+VplX!bHh{@g^>baI2S2R|awiC=h#6R^_cp zL>bvs0?;5BRrPfWaWNdO)PQwH29^no+bx5Nwh=J>n`t@iSCcBU2p7A?@8O8{%QGfo zc>`hhZf=vyz=r{D1{1E$TQ!FoYjG=boKp{wIR4Mh+?X}&hbQ#eG(q9}&0^dfDDwYR zb%+?vMHd|Ndewlems!KDV>>_lNMYfW6%zm>Ke#4YNExUI3F~7f7Zlkc=&#TxjA;;W zni?L%*`=tNvneK=yLfPQjyD)YksNhX4sMvO6a{rxRO`SDkN#=9h;x4XUp76vlzvVi z4@mxR(yp1As{Fff3+a;7zqu+>niYaH!m5Fx%(UZRMZwbjlka!p-b_Y|A11a2`BT-P zh-j=DyId_lLAaoD!okcy-(w+n(~j5+kAlliPeuumW-RosBvFMP=pSvKJbqQ|B z$3>1lPTWcGX4Fgx)YI?h{PmhwqM`Mvj!?}4eJlvXJoX6ub{(PVjRqW7ZC z`c|{aTEaY|1riY>JE^YbfkU%GRzNdgPLDsP2^nW^pL%&@i}H4O$oJ=+&yeNlo--G` z+!O#ViB7Zq;Wt8VXE^^zzEjt8kEAZzS6K0B!p_XiCWqEA=!6~4mXlxh2FPuD}}_blfkKXOz|F6 zcKK`n3`W0^_eQ#EwosM0aXAdA=1!K$0mogIIL~~jCf_RkaIH5wyi>eltCL&oq7u_e z$IRibqe0KU573=u7U8$m?fH(>;4T$>Rc{QC07O%%Tqr`SH6@@&y;u! zv#WWxmKm`LjbTdr`cC=@(nOK!#GaS;&`g81wNQVV_cxSGniH=o&P7!?%JnqPy4RhI zBB)>T*2qvX|GAOsn^5BAy)iM^IseZtBF3oO$c44x6dUx?NEt4?yh;M9Y8~VP4XLg; z^qMMYh;Jx8l|e{m%{fv#zCM$=br9lXFi|v{Oo%1$N;quc0PWOyO&^sos7sF$vTmGB zc$BUL%wWnUNf3_a!<=6!rIS?w$n>x9radveWDdP_avi$=oonCcG}|TDVR)&s_W6~K za4Zlau?pHv3i-#Q9FfpxKb?b3_o)dR(MGbvjg&lPyp7C-SY)F$vJu|6QkMb9BrB)E z?&A6EeY}r0HvZK3WA7l?Mqsg{)xv6q%C}X)EWT7~3yBGAf)ib^YNlF5ndOPpIHH_? zAFLG*J#X|7_ZzOcYeXO0g!xA(GK5?!udZIW767GMH2TbdGgQwK%Zb09fVv!Y!01Zp z1XSixyHT{ze-5pN=-6Y|KddQ z^1U%0&G*AgDO#^==AA7iz8O_9R#t5meHu)@xcYo|@YeBPuYcm`wlb+o;rDav3CLAq z7_bFJBe-tW^>G~edlVF;^2*)nKdnE$Yi$8)9UfKJxcJCHYB64ds~+8N>YB9qUb+py zS~)Ms*?<@N1w$yVYc$J=I2b%i{h=)CMdWv$xwo{gd0i3oS;l+(&>)rgvFoSSt8o*| ziOM{+%=CF+yIfEs#(G_^bzpR;2_q=nb|KI)xoZT5Zg_BKV7!Ysubf&Snf-{Q>f#A! z^-HRM)(EZCdRxR9!_99>Np$c&LhqZHP6Gp75{1fef_2wFLb-B)0Q2ooQ2+n{ literal 0 HcmV?d00001 diff --git a/tools/test-publisher/v2/queue/jokes.raw.json b/tools/test-publisher/v2/queue/jokes.raw.json new file mode 100644 index 00000000..f44f403f --- /dev/null +++ b/tools/test-publisher/v2/queue/jokes.raw.json @@ -0,0 +1,345 @@ +[ + { + "id": "001", + "text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java.", + "images": [] + }, + { + "id": "002", + "text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой.", + "images": [ + "images/joke_02.webp" + ] + }, + { + "id": "003", + "text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра.", + "images": [] + }, + { + "id": "004", + "text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы.", + "images": [] + }, + { + "id": "005", + "text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья.", + "images": [ + "images/joke_05.webp" + ] + }, + { + "id": "006", + "text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен.", + "images": [ + "images/joke_06.webp", + "images/joke_09.webp" + ] + }, + { + "id": "007", + "text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается.", + "images": [] + }, + { + "id": "008", + "text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать.", + "images": [ + "images/joke_08.webp" + ] + }, + { + "id": "009", + "text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный».", + "images": [] + }, + { + "id": "010", + "text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть.", + "images": [] + }, + { + "id": "011", + "text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать.", + "images": [ + "images/joke_01.webp" + ] + }, + { + "id": "012", + "text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя.", + "images": [] + }, + { + "id": "013", + "text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее.", + "images": [] + }, + { + "id": "014", + "text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой.", + "images": [ + "images/joke_04.webp" + ] + }, + { + "id": "015", + "text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно.", + "images": [] + }, + { + "id": "016", + "text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд.", + "images": [] + }, + { + "id": "017", + "text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно.", + "images": [ + "images/joke_07.webp" + ] + }, + { + "id": "018", + "text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать.", + "images": [ + "images/joke_08.webp", + "images/joke_01.webp" + ] + }, + { + "id": "019", + "text": "— Что делаешь? — Ничего. — А вчера? — Не закончил.", + "images": [] + }, + { + "id": "020", + "text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке.", + "images": [ + "images/joke_10.webp" + ] + }, + { + "id": "021", + "text": "План на день был прекрасен. Жаль, что день о нём ничего не знал.", + "images": [] + }, + { + "id": "022", + "text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее.", + "images": [] + }, + { + "id": "023", + "text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты.", + "images": [ + "images/joke_03.webp" + ] + }, + { + "id": "024", + "text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник.", + "images": [] + }, + { + "id": "025", + "text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше.", + "images": [] + }, + { + "id": "026", + "text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди.", + "images": [ + "images/joke_06.webp" + ] + }, + { + "id": "027", + "text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу.", + "images": [] + }, + { + "id": "028", + "text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления.", + "images": [] + }, + { + "id": "029", + "text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука.", + "images": [ + "images/joke_09.webp" + ] + }, + { + "id": "030", + "text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры.", + "images": [ + "images/joke_10.webp", + "images/joke_03.webp" + ] + }, + { + "id": "031", + "text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось.", + "images": [] + }, + { + "id": "032", + "text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта.", + "images": [ + "images/joke_02.webp" + ] + }, + { + "id": "033", + "text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь.", + "images": [] + }, + { + "id": "034", + "text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес.", + "images": [] + }, + { + "id": "035", + "text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду.", + "images": [ + "images/joke_05.webp" + ] + }, + { + "id": "036", + "text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа.", + "images": [] + }, + { + "id": "037", + "text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил.", + "images": [] + }, + { + "id": "038", + "text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%.", + "images": [ + "images/joke_08.webp" + ] + }, + { + "id": "039", + "text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник.", + "images": [] + }, + { + "id": "040", + "text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном.", + "images": [] + }, + { + "id": "041", + "text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут.", + "images": [ + "images/joke_01.webp" + ] + }, + { + "id": "042", + "text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя.", + "images": [ + "images/joke_02.webp", + "images/joke_05.webp" + ] + }, + { + "id": "043", + "text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся.", + "images": [] + }, + { + "id": "044", + "text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием.", + "images": [] + }, + { + "id": "045", + "text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать.", + "images": [] + }, + { + "id": "046", + "text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается.", + "images": [] + }, + { + "id": "047", + "text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы.", + "images": [] + }, + { + "id": "048", + "text": "— Ты выключил утюг? — Теперь это главная интрига поездки.", + "images": [] + }, + { + "id": "049", + "text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает.", + "images": [] + }, + { + "id": "050", + "text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления.", + "images": [] + }, + { + "id": "051", + "text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю.", + "images": [] + }, + { + "id": "052", + "text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены.", + "images": [] + }, + { + "id": "053", + "text": "Микроволновка пищит так, будто лично приготовила ужин с нуля.", + "images": [] + }, + { + "id": "054", + "text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях.", + "images": [ + "images/joke_04.webp", + "images/joke_07.webp" + ] + }, + { + "id": "055", + "text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать».", + "images": [] + }, + { + "id": "056", + "text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона.", + "images": [] + }, + { + "id": "057", + "text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду.", + "images": [] + }, + { + "id": "058", + "text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым.", + "images": [] + }, + { + "id": "059", + "text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча.", + "images": [] + }, + { + "id": "060", + "text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло.", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/requirements.txt b/tools/test-publisher/v2/requirements.txt new file mode 100644 index 00000000..4a68366b --- /dev/null +++ b/tools/test-publisher/v2/requirements.txt @@ -0,0 +1,4 @@ +cryptography>=43,<47 +websockets>=14,<17 +requests>=2.32,<3 +arweave-python-client>=1.0.18 diff --git a/tools/test-publisher/v2/systemd/shine-test-publisher-multi.service b/tools/test-publisher/v2/systemd/shine-test-publisher-multi.service new file mode 100644 index 00000000..be51c611 --- /dev/null +++ b/tools/test-publisher/v2/systemd/shine-test-publisher-multi.service @@ -0,0 +1,17 @@ +[Unit] +Description=SHiNE multi-channel test publisher +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=shine +WorkingDirectory=/opt/shine-test-publisher/v2 +ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher_multi.py run --config config.multi.json +Restart=on-failure +RestartSec=15 +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/tools/test-publisher/v2/systemd/shine-test-publisher.service b/tools/test-publisher/v2/systemd/shine-test-publisher.service new file mode 100644 index 00000000..2a2106b2 --- /dev/null +++ b/tools/test-publisher/v2/systemd/shine-test-publisher.service @@ -0,0 +1,14 @@ +[Unit] +Description=SHiNE Test Publisher v2 +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/shine-test-publisher/v2 +ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher.py run --config config.json +Restart=always +RestartSec=15 + +[Install] +WantedBy=multi-user.target diff --git a/tools/test-publisher/v2/upload-missing-images.sh b/tools/test-publisher/v2/upload-missing-images.sh new file mode 100755 index 00000000..a143a952 --- /dev/null +++ b/tools/test-publisher/v2/upload-missing-images.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +CONFIG_FILE="${1:-prepare.multi.json}" +VENV_DIR="$SCRIPT_DIR/.venv" +PYTHON_BIN="$VENV_DIR/bin/python" +PIP_BIN="$VENV_DIR/bin/pip" + +if [[ ! -f "$CONFIG_FILE" ]]; then + echo "ERROR: config not found: $CONFIG_FILE" >&2 + echo "Copy prepare.multi.example.json to prepare.multi.json first." >&2 + exit 1 +fi + +if [[ ! -f keys/arweave-wallet.json ]]; then + echo "ERROR: Arweave wallet not found: keys/arweave-wallet.json" >&2 + echo "Put the private JWK there. The file is ignored by git." >&2 + exit 1 +fi + +if [[ ! -x "$PYTHON_BIN" ]]; then + python3 -m venv "$VENV_DIR" + "$PIP_BIN" install -r requirements.txt +fi + +"$PYTHON_BIN" prepare/prepare_all_channels.py --config "$CONFIG_FILE" From 408e474130452723b51a22e7a2787135c3d3c0874813238bb09e522599b361a3 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 25 Sep 2026 13:19:24 +0300 Subject: [PATCH 30/30] =?UTF-8?q?=D0=9D=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=81?= =?UTF-8?q?=D1=85=D0=B5=D0=BC=D0=B0=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B?= =?UTF-8?q?=20Arweave=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Turbo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...s => -- удалить javac.20260920_155256.args | 0 .../ArweaveBlockPublisherScheduler.java | 4 +- .../archive/ArweaveBlockPublisherService.java | 54 +++++-- .../archive/ArweaveBlockSyncService.java | 134 ++++++++++++---- .../server/archive/ArweaveBlocksConfig.java | 49 +++++- .../server/archive/TurboDataItemUploader.java | 106 +++++++++++++ .../java/blockchain/Ans104DataItemTest.java | 8 +- .../java/shine/db/DatabaseInitializer.java | 6 + .../shine/db/dao/ArweaveBlockImportDAO.java | 23 ++- .../src/main/java/shine/db/dao/BlocksDAO.java | 17 +- .../java/shine/db/entities/BlockEntry.java | 3 - .../main/resources/postgres/migration_v25.sql | 11 ++ .../src/main/resources/postgres/schema_v1.sql | 4 +- .../blockchain/Net_AddBlock_Handler.java | 4 +- .../src/main/resources/application.properties | 17 +- .../test/it/blockchain/AddBlockSender.java | 2 +- VERSION.properties | 4 +- docs/Blockchain/01_Common_Block_Format.md | 4 +- .../Blockchain/17_ANS104_Arweave_Transport.md | 147 +++++++++++------ docs/Blockchain/CHANGELOG.md | 8 + .../CODEX_APPLY_ANS104_TEST5590_PATCH.md | 149 +++++++----------- docs/Blockchain/README.md | 7 +- shine-UI/js/services/auth-service.js | 2 +- .../static-sites/arweave-viewer/README.md | 2 +- .../static-sites/arweave-viewer/index.html | 6 +- shine-solana-arweave-viewer-v2/README.md | 4 +- shine-solana-arweave-viewer-v2/index.html | 2 +- .../КАК_ЭТО_РАБОТАЕТ.md | 2 +- shine-solana-arweave-viewer-v3/README.md | 4 +- shine-solana-arweave-viewer-v3/index.html | 2 +- 30 files changed, 544 insertions(+), 241 deletions(-) rename javac.20260920_155256.args => -- удалить javac.20260920_155256.args (100%) create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/TurboDataItemUploader.java create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql diff --git a/javac.20260920_155256.args b/-- удалить javac.20260920_155256.args similarity index 100% rename from javac.20260920_155256.args rename to -- удалить javac.20260920_155256.args diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java index 4f592ea1..4025ff08 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java @@ -20,7 +20,7 @@ public final class ArweaveBlockPublisherScheduler { ArweaveBlocksConfig cfg; try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); } catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; } - if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled"); return; } + if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled (mode=none)"); return; } if (!STARTED.compareAndSet(false,true)) return; try { ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg); @@ -29,7 +29,7 @@ public final class ArweaveBlockPublisherScheduler { }); Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } }; executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES); - log.info("Arweave user-block publisher enabled: interval={}m gateway={}", cfg.publishIntervalMinutes(), cfg.publishGateway()); + log.info("Arweave user-block publisher enabled: mode={} interval={}m", cfg.publishMode(), cfg.publishIntervalMinutes()); } catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); } } diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java index 0a314b63..4b979459 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java @@ -8,23 +8,56 @@ import shine.db.entities.BlockEntry; import java.util.ArrayList; import java.util.List; -/** Publishes locally-created signed user DataItems as one standard ANS-104 bundle. */ +/** Publishes locally-created signed user DataItems through the configured transport. */ public final class ArweaveBlockPublisherService { private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class); private final ArweaveBlocksConfig cfg; private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); - private final ArweaveL1Uploader uploader; + private final ArweaveL1Uploader arweaveUploader; + private final TurboDataItemUploader turboUploader; public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) { this.cfg = cfg; - this.uploader = new ArweaveL1Uploader(cfg); + this.arweaveUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.ARWEAVE ? new ArweaveL1Uploader(cfg) : null; + this.turboUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.TURBO ? new TurboDataItemUploader(cfg) : null; } public int runCycle() throws Exception { + if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0; List candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems()); if (candidates.isEmpty()) return 0; + return switch (cfg.publishMode()) { + case TURBO -> publishTurbo(candidates); + case ARWEAVE -> publishDirectArweave(candidates); + case NONE -> 0; + }; + } + private int publishTurbo(List candidates) throws Exception { + int published = 0; + Exception firstFailure = null; + for (BlockEntry e : candidates) { + byte[] raw = e.getBlockBytes(); + byte[] id = e.getDataItemId(); + if (raw == null || raw.length == 0 || id == null || id.length != 32) continue; + try { + TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id); + blocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis()); + published++; + log.debug("Turbo published SHiNE DataItem {} chain={} block={}", result.dataItemId(), e.getBchName(), e.getBlockNumber()); + } catch (Exception ex) { + if (firstFailure == null) firstFailure = ex; + log.warn("Turbo publish failed: chain={} block={} bytes={} error={}", + e.getBchName(), e.getBlockNumber(), raw.length, ex.getMessage()); + } + } + if (published > 0) log.info("Published {} SHiNE test DataItems through Turbo", published); + if (published == 0 && firstFailure != null) throw firstFailure; + return published; + } + + private int publishDirectArweave(List candidates) throws Exception { List items = new ArrayList<>(); List ids = new ArrayList<>(); long estimated = 32; @@ -39,7 +72,9 @@ public final class ArweaveBlockPublisherService { e.getBchName(), e.getBlockNumber(), raw.length); continue; } - items.add(raw); ids.add(id); estimated = next; + items.add(raw); + ids.add(id); + estimated = next; } if (items.isEmpty()) return 0; @@ -47,13 +82,12 @@ public final class ArweaveBlockPublisherService { List rootTags = List.of( new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"), new ArweaveL1Uploader.Tag("Bundle-Format", "binary"), - new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0"), - // Deliberately different from App=test5590 so discovery returns child DataItems only. - new ArweaveL1Uploader.Tag("App", "test5590-batch") + new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0") ); - ArweaveL1Uploader.UploadResult result = uploader.upload(bundle, rootTags); - blocksDAO.markArweavePublished(ids, result.txId(), System.currentTimeMillis()); - log.info("Published {} SHiNE test DataItems in root tx {} (bundle={} bytes)", items.size(), result.txId(), bundle.length); + ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags); + blocksDAO.markArweavePublished(ids, System.currentTimeMillis()); + log.info("Published {} SHiNE test DataItems in direct Arweave root tx {} (bundle={} bytes)", + items.size(), result.txId(), bundle.length); return items.size(); } } diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java index 700bbf87..2c249d29 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java @@ -14,7 +14,9 @@ import shine.db.dao.SolanaUserPdaCurrentDAO; import shine.db.entities.BlockchainStateEntry; import shine.db.entities.SolanaUserPdaCurrentEntry; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -24,8 +26,9 @@ import java.time.Duration; import java.util.*; /** - * Discovers App=test5590 child DataItems, extracts their exact serialized bytes - * from the root ANS-104 bundle and imports them through the normal AddBlock checks. + * Discovers individual App=test5590 DataItems and imports the exact signed ANS-104 bytes. + * The importer is transport-agnostic: a DataItem may have reached Arweave through Turbo + * or inside a direct server-created ANS-104 bundle. */ public final class ArweaveBlockSyncService { private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class); @@ -55,10 +58,11 @@ public final class ArweaveBlockSyncService { long minHeight = Math.max(cfg.syncStartBlockHeight(), stored); String cursor = null; long maxHeight = minHeight; + long lowestRetryHeight = Long.MAX_VALUE; int discovered = 0; do { - JsonNode response = graphQlPage(minHeight, cursor, ArweaveBlocksConfig.TEST_TAG_VALUE + "-batch"); + JsonNode response = graphQlPage(minHeight, cursor); JsonNode txs = response.path("data").path("transactions"); if (response.has("errors")) throw new IOException("Arweave GraphQL errors: " + response.path("errors")); JsonNode edges = txs.path("edges"); @@ -68,22 +72,27 @@ public final class ArweaveBlockSyncService { for (JsonNode edge : edges) { nextCursor = edge.path("cursor").asText(null); JsonNode node = edge.path("node"); - String rootTx = node.path("id").asText("").trim(); + String dataItemId = node.path("id").asText("").trim(); long height = node.path("block").path("height").asLong(-1L); - if (rootTx.isBlank() || height < 0) continue; + if (dataItemId.isBlank() || height < 0) continue; maxHeight = Math.max(maxHeight, height); - byte[] bundle = downloadRootBundle(rootTx); - for (Ans104Bundle.Entry entry : Ans104Bundle.read(bundle, Math.max(cfg.publishMaxItems() * 4, 100_000))) { - byte[] id32 = entry.id32(); - if (id32 == null || id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) { - continue; - } - Ans104DataItem parsed = new Ans104DataItem(entry.rawDataItem()); - if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue; - if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature in root bundle " + rootTx); - importDAO.enqueueIfMissing(id32, rootTx, height, entry.rawDataItem(), System.currentTimeMillis()); - discovered++; + byte[] id32; + try { + id32 = B64URL.decode(dataItemId); + if (id32.length != 32) throw new IllegalArgumentException("id length=" + id32.length); + } catch (Exception e) { + log.warn("Ignoring malformed Arweave DataItem id {}: {}", dataItemId, e.getMessage()); + continue; + } + if (blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue; + + try { + byte[] rawDataItem = downloadSignedDataItem(dataItemId); + if (importDAO.enqueueIfMissing(id32, height, rawDataItem, System.currentTimeMillis())) discovered++; + } catch (Exception e) { + lowestRetryHeight = Math.min(lowestRetryHeight, height); + log.warn("Cannot retrieve signed DataItem {} at height {} yet: {}", dataItemId, height, e.getMessage()); } } boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false); @@ -91,9 +100,12 @@ public final class ArweaveBlockSyncService { if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor"); } while (cursor != null); - // Keep one-height overlap: the next query includes this height and deduplicates IDs. - importDAO.setLastBlockHeight(maxHeight, System.currentTimeMillis()); - if (discovered > 0) log.info("Arweave discovery queued {} new SHiNE test DataItems through height {}", discovered, maxHeight); + // Keep inclusive overlap. If a gateway has indexed GraphQL before offsets, do not advance past that item. + long checkpoint = lowestRetryHeight == Long.MAX_VALUE ? maxHeight : Math.min(maxHeight, lowestRetryHeight); + importDAO.setLastBlockHeight(checkpoint, System.currentTimeMillis()); + if (discovered > 0) { + log.info("Arweave discovery queued {} new SHiNE test DataItems; checkpoint={}", discovered, checkpoint); + } } private void drainQueue() throws Exception { @@ -106,13 +118,12 @@ public final class ArweaveBlockSyncService { if (pending.isEmpty()) break; for (ArweaveBlockImportDAO.QueueItem q : pending) { Ans104DataItem item; - BchBlockEntry block; try { item = new Ans104DataItem(q.rawDataItem()); if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch"); if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag"); if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature"); - block = new BchBlockEntry(q.rawDataItem()); + new BchBlockEntry(q.rawDataItem()); } catch (Exception e) { importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis()); continue; @@ -154,9 +165,12 @@ public final class ArweaveBlockSyncService { stateDAO.insertIfMissing(s); } - private JsonNode graphQlPage(long minHeight, String cursor, String appTagValue) throws Exception { + private JsonNode graphQlPage(long minHeight, String cursor) throws Exception { String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\""; - String query = "query { transactions(tags:[{name:\"App\",values:[\"" + escapeGraphQl(appTagValue) + "\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id block { height } } } } }"; + String query = "query { transactions(tags:[{name:\"" + ArweaveBlocksConfig.TEST_TAG_NAME + "\",values:[\"" + + ArweaveBlocksConfig.TEST_TAG_VALUE + "\"]}], block:{min:" + minHeight + "}, first:" + + cfg.syncPageSize() + ", after:" + after + + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id block { height } } } } }"; String body = MAPPER.writeValueAsString(Map.of("query", query)); HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql")) .timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json") @@ -166,17 +180,73 @@ public final class ArweaveBlockSyncService { return MAPPER.readTree(resp.body()); } - private byte[] downloadRootBundle(String txId) throws Exception { - HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/" + txId)) - .timeout(Duration.ofMinutes(5)).GET().build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray()); - if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Arweave root HTTP " + resp.statusCode() + " tx=" + txId); - byte[] body = resp.body(); - if (body == null || body.length == 0) throw new IOException("Empty Arweave root bundle " + txId); - if (body.length > cfg.syncMaxRootBundleBytes()) throw new IOException("Root bundle exceeds syncMaxRootBundleBytes: " + body.length); - return body; + /** + * Gateways normally expose only the payload at /{dataItemId}. SHiNE needs the complete signed + * DataItem, so obtain its exact offset/size inside the root L1 transaction and range-read it. + */ + private byte[] downloadSignedDataItem(String dataItemId) throws Exception { + JsonNode offsets = getOffsets(dataItemId); + String rootTxId = offsets.path("rootTxId").asText("").trim(); + long rootOffset = offsets.path("rootOffset").asLong(-1L); + long size = offsets.path("size").asLong(-1L); + if (rootTxId.isBlank() || rootOffset < 0 || size <= 0) { + throw new IOException("Bad AR.IO offsets for " + dataItemId + ": " + offsets); + } + if (size > cfg.syncMaxDataItemBytes()) { + throw new IOException("DataItem exceeds syncMaxDataItemBytes: " + size); + } + if (rootOffset > Long.MAX_VALUE - size) throw new IOException("DataItem offset overflow"); + long endInclusive = rootOffset + size - 1; + + HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/raw/" + rootTxId)) + .timeout(Duration.ofMinutes(2)) + .header("Range", "bytes=" + rootOffset + "-" + endInclusive) + .header("Accept", "application/octet-stream") + .GET().build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream in = resp.body()) { + if (resp.statusCode() != 206) { + throw new IOException("Gateway ignored root range for DataItem " + dataItemId + ": HTTP " + resp.statusCode()); + } + byte[] bytes = readExactlyBounded(in, (int) size); + if (bytes.length != size) throw new IOException("Truncated DataItem range: expected=" + size + " got=" + bytes.length); + Ans104DataItem parsed = new Ans104DataItem(bytes); + byte[] expectedId = B64URL.decode(dataItemId); + if (!Arrays.equals(parsed.id32(), expectedId)) { + throw new IOException("Range returned another ANS-104 DataItem for " + dataItemId); + } + return bytes; + } } + private JsonNode getOffsets(String dataItemId) throws Exception { + HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/ar-io/offsets/" + dataItemId)) + .timeout(Duration.ofSeconds(30)).header("Accept","application/json").GET().build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (resp.statusCode() == 404) throw new IOException("AR.IO offsets not indexed yet"); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw new IOException("AR.IO offsets HTTP " + resp.statusCode() + ": " + safe(resp.body())); + } + return MAPPER.readTree(resp.body()); + } + + private static byte[] readExactlyBounded(InputStream in, int expected) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(expected); + byte[] buf = new byte[Math.min(64 * 1024, Math.max(1024, expected))]; + int remaining = expected; + while (remaining > 0) { + int n = in.read(buf, 0, Math.min(buf.length, remaining)); + if (n < 0) break; + out.write(buf, 0, n); + remaining -= n; + } + return out.toByteArray(); + } + + private static String safe(String value) { + String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim(); + return v.length() <= 500 ? v : v.substring(0, 500); + } private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");} private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");} } diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java index 116be97f..f97790c6 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java @@ -1,12 +1,14 @@ package server.archive; +import blockchain.Ans104DataItem; import utils.config.AppConfig; import java.nio.file.Path; +import java.util.Locale; -/** Configuration of the new per-user-block ANS-104 Arweave transport. */ +/** Configuration of per-user-block ANS-104 Arweave/Turbo transport. */ public record ArweaveBlocksConfig( - boolean publishEnabled, + PublishMode publishMode, int publishIntervalMinutes, int publishMaxItems, long publishMaxBundleBytes, @@ -15,22 +17,44 @@ public record ArweaveBlocksConfig( int minConfirmations, int confirmPollSeconds, int confirmTimeoutMinutes, + String turboUploadUrl, + String turboPaidByAddress, + Path turboWalletJwkPath, boolean syncEnabled, int syncIntervalMinutes, int syncPageSize, int syncQueueBatchSize, long syncStartBlockHeight, - long syncMaxRootBundleBytes, + long syncMaxDataItemBytes, String syncGateway ) { + public enum PublishMode { + TURBO, + ARWEAVE, + NONE; + + static PublishMode parse(String value) { + String normalized = value == null ? "none" : value.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "turbo" -> TURBO; + case "arweave" -> ARWEAVE; + case "none", "" -> NONE; + default -> throw new IllegalArgumentException( + "arweave.blocks.publish.mode must be turbo, arweave or none; got: " + value); + }; + } + } + public static final String TEST_TAG_NAME = "App"; public static final String TEST_TAG_VALUE = "test5590"; - public static final String CHANNEL_TAG_NAME = "c"; + public static final String CHANNEL_TAG_NAME = "c_test5590"; public static ArweaveBlocksConfig load() { AppConfig c = AppConfig.getInstance(); + long maxDataItemBytes = parseLong(c.getParam("arweave.blocks.sync.maxDataItemBytes"), Ans104DataItem.MAX_DATA_ITEM_BYTES); + if (maxDataItemBytes > Ans104DataItem.MAX_DATA_ITEM_BYTES) maxDataItemBytes = Ans104DataItem.MAX_DATA_ITEM_BYTES; return new ArweaveBlocksConfig( - c.getBoolean("arweave.blocks.publish.enabled", false), + PublishMode.parse(c.getParam("arweave.blocks.publish.mode")), positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"), positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"), positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"), @@ -39,23 +63,32 @@ public record ArweaveBlocksConfig( nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"), positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"), positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"), + orDefault(c.getParam("arweave.blocks.publish.turbo.uploadUrl"), "https://turbo.ardrive.io/tx"), + blankToNull(c.getParam("arweave.blocks.publish.turbo.paidByAddress")), + optionalPath(c.getParam("arweave.blocks.publish.turbo.walletJwkPath")), c.getBoolean("arweave.blocks.sync.enabled", false), positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"), clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100), positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"), nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"), - positiveLong(parseLong(c.getParam("arweave.blocks.sync.maxRootBundleBytes"), 256L * 1024 * 1024), "sync.maxRootBundleBytes"), + positiveLong(maxDataItemBytes, "sync.maxDataItemBytes"), orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com") ); } + public boolean publishEnabled() { return publishMode != PublishMode.NONE; } + public void validatePublisher() { - if (publishEnabled && walletJwkPath == null) { - throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required when publisher is enabled"); + if (publishMode == PublishMode.ARWEAVE && walletJwkPath == null) { + throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required for mode=arweave"); + } + if (publishMode == PublishMode.TURBO && (turboUploadUrl == null || turboUploadUrl.isBlank())) { + throw new IllegalArgumentException("arweave.blocks.publish.turbo.uploadUrl is required for mode=turbo"); } } private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); } + private static String blankToNull(String v) { return v == null || v.isBlank() ? null : v.trim(); } private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); } private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); } private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/TurboDataItemUploader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/TurboDataItemUploader.java new file mode 100644 index 00000000..7aea253b --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/TurboDataItemUploader.java @@ -0,0 +1,106 @@ +package server.archive; + +import blockchain.Ans104DataItem; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +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.security.MessageDigest; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.Objects; + +/** Uploads an already user-signed ANS-104 DataItem to Turbo without modifying it. */ +public final class TurboDataItemUploader { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder B64URL_DECODER = Base64.getUrlDecoder(); + + public record UploadResult(String dataItemId, String owner) {} + + private final ArweaveBlocksConfig cfg; + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + private volatile String resolvedPaidByAddress; + + public TurboDataItemUploader(ArweaveBlocksConfig cfg) { + this.cfg = Objects.requireNonNull(cfg); + } + + public UploadResult upload(byte[] rawDataItem, byte[] expectedId32) throws Exception { + if (rawDataItem == null || rawDataItem.length == 0) throw new IllegalArgumentException("Turbo DataItem is empty"); + Ans104DataItem item = new Ans104DataItem(rawDataItem); + if (!item.verifySignature()) throw new IllegalArgumentException("Turbo DataItem has bad ANS-104 signature"); + if (expectedId32 != null && !Arrays.equals(item.id32(), expectedId32)) { + throw new IllegalArgumentException("Turbo DataItem id does not match blocks.data_item_id"); + } + + String expectedId = B64URL.encodeToString(item.id32()); + HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(cfg.turboUploadUrl())) + .timeout(Duration.ofMinutes(2)) + .header("Content-Type", "application/octet-stream") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofByteArray(rawDataItem)); + String paidBy = paidByAddress(); + if (paidBy != null) request.header("x-paid-by", paidBy); + + HttpResponse response = http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + String body = response.body() == null ? "" : response.body().trim(); + if (response.statusCode() == 409 && body.toLowerCase().contains("data item exists")) { + return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + String hint = response.statusCode() == 402 + ? " (Turbo payment required: check server credits / Credit Share Approval / x-paid-by)" + : ""; + throw new IOException("Turbo HTTP " + response.statusCode() + hint + ": " + safe(body)); + } + + if (body.isBlank()) return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); + JsonNode json; + try { json = MAPPER.readTree(body); } + catch (Exception ignored) { return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); } + String returnedId = json.path("id").asText("").trim(); + if (!returnedId.isBlank() && !returnedId.equals(expectedId)) { + throw new IOException("Turbo returned another DataItem id: expected=" + expectedId + " got=" + returnedId); + } + return new UploadResult(expectedId, json.path("owner").asText("")); + } + + /** + * x-paid-by contains the payer's public native address, never the private key. + * A configured Arweave JWK is used only to derive that public address. + */ + private String paidByAddress() throws Exception { + if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress; + synchronized (this) { + if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress; + String explicit = cfg.turboPaidByAddress(); + if (explicit != null && !explicit.isBlank()) return resolvedPaidByAddress = explicit.trim(); + if (cfg.turboWalletJwkPath() == null) { + resolvedPaidByAddress = ""; + return null; + } + JsonNode jwk = MAPPER.readTree(Files.readString(cfg.turboWalletJwkPath(), StandardCharsets.UTF_8)); + String modulus = jwk.path("n").asText("").trim(); + if (modulus.isBlank()) throw new IllegalStateException("Turbo payer JWK missing n"); + byte[] owner = B64URL_DECODER.decode(modulus); + resolvedPaidByAddress = B64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(owner)); + return resolvedPaidByAddress; + } + } + + private static String safe(String value) { + String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim(); + return v.length() <= 500 ? v : v.substring(0, 500); + } +} diff --git a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java index ec120078..2350b93b 100644 --- a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java +++ b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java @@ -16,7 +16,7 @@ final class Ans104DataItemTest { byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8); List tags = List.of( new Ans104DataItem.Tag("App", "test5590"), - new Ans104DataItem.Tag("c", "books") + new Ans104DataItem.Tag("c_test5590", "books") ); byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data); @@ -28,7 +28,7 @@ final class Ans104DataItemTest { assertArrayEquals(owner, parsed.owner32()); assertArrayEquals(data, parsed.data()); assertTrue(parsed.hasTag("App", "test5590")); - assertEquals("books", parsed.tagValue("c")); + assertEquals("books", parsed.tagValue("c_test5590")); assertTrue(parsed.verifySignature()); assertEquals(32, parsed.id32().length); } @@ -51,11 +51,11 @@ final class Ans104DataItemTest { byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8); List tags = List.of( new Ans104DataItem.Tag("App", "test5590"), - new Ans104DataItem.Tag("c", "books") + new Ans104DataItem.Tag("c_test5590", "books") ); byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data); - byte[] expected = hex("7e67d0debce103606d697a1e3785130ca20cb89cd8a06f9a65b98b2d2427eaf411fcf7d482da268e44f3ac25c57c3cb9"); + byte[] expected = hex("1abe0371d12268b34be32ca5ebf3d3d2189f9d311d9004c142538ca8a4312faa329d1a4873df72dd01b632a2ecce6b87"); assertArrayEquals(expected, actual); } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index b5f838ff..9c19edf7 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -41,6 +41,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_22 = 22; public static final int SCHEMA_VERSION_23 = 23; public static final int SCHEMA_VERSION_24 = 24; + public static final int SCHEMA_VERSION_25 = 25; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -65,6 +66,7 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql"; public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql"; public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql"; + public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql"; private DatabaseInitializer() {} @@ -230,6 +232,10 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE); currentVersion = SCHEMA_VERSION_24; } + if (currentVersion < SCHEMA_VERSION_25) { + runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE); + currentVersion = SCHEMA_VERSION_25; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java index 371fbf9d..74b8174e 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java @@ -6,12 +6,12 @@ import java.sql.*; import java.util.ArrayList; import java.util.List; -/** Persistent discovery/import queue for ANS-104 SHiNE blocks found through Arweave. */ +/** Persistent discovery/import queue for individual ANS-104 SHiNE DataItems found through Arweave. */ public final class ArweaveBlockImportDAO { public static final String STATUS_PENDING = "PENDING"; public static final String STATUS_REJECTED = "REJECTED"; - public record QueueItem(byte[] dataItemId, String rootTxId, long blockHeight, + public record QueueItem(byte[] dataItemId, long blockHeight, byte[] rawDataItem, String status, String lastError, long firstSeenAtMs, long updatedAtMs) {} @@ -51,22 +51,21 @@ public final class ArweaveBlockImportDAO { } /** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */ - public boolean enqueueIfMissing(byte[] dataItemId, String rootTxId, long blockHeight, byte[] rawDataItem, long nowMs) + public boolean enqueueIfMissing(byte[] dataItemId, long blockHeight, byte[] rawDataItem, long nowMs) throws SQLException { String sql = """ INSERT INTO arweave_block_import_queue( - data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms - ) VALUES(?,?,?,?,?,'',?,?) + data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms + ) VALUES(?,?,?,?, '',?,?) ON CONFLICT(data_item_id) DO NOTHING """; try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { ps.setBytes(1, dataItemId); - ps.setString(2, rootTxId); - ps.setLong(3, blockHeight); - ps.setBytes(4, rawDataItem); - ps.setString(5, STATUS_PENDING); + ps.setLong(2, blockHeight); + ps.setBytes(3, rawDataItem); + ps.setString(4, STATUS_PENDING); + ps.setLong(5, nowMs); ps.setLong(6, nowMs); - ps.setLong(7, nowMs); return ps.executeUpdate() > 0; } } @@ -82,7 +81,7 @@ public final class ArweaveBlockImportDAO { public List listPending(int limit) throws SQLException { int safeLimit = Math.max(1, Math.min(limit, 100_000)); String sql = """ - SELECT data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms + SELECT data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms FROM arweave_block_import_queue WHERE status=? ORDER BY block_height ASC, first_seen_at_ms ASC @@ -95,7 +94,7 @@ public final class ArweaveBlockImportDAO { try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { out.add(new QueueItem( - rs.getBytes("data_item_id"), rs.getString("root_tx_id"), rs.getLong("block_height"), + rs.getBytes("data_item_id"), rs.getLong("block_height"), rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"), rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms"))); } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java index 1b40ffe0..e7783ba6 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java @@ -27,9 +27,9 @@ public final class BlocksDAO { login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, to_login,to_bch_name,to_block_number,to_block_hash, block_hash,block_signature,data_item_id, - arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, + arweave_publish_pending,arweave_published_at_ms, edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { int i = 1; @@ -48,7 +48,6 @@ public final class BlocksDAO { ps.setBytes(i++, e.getDataItemId()); ps.setBoolean(i++, e.isArweavePublishPending()); if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs()); - setNullableString(ps, i++, e.getArweaveRootTxId()); setNullableInt(ps, i++, e.getEditedByBlockNumber()); setNullableInt(ps, i++, e.getLineCode()); setNullableInt(ps, i++, e.getPrevLineNumber()); @@ -74,7 +73,7 @@ public final class BlocksDAO { String sql = """ SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id, - arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, + arweave_publish_pending,arweave_published_at_ms, edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number FROM blocks WHERE arweave_publish_pending = TRUE @@ -89,18 +88,18 @@ public final class BlocksDAO { return out; } - public void markArweavePublished(List dataItemIds, String rootTxId, long publishedAtMs) throws SQLException { + public void markArweavePublished(List dataItemIds, long publishedAtMs) throws SQLException { if (dataItemIds == null || dataItemIds.isEmpty()) return; String sql = """ UPDATE blocks - SET arweave_publish_pending=FALSE, arweave_published_at_ms=?, arweave_root_tx_id=? + SET arweave_publish_pending=FALSE, arweave_published_at_ms=? WHERE data_item_id=? """; try (Connection c = db.getConnection()) { c.setAutoCommit(false); try (PreparedStatement ps = c.prepareStatement(sql)) { for (byte[] id : dataItemIds) { - ps.setLong(1, publishedAtMs); ps.setString(2, rootTxId); ps.setBytes(3, id); ps.addBatch(); + ps.setLong(1, publishedAtMs); ps.setBytes(2, id); ps.addBatch(); } ps.executeBatch(); c.commit(); } catch (Exception e) { c.rollback(); throw e; } @@ -133,7 +132,7 @@ public final class BlocksDAO { private static String baseSelect(){return """ SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id, - arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, + arweave_publish_pending,arweave_published_at_ms, edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number FROM blocks """;} @@ -144,7 +143,7 @@ public final class BlocksDAO { e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes")); e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash")); e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id")); - e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms")); e.setArweaveRootTxId(rs.getString("arweave_root_tx_id")); + e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms")); e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number")); return e; } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java index 12df97cf..965de4f9 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java @@ -40,7 +40,6 @@ public class BlockEntry { private byte[] dataItemId; private boolean arweavePublishPending; private Long arweavePublishedAtMs; - private String arweaveRootTxId; private Integer editedByBlockNumber; @@ -95,8 +94,6 @@ public class BlockEntry { public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; } public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; } public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; } - public String getArweaveRootTxId() { return arweaveRootTxId; } - public void setArweaveRootTxId(String arweaveRootTxId) { this.arweaveRootTxId = arweaveRootTxId; } public Integer getEditedByBlockNumber() { return editedByBlockNumber; } public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; } diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql new file mode 100644 index 00000000..8c0484d9 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql @@ -0,0 +1,11 @@ +-- Turbo/direct-Arweave transport no longer stores root transaction IDs for user DataItems. +ALTER TABLE blocks + DROP COLUMN IF EXISTS arweave_root_tx_id; + +ALTER TABLE arweave_block_import_queue + DROP COLUMN IF EXISTS root_tx_id; + +UPDATE db_schema_version +SET schema_version=25, + updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) +WHERE id=1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 9109d1c4..f13a37a4 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -500,7 +500,6 @@ CREATE TABLE IF NOT EXISTS blocks ( data_item_id BYTEA NOT NULL, arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE, arweave_published_at_ms BIGINT, - arweave_root_tx_id TEXT, edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0), line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0), prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0), @@ -535,7 +534,6 @@ VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING; CREATE TABLE IF NOT EXISTS arweave_block_import_queue ( data_item_id BYTEA PRIMARY KEY, - root_tx_id TEXT NOT NULL, block_height BIGINT NOT NULL, raw_data_item BYTEA NOT NULL, status TEXT NOT NULL, @@ -2081,7 +2079,7 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending WHERE archive_head_tx_id <> ''; INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,25,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 18829f3e..77663501 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -409,10 +409,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L); } - // Channel DataItems are indexed by a signed canonical channel slug tag: c=. + // Channel DataItems are indexed by a signed canonical channel slug tag: c_test5590=. try { String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry); - String actualChannelSlug = block.getDataItem().tagValue("c"); + String actualChannelSlug = block.getDataItem().tagValue("c_test5590"); if (expectedChannelSlug != null) { if (!expectedChannelSlug.equals(actualChannelSlug)) { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex); diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index dfbcff92..12fe9f9e 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -138,11 +138,14 @@ test.freeAvatar.walletJwkPath= # ============================================================ # Arweave per-user-block transport (ANS-104) # Test namespace: each user DataItem is signed with App=test5590. -# Channel DataItems additionally contain c=. +# Channel DataItems additionally contain c_test5590=. +# publish.mode: turbo | arweave | none # ============================================================ -arweave.blocks.publish.enabled=false +arweave.blocks.publish.mode=none arweave.blocks.publish.intervalMinutes=15 arweave.blocks.publish.maxItems=10000 + +# Direct Arweave L1 fallback: server combines user DataItems into one standard ANS-104 bundle. arweave.blocks.publish.maxBundleBytes=134217728 arweave.blocks.publish.gateway=https://arweave.net arweave.blocks.publish.walletJwkPath= @@ -150,10 +153,18 @@ arweave.blocks.publish.minConfirmations=0 arweave.blocks.publish.confirmPollSeconds=30 arweave.blocks.publish.confirmTimeoutMinutes=180 +# Turbo: uploads each already user-signed DataItem separately, without re-signing it. +# paidByAddress is the public Turbo payer address. If it is empty and turbo.walletJwkPath is set, +# the Arweave payer address is derived locally from that JWK. The private key is never sent to Turbo. +# For paid uploads of someone else's signed DataItem, Turbo Credit Share Approval must exist for its signer. +arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx +arweave.blocks.publish.turbo.paidByAddress= +arweave.blocks.publish.turbo.walletJwkPath= + arweave.blocks.sync.enabled=false arweave.blocks.sync.intervalMinutes=15 arweave.blocks.sync.gateway=https://turbo-gateway.com arweave.blocks.sync.pageSize=100 arweave.blocks.sync.queueBatchSize=10000 arweave.blocks.sync.startBlockHeight=0 -arweave.blocks.sync.maxRootBundleBytes=268435456 +arweave.blocks.sync.maxDataItemBytes=8388608 diff --git a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java index 7e555a78..9a659908 100644 --- a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java +++ b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java @@ -82,7 +82,7 @@ public final class AddBlockSender { List tags = new ArrayList<>(); tags.add(new Ans104DataItem.Tag("App", "test5590")); String channelSlug = channelSlugFor(body); - if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c", channelSlug)); + if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c_test5590", channelSlug)); byte[] signingMessage = Ans104DataItem.buildSigningMessage(owner32, tags, frame); byte[] signature64 = utils.crypto.Ed25519Util.sign(signingMessage, loginPrivKey); diff --git a/VERSION.properties b/VERSION.properties index 156ef164..f668f660 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.23 -server.version=1.10.9 +client.version=1.12.24 +server.version=1.10.10 diff --git a/docs/Blockchain/01_Common_Block_Format.md b/docs/Blockchain/01_Common_Block_Format.md index a9b49bb5..28a51ce2 100644 --- a/docs/Blockchain/01_Common_Block_Format.md +++ b/docs/Blockchain/01_Common_Block_Format.md @@ -82,7 +82,7 @@ App = test5590 Если блок относится к конкретному каналу, он дополнительно содержит: ```text -c = +c_test5590 = ``` Slug входит в подпись DataItem и не может быть изменён сервером после подписи. @@ -107,7 +107,7 @@ Slug входит в подпись DataItem и не может быть изм 1. распарсить полный ANS-104 DataItem; 2. проверить `App=test5590`; -3. проверить `c`, если тип блока требует канал; +3. проверить `c_test5590`, если тип блока требует канал; 4. проверить ANS-104 Ed25519 подпись; 5. проверить, что `owner` равен текущему blockchain public key пользователя; 6. распарсить Frame v1 и body; diff --git a/docs/Blockchain/17_ANS104_Arweave_Transport.md b/docs/Blockchain/17_ANS104_Arweave_Transport.md index a71759a7..259b1ea1 100644 --- a/docs/Blockchain/17_ANS104_Arweave_Transport.md +++ b/docs/Blockchain/17_ANS104_Arweave_Transport.md @@ -2,96 +2,155 @@ ## Цель -Каждый пользовательский блок уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер не переподписывает пользовательский контент: он проверяет его, хранит в PostgreSQL и объединяет готовые DataItems в стандартный ANS-104 bundle. +Каждый пользовательский блок SHiNE уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер проверяет и хранит **точно эти signed bytes** и может публиковать их одним из двух транспортов: через Turbo по одному DataItem либо через прямую Arweave L1-транзакцию в составе стандартного большого ANS-104 bundle. -## Child DataItem tags +Способ публикации — локальная политика конкретного сервера. Формат пользовательского блока и импорт от него не зависят. -Обязательно для тестового контура: +## User DataItem tags + +Для тестового контура обязательно: ```text App=test5590 ``` -Дополнительно для блоков конкретного канала: +Для блоков конкретного канала дополнительно: ```text -c= +c_test5590= ``` -Теги входят в ANS-104 подпись пользователя. +Теги входят в ANS-104 подпись пользователя. Старый тестовый тег `c` новым кодом не создаётся и не принимается как channel tag. -## Publisher +## Publisher modes -По умолчанию цикл — раз в 15 минут. +Настройка: + +```text +arweave.blocks.publish.mode=turbo | arweave | none +``` + +### `turbo` ```text blocks.arweave_publish_pending=true ↓ -готовые serialized DataItems +готовый signed user DataItem из blocks.block_bytes ↓ -ANS-104 binary bundle +POST в Turbo как application/octet-stream ↓ -обычная Arweave L1 transaction +Turbo bundling / Arweave ``` -Если pending-блоков нет, транзакция не создаётся. +DataItem **не переподписывается** сервером. Его `data_item_id = SHA-256(user signature)` до и после загрузки должен оставаться тем же. -Root transaction содержит стандартные bundle tags: +Для Turbo можно задать публичный payer address напрямую: + +```text +arweave.blocks.publish.turbo.paidByAddress=... +``` + +либо путь к серверному Arweave JWK: + +```text +arweave.blocks.publish.turbo.walletJwkPath=/path/to/server-turbo-wallet.json +``` + +Из JWK локально вычисляется только публичный Arweave address для `x-paid-by`; приватный ключ Turbo upload endpoint не получает. + +Важно: если upload уже требует оплаты, а signed DataItem принадлежит другому signer, Turbo Credits серверного кошелька используются через Credit Share Approval в пользу signer-адреса. Для маленьких DataItem, попадающих под действующий free tier Turbo, payer может не понадобиться. Код не должен рассчитывать на вечное существование free tier: HTTP `402` считается ошибкой оплаты и блок остаётся pending. + +### `arweave` + +Сохраняется прежний fallback: + +```text +pending user DataItems + ↓ +standard ANS-104 binary bundle + ↓ +server Arweave RSA/JWK signature + ↓ +Arweave L1 +``` + +Root transaction содержит только стандартные bundle tags: ```text Bundle-Format=binary Bundle-Version=2.0.0 Content-Type=application/octet-stream -App=test5590-batch ``` -`App=test5590-batch` намеренно отличается от child `App=test5590`, чтобы discovery-запрос находил пользовательские блоки, а не root bundles. +Специальный `App=test5590-batch` больше не используется. Важны вложенные user DataItems, у которых уже есть `App=test5590`. -После успешной L1-загрузки сервер ставит child-блокам: +### `none` + +Сервер принимает и хранит блоки локально, но publisher не отправляет их в Arweave/Turbo. Importer при этом может работать независимо. + +## Состояние публикации в БД + +После успешной публикации: - `arweave_publish_pending=false`; -- `arweave_published_at_ms`; -- `arweave_root_tx_id`. +- заполняется `arweave_published_at_ms`. -## Importer +`arweave_root_tx_id` больше не хранится: один и тот же пользовательский DataItem может быть физически упакован разными bundler-ами, а стабильным сетевым идентификатором SHiNE является именно `data_item_id`. -Каждый сервер может независимо искать: +## Importer: только individual DataItems + +Importer всегда выполняет один discovery-запрос: ```text App=test5590 ``` -через GraphQL gateway с cursor pagination. +Он **не ищет root bundles** и не зависит от `publish.mode`. -Для каждого нового DataItem: +Это одинаково работает для: -1. взять `id` и `bundledIn.id`; -2. получить root bundle; -3. извлечь точные serialized bytes child DataItem по bundle index; -4. проверить `dataItemId == SHA256(signature)`; -5. проверить ANS-104 Ed25519 подпись; -6. определить пользователя по `owner`; -7. применить обычные проверки `AddBlock`; -8. записать в PostgreSQL с `arweave_publish_pending=false`. +- DataItem, отправленного через Turbo; +- DataItem, находящегося внутри большого direct-Arweave ANS-104 bundle сервера. -### Блоки могут прийти не по порядку +После того как AR.IO gateway распаковал/indexed bundle, child DataItem присутствует в GraphQL как отдельная сущность со своим `id` и собственными tags. -Discovery/import использует persistent queue `arweave_block_import_queue`. Если, например, block 102 увиден раньше block 101, block 102 остаётся `PENDING`; после появления 101 очередь повторно проигрывается. +### Получение полного signed DataItem -## Дедупликация и несколько серверов +Обычная выдача DataItem по gateway URL может представлять только payload, а SHiNE для криптографической проверки нужны полные serialized ANS-104 bytes. -Один и тот же готовый DataItem имеет один `data_item_id = SHA256(signature)`. Если несколько серверов включили его в разные root bundles, локально это всё равно один логический блок: `blocks.data_item_id` уникален. +Поэтому importer: -Импортированный из Arweave блок **не ставится обратно в publish queue**. Это предотвращает бесконечное переархивирование между серверами. +1. получает `data_item_id` через GraphQL `App=test5590`; +2. запрашивает `GET /ar-io/offsets/{data_item_id}`; +3. получает `rootTxId`, `rootOffset`, `size`; +4. делает range-read `GET /raw/{rootTxId}` ровно по этому диапазону; +5. разбирает полученные bytes как `Ans104DataItem`; +6. проверяет, что `SHA-256(signature) == data_item_id`; +7. проверяет Ed25519 ANS-104 signature; +8. определяет пользователя по `owner`; +9. импортирует через обычную логику `AddBlock` без повторной публикации. + +Если GraphQL уже увидел DataItem, но gateway ещё не подготовил offsets, checkpoint не продвигается за этот height и DataItem будет повторён в следующем цикле. + +## Очередь и порядок блоков + +`arweave_block_import_queue` хранит: + +- `data_item_id`; +- `block_height`; +- полный `raw_data_item`; +- status/error/timestamps. + +`root_tx_id` очереди больше не нужен. + +Если block N+1 увиден раньше N, он остаётся `PENDING`; после появления предыдущего блока очередь повторно проигрывается. + +## Дедупликация + +`blocks.data_item_id` уникален. Один signed DataItem остаётся одним логическим SHiNE-блоком независимо от того, сколько серверов или bundler-ов физически включили его в Arweave. + +Импортированный блок записывается через `AddBlock` с отключённой повторной публикацией, поэтому серверы не создают цикл переархивирования. ## Локальное хранение -Пользовательские blockchain-файлы на диске больше не используются. Полный serialized DataItem находится в `blocks.block_bytes` PostgreSQL. - -## Настройки - -См. `application.properties` и `CODEX_APPLY_ANS104_TEST5590_PATCH.md`. - -## Что намеренно не входит в этот патч - -Remote/homeserver signing path, связанный с внешним homeserver/ESP32 signer, не мигрируется этим патчем. Каталог `ESP32/` не изменяется. До отдельной миграции новый Frame v1/ANS-104 production path рассчитан на клиент, у которого локально доступен blockchain Ed25519 key. +Полный serialized signed DataItem хранится в `blocks.block_bytes` PostgreSQL. Пользовательские `.bch`-файлы не являются источником истины. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index a0e37e0e..bc247cb5 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,13 @@ # История изменений документации блокчейна +## 2026-09-23 — Turbo transport для individual ANS-104 DataItems +- Базовый коммит-ориентир: `3483a0a`; изменения подготовлены как patch без нового git-коммита. +- Publisher получил режимы `turbo | arweave | none`: Turbo отправляет каждый исходный user-signed DataItem отдельно, direct Arweave fallback сохраняет standard ANS-104 bundle, `none` отключает внешнюю публикацию. +- Удалён технический namespace `App=test5590-batch`: importer всегда ищет только individual `App=test5590` DataItems независимо от способа их физической упаковки. +- Channel tag тестового контура изменён с `c` на `c_test5590`; новый тег является частью пользовательской ANS-104 подписи. +- Importer получает точные serialized signed DataItem bytes через AR.IO offsets + range-read root transaction и проверяет `data_item_id`/Ed25519 signature перед `AddBlock`. +- Из PostgreSQL удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`; добавлена migration v25. + ## 2026-09-23 — Тестовые каналы и Arweave-only синхронизация - Базовый коммит-ориентир: `3483a0a`. - Добавлены тестовые каналы и publisher для генерации пользовательских POST-блоков через обычный `AddBlock`. diff --git a/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md index c08e6016..791f37df 100644 --- a/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md +++ b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md @@ -1,108 +1,79 @@ -# Инструкция Codex: применить ANS-104 test5590 patch +# Применение patch: Turbo + direct Arweave для `App=test5590` -## Цель +## Что меняется -Перевести пользовательский blockchain SHiNE на Frame v1 внутри готовых ANS-104 DataItems и убрать старый SHINE-ARCHIVE/файловое хранение цепочек. +- `arweave.blocks.publish.mode=turbo|arweave|none` вместо boolean publisher switch. +- `turbo`: каждый готовый user-signed ANS-104 DataItem отправляется в Turbo отдельно. +- `arweave`: сохранён прямой L1 fallback — несколько user DataItems собираются в standard ANS-104 bundle. +- `none`: наружу ничего не публикуется. +- Importer всегда ищет только individual `App=test5590` DataItems. +- `App=test5590-batch` больше не используется. +- Channel tag: `c_test5590=` вместо `c=...`. +- Удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`. +- Схема PostgreSQL: v25. -## Жёсткое ограничение - -**Не изменять ничего в `ESP32/`.** В этом patch нет ни одного файла `ESP32/**`. - -Remote/homeserver signer, завязанный на устройство, намеренно не мигрирован. Не пытаться «заодно исправить» его в рамках этого patch. - -## Применение - -1. Распаковать patch поверх корня репозитория, сохраняя относительные пути. -2. Удалить все пути из корневого `DELETE_FILES.txt`. -3. Проверить, что `git diff -- ESP32` пуст. -4. Использовать чистую/dev test DB. `migration_v24.sql` намеренно откажется мигрировать непустую blockchain DB, потому что backward compatibility со старым block format не требуется. - -## Arweave config - -Минимально для публикации: +## Минимальная настройка Turbo ```properties -arweave.blocks.publish.enabled=true -arweave.blocks.publish.intervalMinutes=15 -arweave.blocks.publish.gateway=https://arweave.net -arweave.blocks.publish.walletJwkPath=/ABSOLUTE/SECRET/PATH/arweave-wallet.json +arweave.blocks.publish.mode=turbo +arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx ``` -JWK не коммитить. +Для действующего free tier маленьких DataItem этого может быть достаточно. -Для discovery/import: +Если upload платный и расходы должны идти с server Turbo Credits: + +```properties +arweave.blocks.publish.turbo.walletJwkPath=/home/player/SHiNE/secrets/turbo-wallet.json +# либо вместо JWK сразу публичный адрес: +# arweave.blocks.publish.turbo.paidByAddress= +``` + +JWK не отправляется Turbo: из него вычисляется публичный address для `x-paid-by`. +Для чужого signed DataItem платные Turbo Credits требуют действующего Credit Share Approval от server payer к signer-адресу DataItem. Если его нет, Turbo вернёт HTTP 402, а блок останется pending для повторной попытки. + +## Direct Arweave fallback + +```properties +arweave.blocks.publish.mode=arweave +arweave.blocks.publish.walletJwkPath=/home/player/SHiNE/secrets/arweave-wallet.json +arweave.blocks.publish.gateway=https://arweave.net +``` + +Root bundle больше не получает `App=test5590-batch`; child DataItems уже содержат `App=test5590` и именно их индексирует importer. + +## Отключение публикации + +```properties +arweave.blocks.publish.mode=none +``` + +Это не отключает `arweave.blocks.sync.enabled`: read/import и publish независимы. + +## Importer ```properties arweave.blocks.sync.enabled=true -arweave.blocks.sync.intervalMinutes=15 arweave.blocks.sync.gateway=https://turbo-gateway.com -arweave.blocks.sync.startBlockHeight=0 +arweave.blocks.sync.maxDataItemBytes=8388608 ``` -На тестах желательно установить `startBlockHeight` на высоту начала `test5590`, чтобы не сканировать лишнюю историю. +Importer: -## Test namespace +1. GraphQL `App=test5590`; +2. `/ar-io/offsets/`; +3. range `GET /raw/`; +4. проверка exact signed DataItem ID + signature; +5. обычный `AddBlock` import. -Child DataItem: +## Миграция БД -```text -App=test5590 -``` +При старте schema v24 автоматически применит `migration_v25.sql`, которая удаляет два root-tx поля и ставит version 25. -Channel child: +## Проверка после применения -```text -App=test5590 -c= -``` - -Root bundle: - -```text -Bundle-Format=binary -Bundle-Version=2.0.0 -App=test5590-batch -``` - -Перед production-start test namespace должен быть заменён отдельным осознанным изменением. - -## Проверки после применения - -Из корня репозитория: - -```bash -node --check shine-UI/js/services/ans104-data-item.js -node --check shine-UI/js/services/auth-service.js -node --check shine-UI/js/app.js -node --check shine-UI/js/pages/settings-view.js -``` - -Java/Gradle: - -```bash -./gradlew testClasses -./gradlew test -``` - -Затем локальный smoke test по штатной инструкции проекта, например `./gradlew startLocal`. - -В среде, где готовился patch, Gradle wrapper не смог скачать Gradle 8.14 из-за отсутствия внешнего сетевого доступа к `services.gradle.org`. Поэтому полный Gradle compile/test обязательно прогнать после применения в обычной dev-среде. - -## Smoke scenario - -1. Создать/использовать тестового пользователя с локальным blockchain Ed25519 key. -2. Добавить обычный block и убедиться, что `blocks.block_bytes` начинается с ANS-104 DataItem, а `data_item_id` заполнен. -3. Создать channel и post; проверить `c=`. -4. Включить publisher, дождаться цикла или вызвать сервис тестом; проверить root Arweave tx. -5. На второй чистой test DB включить importer и убедиться, что `App=test5590` blocks восстанавливаются в правильном порядке. -6. Убедиться, что imported blocks имеют `arweave_publish_pending=false`. -7. Проверить, что повторный discovery не создаёт дублей. - -## Не делать в этом patch - -- не добавлять backward compatibility Frame v0; -- не возвращать `.bch` storage; -- не возвращать SHINE-ARCHIVE; -- не менять ESP32; -- не мигрировать remote/homeserver signing без отдельного решения пользователя; -- не заменять `prevHash` на Arweave DataItem ID. +1. Создать новый channel/post и проверить signed tag `c_test5590=`. +2. В `mode=turbo` убедиться, что `blocks.data_item_id` совпадает с Turbo response `id` и pending становится false. +3. На втором сервере включить sync и убедиться, что DataItem находится GraphQL-запросом `App=test5590` и импортируется без прямой server-to-server связи. +4. Переключить первый сервер в `mode=arweave`, создать ещё несколько блоков и убедиться, что тот же importer второго сервера видит child DataItems без знания root bundle ID. +5. Проверить `mode=none`: новые локальные блоки остаются pending, наружу ничего не отправляется. diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index ac552eb6..30928277 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -14,7 +14,7 @@ ```text User -> создаёт Frame v1 - -> tags: App=test5590, при канале c= + -> tags: App=test5590, при канале c_test5590= -> Ed25519 подписывает ANS-104 deep-hash -> готовый DataItem -> AddBlock @@ -22,8 +22,9 @@ User Server -> verify DataItem + SHiNE chain -> PostgreSQL - -> каждые ~15 минут ANS-104 bundle - -> Arweave L1 + -> publish.mode=turbo: каждый signed DataItem через Turbo + ИЛИ publish.mode=arweave: большой standard ANS-104 bundle -> Arweave L1 + ИЛИ publish.mode=none: наружу не публиковать Other servers -> GraphQL App=test5590 diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index c8c97879..24859bdd 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1845,7 +1845,7 @@ export class AuthService { const ansTags = [{ name: 'App', value: 'test5590' }]; const cleanChannelSlug = String(channelSlug || '').trim(); - if (cleanChannelSlug) ansTags.push({ name: 'c', value: cleanChannelSlug }); + if (cleanChannelSlug) ansTags.push({ name: 'c_test5590', value: cleanChannelSlug }); const { response, blockchainName } = await this.runAddBlockWithRetry({ login: cleanLogin, diff --git a/shine-UI/static-sites/arweave-viewer/README.md b/shine-UI/static-sites/arweave-viewer/README.md index eccebca9..5f306626 100644 --- a/shine-UI/static-sites/arweave-viewer/README.md +++ b/shine-UI/static-sites/arweave-viewer/README.md @@ -32,7 +32,7 @@ index.html?key=&channel=books 1. Если передан `login`, вычисляет PDA `user_login=` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`. 2. Если передан `key`, использует его напрямую и не обращается к Solana. 3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`. -4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c=`. +4. Делает Arweave GraphQL запрос по owner + `App=test5590`; `channel` применяется локально после загрузки авторского блокчейна. 5. Загружает payload каждого DataItem через gateway `/`. 6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes). 7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1. diff --git a/shine-UI/static-sites/arweave-viewer/index.html b/shine-UI/static-sites/arweave-viewer/index.html index 6e0c4bac..aba22cff 100644 --- a/shine-UI/static-sites/arweave-viewer/index.html +++ b/shine-UI/static-sites/arweave-viewer/index.html @@ -1032,8 +1032,8 @@ select:focus { /* Viewer v3 */ :root{--v3-bg:#f5f5f3;--v3-panel:#fff;--v3-text:#171717;--v3-sub:#747474;--v3-border:#e7e7e3;--v3-hover:#f0f0ed;--v3-shadow:0 18px 60px rgba(0,0,0,.08)} html[data-theme="dark"]{color-scheme:dark;--v3-bg:#101110;--v3-panel:#181918;--v3-text:#f3f3ef;--v3-sub:#9c9d98;--v3-border:#292a28;--v3-hover:#222320;--v3-shadow:0 18px 60px rgba(0,0,0,.28)} -html,body{background:var(--v3-bg)!important;color:var(--v3-text)!important}.viewer-app{min-height:100vh;background:var(--v3-bg);padding-bottom:54px}.viewer-toolbar{position:sticky;top:0;z-index:30;height:64px;padding:0 max(18px,calc((100vw - 980px)/2));display:flex;align-items:center;gap:10px;background:color-mix(in srgb,var(--v3-bg) 88%,transparent);backdrop-filter:blur(18px);border-bottom:1px solid var(--v3-border)} -.v3-brand{font-weight:800;letter-spacing:-.03em;margin-right:auto}.v3-tabs{display:flex;padding:4px;background:var(--v3-hover);border-radius:12px}.v3-tab{border:0;background:transparent;color:var(--v3-sub);padding:8px 13px;border-radius:9px;font-weight:700;cursor:pointer}.v3-tab.is-active{background:var(--v3-panel);color:var(--v3-text);box-shadow:0 1px 5px rgba(0,0,0,.08)}.toolbar-icon{background:var(--v3-panel)!important;color:var(--v3-text)!important;border:1px solid var(--v3-border)!important;border-radius:11px!important;width:40px;height:40px}.v3-menu{position:absolute;right:max(18px,calc((100vw - 980px)/2));top:58px;width:min(340px,calc(100vw - 28px));padding:14px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:16px;box-shadow:var(--v3-shadow);z-index:50}.v3-menu label{color:var(--v3-text)}.v3-menu input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-switch{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 2px 14px}.viewer-main{width:min(860px,calc(100% - 28px));margin:22px auto!important}.mobile-card,.message-bubble,.channel-row{background:var(--v3-panel)!important;color:var(--v3-text)!important;border-color:var(--v3-border)!important;box-shadow:none!important}.message-bubble__meta,.channel-row__meta,.channel-row__preview,.channel-head p{color:var(--v3-sub)!important}.thread-toggle{border:0;background:transparent;color:var(--v3-sub);padding:8px 0 2px;font-weight:700;cursor:pointer}.thread-toggle:hover{color:var(--v3-text)}.message-thread__replies{border-left:1px solid var(--v3-border)!important;margin-left:18px!important;padding-left:16px!important}.v3-status{position:fixed;z-index:40;left:0;right:0;bottom:0;height:38px;display:flex;align-items:center;padding:0 max(18px,calc((100vw - 980px)/2));background:var(--v3-panel);border-top:1px solid var(--v3-border);font-size:12px;color:var(--v3-sub);cursor:pointer}.v3-status-detail{position:fixed;z-index:39;left:50%;bottom:38px;transform:translateX(-50%);width:min(760px,calc(100% - 28px));max-height:45vh;overflow:auto;background:var(--v3-panel);border:1px solid var(--v3-border);border-bottom:0;border-radius:16px 16px 0 0;padding:16px;box-shadow:var(--v3-shadow);font:12px/1.55 var(--mono);white-space:pre-wrap}.v3-start{max-width:520px;margin:12vh auto;padding:28px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:24px;box-shadow:var(--v3-shadow)}.v3-start h1{margin:0 0 8px;font-size:32px;letter-spacing:-.04em}.v3-start p{color:var(--v3-sub);line-height:1.5}.v3-start-grid{display:grid;gap:12px}.v3-start input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-open{height:48px;border:0;border-radius:12px;background:var(--v3-text);color:var(--v3-bg);font-weight:800;cursor:pointer}.v3-blocks{display:grid;gap:8px}.v3-block{background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:14px;padding:12px 14px}.v3-block summary{cursor:pointer;font-weight:750}.v3-block pre{overflow:auto;color:var(--v3-sub);white-space:pre-wrap}.message-inline-media{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:10px}.message-inline-media:has(>a:only-child){grid-template-columns:minmax(0,1fr)}.message-inline-media a{display:block;overflow:hidden;border-radius:12px;border:1px solid var(--v3-border);background:var(--v3-bg)}.message-inline-media img{display:block;width:100%;max-height:520px;object-fit:contain;background:var(--v3-bg)}@media(max-width:520px){.message-inline-media{grid-template-columns:1fr}.message-inline-media img{max-height:70vh}} +html,body{background:var(--v3-bg)!important;color:var(--v3-text)!important}.viewer-app{min-height:100vh;background:var(--v3-bg);padding-bottom:54px}.viewer-toolbar{position:sticky;top:0;z-index:30;height:64px;padding:0 max(18px,calc((100vw - 980px)/2));display:flex;align-items:center;gap:10px;background:color-mix(in srgb,var(--v3-bg) 88%,transparent);backdrop-filter:blur(18px);border-bottom:1px solid var(--v3-border);overflow:visible!important} +.v3-brand{font-weight:800;letter-spacing:-.03em;margin-right:auto}.v3-tabs{display:flex;padding:4px;background:var(--v3-hover);border-radius:12px}.v3-tab{border:0;background:transparent;color:var(--v3-sub);padding:8px 13px;border-radius:9px;font-weight:700;cursor:pointer}.v3-tab.is-active{background:var(--v3-panel);color:var(--v3-text);box-shadow:0 1px 5px rgba(0,0,0,.08)}.toolbar-icon{background:var(--v3-panel)!important;color:var(--v3-text)!important;border:1px solid var(--v3-border)!important;border-radius:11px!important;width:40px;height:40px}.v3-menu{position:absolute;right:max(18px,calc((100vw - 980px)/2));top:58px;width:min(340px,calc(100vw - 28px));padding:14px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:16px;box-shadow:var(--v3-shadow);z-index:1000}.v3-menu label{color:var(--v3-text)}.v3-menu input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-switch{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 2px 14px}.viewer-main{width:min(860px,calc(100% - 28px));margin:22px auto!important}.mobile-card,.message-bubble,.channel-row{background:var(--v3-panel)!important;color:var(--v3-text)!important;border-color:var(--v3-border)!important;box-shadow:none!important}.message-bubble__meta,.channel-row__meta,.channel-row__preview,.channel-head p{color:var(--v3-sub)!important}.thread-toggle{border:0;background:transparent;color:var(--v3-sub);padding:8px 0 2px;font-weight:700;cursor:pointer}.thread-toggle:hover{color:var(--v3-text)}.message-thread__replies{border-left:1px solid var(--v3-border)!important;margin-left:18px!important;padding-left:16px!important}.v3-status{position:fixed;z-index:40;left:0;right:0;bottom:0;height:38px;display:flex;align-items:center;padding:0 max(18px,calc((100vw - 980px)/2));background:var(--v3-panel);border-top:1px solid var(--v3-border);font-size:12px;color:var(--v3-sub);cursor:pointer}.v3-status-detail{position:fixed;z-index:39;left:50%;bottom:38px;transform:translateX(-50%);width:min(760px,calc(100% - 28px));max-height:45vh;overflow:auto;background:var(--v3-panel);border:1px solid var(--v3-border);border-bottom:0;border-radius:16px 16px 0 0;padding:16px;box-shadow:var(--v3-shadow);font:12px/1.55 var(--mono);white-space:pre-wrap}.v3-start{max-width:520px;margin:12vh auto;padding:28px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:24px;box-shadow:var(--v3-shadow)}.v3-start h1{margin:0 0 8px;font-size:32px;letter-spacing:-.04em}.v3-start p{color:var(--v3-sub);line-height:1.5}.v3-start-grid{display:grid;gap:12px}.v3-start input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-open{height:48px;border:0;border-radius:12px;background:var(--v3-text);color:var(--v3-bg);font-weight:800;cursor:pointer}.v3-blocks{display:grid;gap:8px}.v3-block{background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:14px;padding:12px 14px}.v3-block summary{cursor:pointer;font-weight:750}.v3-block pre{overflow:auto;color:var(--v3-sub);white-space:pre-wrap}.message-inline-media{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:10px}.message-inline-media:has(>a:only-child){grid-template-columns:minmax(0,1fr)}.message-inline-media a{display:block;overflow:hidden;border-radius:12px;border:1px solid var(--v3-border);background:var(--v3-bg)}.message-inline-media img{display:block;width:100%;max-height:520px;object-fit:contain;background:var(--v3-bg)}@media(max-width:520px){.message-inline-media{grid-template-columns:1fr}.message-inline-media img{max-height:70vh}} .verification-view{position:fixed;inset:0;z-index:80;background:var(--v3-bg);color:var(--v3-text);overflow:auto;padding:0 0 70px} .verification-head{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:12px;padding:14px max(16px,calc((100vw - 900px)/2));background:var(--v3-panel);border-bottom:1px solid var(--v3-border)} .verification-head h2{margin:0;font-size:18px}.verification-body{width:min(900px,calc(100% - 28px));margin:20px auto;display:grid;gap:14px} @@ -2288,7 +2288,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x async function* queryArweaveChannelPages(gatewayBaseUrl, blockchainKey, channelName) { const gateway = baseUrl(gatewayBaseUrl); const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey); - // Author-first: channel is a local view filter, never an Arweave query filter. + // Author-first and transport-agnostic (Turbo/direct Arweave): discover user DataItems by owner + App=test5590; channel/c_test5590 is only a local view filter. const tags = [{ name: 'App', values: [SHINE_ARWEAVE_APP_TAG] }]; const query = `query($owners:[String!],$tags:[TagFilter!],$after:String){transactions(first:100,after:$after,owners:$owners,tags:$tags,sort:HEIGHT_DESC){pageInfo{hasNextPage}edges{cursor node{id owner{address key} block{height timestamp} bundledIn{id} tags{name value}}}}}`; let after = null; diff --git a/shine-solana-arweave-viewer-v2/README.md b/shine-solana-arweave-viewer-v2/README.md index 21236b1a..48c54e9c 100644 --- a/shine-solana-arweave-viewer-v2/README.md +++ b/shine-solana-arweave-viewer-v2/README.md @@ -32,7 +32,7 @@ index.html?key=&channel=books 1. Если передан `login`, вычисляет PDA `user_login=` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`. 2. Если передан `key`, использует его напрямую и не обращается к Solana. 3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`. -4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c=`. +4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c_test5590=`. 5. Загружает payload каждого DataItem через gateway `/`. 6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes). 7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1. @@ -40,6 +40,6 @@ index.html?key=&channel=books ## Важно -При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально. +При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c_test5590`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально. Старый `shine-solana-arweave-viewer/` не изменяется; новая версия лежит отдельно в `shine-solana-arweave-viewer-v2/`. diff --git a/shine-solana-arweave-viewer-v2/index.html b/shine-solana-arweave-viewer-v2/index.html index 9a598cc7..42f16135 100644 --- a/shine-solana-arweave-viewer-v2/index.html +++ b/shine-solana-arweave-viewer-v2/index.html @@ -2272,7 +2272,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x const gateway = baseUrl(gatewayBaseUrl); const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey); const tags = [{ name: 'App', values: [SHINE_ARWEAVE_APP_TAG] }]; - if (channelName) tags.push({ name: 'c', values: [channelName] }); + if (channelName) tags.push({ name: 'c_test5590', values: [channelName] }); const query = `query($owners:[String!],$tags:[TagFilter!],$after:String){transactions(first:100,after:$after,owners:$owners,tags:$tags,sort:HEIGHT_ASC){pageInfo{hasNextPage}edges{cursor node{id owner{address key} block{height timestamp} bundledIn{id} tags{name value}}}}}`; let after = null; const items = []; diff --git a/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md b/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md index 652117f7..26b2ecaa 100644 --- a/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md +++ b/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md @@ -6,7 +6,7 @@ Viewer рассчитан на новый архив SHiNE: **один поль `?login=&channel=` -Viewer читает Solana PDA пользователя, получает `blockchainKey`, затем ищет Arweave DataItem этого владельца с тегами `App=test5590` и `c=`. +Viewer читает Solana PDA пользователя, получает `blockchainKey`, затем ищет Arweave DataItem этого владельца с тегами `App=test5590` и `c_test5590=`. ### Режим 2: key + channel diff --git a/shine-solana-arweave-viewer-v3/README.md b/shine-solana-arweave-viewer-v3/README.md index eccebca9..4e9c5523 100644 --- a/shine-solana-arweave-viewer-v3/README.md +++ b/shine-solana-arweave-viewer-v3/README.md @@ -32,7 +32,7 @@ index.html?key=&channel=books 1. Если передан `login`, вычисляет PDA `user_login=` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`. 2. Если передан `key`, использует его напрямую и не обращается к Solana. 3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`. -4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c=`. +4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c_test5590=`. 5. Загружает payload каждого DataItem через gateway `/`. 6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes). 7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1. @@ -40,7 +40,7 @@ index.html?key=&channel=books ## Важно -При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально. +При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c_test5590`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально. Старый `shine-solana-arweave-viewer/` не изменяется; новая версия лежит отдельно в `shine-solana-arweave-viewer-v2/`. diff --git a/shine-solana-arweave-viewer-v3/index.html b/shine-solana-arweave-viewer-v3/index.html index 446463c4..9d1a5efc 100644 --- a/shine-solana-arweave-viewer-v3/index.html +++ b/shine-solana-arweave-viewer-v3/index.html @@ -2285,7 +2285,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x const gateway = baseUrl(gatewayBaseUrl); const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey); const tags = [{ name: 'App', values: [SHINE_ARWEAVE_APP_TAG] }]; - if (channelName) tags.push({ name: 'c', values: [channelName] }); + if (channelName) tags.push({ name: 'c_test5590', values: [channelName] }); const query = `query($owners:[String!],$tags:[TagFilter!],$after:String){transactions(first:100,after:$after,owners:$owners,tags:$tags,sort:HEIGHT_DESC){pageInfo{hasNextPage}edges{cursor node{id owner{address key} block{height timestamp} bundledIn{id} tags{name value}}}}}`; let after = null; let page = 0;