SHA256
Compare commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
8c4e404f23 | ||
|
|
e8a4713f6e | ||
|
|
b4b23afc10 | ||
|
|
8f32e82d14 | ||
|
|
cf391bdd80 | ||
|
|
7120d6a9db | ||
|
|
4d80ab639e | ||
|
|
95bbd2852e | ||
|
|
4b0a934e51 | ||
|
|
ee5d86003b | ||
|
|
bc11b60147 | ||
|
|
801a4697ea | ||
|
|
628a970e6f | ||
|
|
566ea942dc | ||
|
|
e047c2d54e | ||
|
|
3fdd3d22e2 | ||
|
|
08e0fa9206 | ||
|
|
ed9e6ce676 | ||
|
|
a7bf07130e | ||
|
|
7c31662698 | ||
|
|
15df06a0bc | ||
|
|
47b5f8db7b | ||
|
|
0e763987aa | ||
|
|
37e8ea27d0 | ||
|
|
67d63256c2 | ||
|
|
ee185cf20a | ||
|
|
3552e05e4c | ||
|
|
735bb55b78 | ||
|
|
60f8a50a97 | ||
|
|
ffd01814b0 | ||
|
|
c511910b8f | ||
|
|
5c38f8f0d8 | ||
|
|
d512442325 | ||
|
|
43f54c90d2 | ||
|
|
57a99b478a | ||
|
|
3f402fbde7 | ||
|
|
98140c1f71 | ||
|
|
f00de85e38 | ||
|
|
b686b12c6a | ||
|
|
e4b903b13c | ||
|
|
72f576a70e | ||
|
|
c4f2c7be63 | ||
|
|
75b75fd223 | ||
|
|
391b18a5ac | ||
|
|
8de0f8e87c | ||
|
|
adb0b76559 | ||
|
|
b6df25c02c | ||
|
|
620186db1c | ||
|
|
21fac8046e | ||
|
|
de7c35f587 | ||
|
|
a23c979045 | ||
|
|
13da7287b1 | ||
|
|
e8398df9fc | ||
|
|
8303682bd7 | ||
|
|
8125d1a124 | ||
|
|
110ff5e12e | ||
|
|
24afcba20a | ||
|
|
d5f41d12e8 | ||
|
|
d640dd66e2 | ||
|
|
05bd9f57d6 | ||
|
|
60ab476ec7 | ||
|
|
bcae285ab3 | ||
|
|
cd67176b41 | ||
|
|
98eba54621 | ||
|
|
c530627078 | ||
|
|
f961947e1e | ||
|
|
e9e6628b21 | ||
|
|
c7684d6fe1 | ||
|
|
7d475ab403 | ||
|
|
143adcbbe4 | ||
|
|
ca8b6a33ba | ||
|
|
2f73cdb0f9 | ||
|
|
d1e06d9ef1 | ||
|
|
0ed78e2202 | ||
|
|
64818c586b | ||
|
|
809c216a5c | ||
|
|
415a40e436 | ||
|
|
d233f1d73f |
+11
-1
@@ -36,7 +36,12 @@ public final class BodyRecordParser {
|
|||||||
case TextBody.KEY -> {
|
case TextBody.KEY -> {
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
yield new TextLineBody(subType, version, bodyBytes);
|
yield new TextLineBody(subType, version, bodyBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +50,17 @@ public final class BodyRecordParser {
|
|||||||
yield new TextReplyBody(subType, version, bodyBytes);
|
yield new TextReplyBody(subType, version, bodyBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (st == (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
||||||
|
yield new TextRatingBody(subType, version, bodyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
|
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
|
||||||
}
|
}
|
||||||
|
|
||||||
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
|
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
|
||||||
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
|
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
|
||||||
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
|
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
|
||||||
|
case StatusActionBody.KEY -> new StatusActionBody(subType, version, bodyBytes);
|
||||||
|
|
||||||
default -> throw new IllegalArgumentException(String.format(
|
default -> throw new IllegalArgumentException(String.format(
|
||||||
"Unknown body type/version from header: type=%d ver=%d subType=%d",
|
"Unknown body type/version from header: type=%d ver=%d subType=%d",
|
||||||
|
|||||||
@@ -64,11 +64,29 @@ public final class MsgSubType {
|
|||||||
*/
|
*/
|
||||||
public static final short TEXT_EDIT_REPLY = 21;
|
public static final short TEXT_EDIT_REPLY = 21;
|
||||||
|
|
||||||
|
/** RATING — target-based отзыв на конкретный блок. */
|
||||||
|
public static final short TEXT_RATING = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REPOST — репост сообщения в линии канала.
|
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
||||||
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||||
*/
|
*/
|
||||||
public static final short TEXT_REPOST = 30;
|
public static final short TEXT_REPOST = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CHANNEL_META — скрытый технический снимок профиля канала.
|
||||||
|
* Имеет hasLine, использует body как POST: line-поля + UTF-8 текст.
|
||||||
|
*/
|
||||||
|
public static final short TEXT_CHANNEL_META = 90;
|
||||||
|
|
||||||
|
/** ENTRYPOINT — входная страница канала (line-based). */
|
||||||
|
public static final short TEXT_ENTRYPOINT = 100;
|
||||||
|
/** EXERCISE — упражнение/комплекс (line-based). */
|
||||||
|
public static final short TEXT_EXERCISE = 110;
|
||||||
|
/** SERVICE — услуга/процедура (line-based). */
|
||||||
|
public static final short TEXT_SERVICE = 120;
|
||||||
|
/** COURSE — курс (line-based). */
|
||||||
|
public static final short TEXT_COURSE = 130;
|
||||||
|
|
||||||
/* ===================== REACTION (msg_type=2) ===================== */
|
/* ===================== REACTION (msg_type=2) ===================== */
|
||||||
|
|
||||||
@@ -138,4 +156,16 @@ public final class MsgSubType {
|
|||||||
|
|
||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
public static final short USER_PARAM_TEXT_TEXT = 1;
|
public static final short USER_PARAM_TEXT_TEXT = 1;
|
||||||
|
|
||||||
|
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
|
||||||
|
|
||||||
|
public static final short STATUS_DONE_ONCE = 10;
|
||||||
|
public static final short STATUS_LEARNED = 20;
|
||||||
|
public static final short STATUS_SERVICE_PASSED = 30;
|
||||||
|
public static final short STATUS_CONFIRMED = 100;
|
||||||
|
public static final short STATUS_INTERESTED = 110;
|
||||||
|
public static final short STATUS_STARTED = 120;
|
||||||
|
public static final short STATUS_IN_STUDY = 130;
|
||||||
|
public static final short STATUS_ABANDONED = 140;
|
||||||
|
public static final short STATUS_COMPLETED = 150;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-7
@@ -19,7 +19,7 @@ import java.util.Objects;
|
|||||||
* [1] channelNameLen
|
* [1] channelNameLen
|
||||||
* [N] channelName UTF-8
|
* [N] channelName UTF-8
|
||||||
* [2] channelDescriptionLen
|
* [2] channelDescriptionLen
|
||||||
* [M] channelDescription UTF-8 (0..200 bytes)
|
* [M] channelDescription UTF-8 (0..2048 bytes)
|
||||||
* [2] channelTypeCode (uint16)
|
* [2] channelTypeCode (uint16)
|
||||||
* [2] channelTypeVersion (uint16)
|
* [2] channelTypeVersion (uint16)
|
||||||
*/
|
*/
|
||||||
@@ -38,7 +38,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
|
|
||||||
private static final byte[] ZERO32 = new byte[32];
|
private static final byte[] ZERO32 = new byte[32];
|
||||||
private static final int MAX_NAME_LENGTH = 32;
|
private static final int MAX_NAME_LENGTH = 32;
|
||||||
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
|
public static final int MAX_DESCRIPTION_UTF8_LEN = 2048;
|
||||||
|
|
||||||
public final short subType;
|
public final short subType;
|
||||||
public final short version;
|
public final short version;
|
||||||
@@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
|
|
||||||
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
||||||
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
|
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
|
||||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||||
}
|
}
|
||||||
if (bb.remaining() != descriptionLen + 4) {
|
if (bb.remaining() != descriptionLen + 4) {
|
||||||
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
|
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
|
||||||
@@ -145,7 +145,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
String normalizedDescription = normalizeDescription(channelDescription);
|
String normalizedDescription = normalizeDescription(channelDescription);
|
||||||
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
||||||
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||||
}
|
}
|
||||||
|
|
||||||
int typeCode = Short.toUnsignedInt(channelTypeCode);
|
int typeCode = Short.toUnsignedInt(channelTypeCode);
|
||||||
@@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
|
|
||||||
private static String normalizeDescription(String value) {
|
private static String normalizeDescription(String value) {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
return value.trim().replaceAll("\\s+", " ");
|
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
}
|
}
|
||||||
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
||||||
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||||
}
|
}
|
||||||
|
|
||||||
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
|
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
|
||||||
@@ -204,4 +204,3 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
@Override
|
@Override
|
||||||
public int lineSeq() { return thisLineNumber; }
|
public int lineSeq() { return thisLineNumber; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
package blockchain.body;
|
||||||
|
|
||||||
|
import blockchain.MsgSubType;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.CharacterCodingException;
|
||||||
|
import java.nio.charset.CodingErrorAction;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatusActionBody — type=5, ver=1.
|
||||||
|
*
|
||||||
|
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
|
||||||
|
*
|
||||||
|
* Формат bodyBytes (BigEndian):
|
||||||
|
* [1] toBlockchainNameLen (uint8)
|
||||||
|
* [N] toBlockchainName UTF-8
|
||||||
|
* [4] toBlockGlobalNumber
|
||||||
|
* [32] toBlockHash32
|
||||||
|
* [2] textLenBytes (uint16)
|
||||||
|
* [M] text UTF-8
|
||||||
|
*/
|
||||||
|
public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||||
|
|
||||||
|
public static final short TYPE = 5;
|
||||||
|
public static final short VER = 1;
|
||||||
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
|
public final short subType;
|
||||||
|
public final short version;
|
||||||
|
public final String toBlockchainName;
|
||||||
|
public final int toBlockGlobalNumber;
|
||||||
|
public final byte[] toBlockHash32;
|
||||||
|
public final String message;
|
||||||
|
|
||||||
|
public StatusActionBody(short subType, short version, byte[] bodyBytes) {
|
||||||
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
this.subType = subType;
|
||||||
|
this.version = version;
|
||||||
|
|
||||||
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("StatusActionBody version must be 1, got=" + (version & 0xFFFF));
|
||||||
|
}
|
||||||
|
if (!isSupportedSubType(subType)) {
|
||||||
|
throw new IllegalArgumentException("Unsupported STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
|
||||||
|
|
||||||
|
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
|
||||||
|
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
||||||
|
|
||||||
|
byte[] nameBytes = new byte[nameLen];
|
||||||
|
bb.get(nameBytes);
|
||||||
|
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||||
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
|
this.toBlockHash32 = new byte[32];
|
||||||
|
bb.get(this.toBlockHash32);
|
||||||
|
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
||||||
|
|
||||||
|
ensureNoTail(bb, "StatusActionBody");
|
||||||
|
}
|
||||||
|
|
||||||
|
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
|
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||||
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
Objects.requireNonNull(message, "message == null");
|
||||||
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
|
||||||
|
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
|
this.subType = subType;
|
||||||
|
this.version = VER;
|
||||||
|
this.toBlockchainName = toBlockchainName;
|
||||||
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public StatusActionBody check() {
|
||||||
|
if (!isSupportedSubType(subType)) {
|
||||||
|
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||||
|
}
|
||||||
|
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
|
||||||
|
}
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
||||||
|
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
}
|
||||||
|
if (message == null) throw new IllegalArgumentException("message is null");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] toBytes() {
|
||||||
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
|
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||||
|
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||||
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.put((byte) nameUtf8.length);
|
||||||
|
bb.put(nameUtf8);
|
||||||
|
bb.putInt(toBlockGlobalNumber);
|
||||||
|
bb.put(toBlockHash32);
|
||||||
|
bb.putShort((short) msgUtf8.length);
|
||||||
|
bb.put(msgUtf8);
|
||||||
|
return bb.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public String toBchName() { return toBlockchainName; }
|
||||||
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
|
private static boolean isSupportedSubType(short subType) {
|
||||||
|
int st = subType & 0xFFFF;
|
||||||
|
return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) {
|
||||||
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
|
if (len == 0) return "";
|
||||||
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
|
byte[] bytes = new byte[len];
|
||||||
|
bb.get(bytes);
|
||||||
|
|
||||||
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
|
} catch (CharacterCodingException e) {
|
||||||
|
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
||||||
|
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
||||||
|
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-13
@@ -16,11 +16,16 @@ import java.util.Objects;
|
|||||||
* subType:
|
* subType:
|
||||||
* - POST (10)
|
* - POST (10)
|
||||||
* - EDIT_POST (11)
|
* - EDIT_POST (11)
|
||||||
* - REPOST (30)
|
* - REPOST (50)
|
||||||
|
* - CHANNEL_META (90)
|
||||||
|
* - ENTRYPOINT (100)
|
||||||
|
* - EXERCISE (110)
|
||||||
|
* - SERVICE (120)
|
||||||
|
* - COURSE (130)
|
||||||
*
|
*
|
||||||
* Формат bodyBytes (BigEndian):
|
* Формат bodyBytes (BigEndian):
|
||||||
*
|
*
|
||||||
* POST:
|
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
* [4] prevLineNumber
|
* [4] prevLineNumber
|
||||||
* [32] prevLineHash32
|
* [32] prevLineHash32
|
||||||
@@ -89,8 +94,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
int st = this.subType & 0xFFFF;
|
int st = this.subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
|
||||||
}
|
}
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
@@ -133,7 +143,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
|
this.message = readStrictUtf8Len16(bb, "TextLineBody text",
|
||||||
|
st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF));
|
||||||
|
|
||||||
ensureNoTail(bb, "TextLineBody");
|
ensureNoTail(bb, "TextLineBody");
|
||||||
}
|
}
|
||||||
@@ -155,12 +166,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) {
|
if (requiresNonBlankMessage(st) && message.isBlank()) {
|
||||||
throw new IllegalArgumentException("message is blank");
|
throw new IllegalArgumentException("message is blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +222,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF))
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF))
|
||||||
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
@@ -231,10 +252,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
||||||
} else {
|
} else {
|
||||||
if (message == null || message.isBlank())
|
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
|
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
|
||||||
|
} else if (requiresNonBlankMessage(st) && (message == null || message.isBlank())) {
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
throw new IllegalArgumentException("Text message is blank");
|
||||||
|
} else if (message == null) {
|
||||||
|
throw new IllegalArgumentException("Text message is null");
|
||||||
|
}
|
||||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||||
throw new IllegalArgumentException("POST must not contain target fields");
|
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
@@ -246,12 +272,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) {
|
if (requiresNonBlankMessage(st) && msgUtf8.length == 0) {
|
||||||
throw new IllegalArgumentException("Text payload is empty");
|
throw new IllegalArgumentException("Text payload is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
int cap;
|
int cap;
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
// EDIT_POST
|
// EDIT_POST
|
||||||
@@ -310,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean requiresNonBlankMessage(int st) {
|
||||||
|
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) {
|
if (len == 0) {
|
||||||
|
|||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
package blockchain.body;
|
||||||
|
|
||||||
|
import blockchain.MsgSubType;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.CharacterCodingException;
|
||||||
|
import java.nio.charset.CodingErrorAction;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TextRatingBody — type=1, ver=1.
|
||||||
|
*
|
||||||
|
* subType:
|
||||||
|
* - RATING (30)
|
||||||
|
*
|
||||||
|
* Формат bodyBytes (BigEndian):
|
||||||
|
* [1] toBlockchainNameLen (uint8)
|
||||||
|
* [N] toBlockchainName UTF-8
|
||||||
|
* [4] toBlockGlobalNumber
|
||||||
|
* [32] toBlockHash32
|
||||||
|
* [2] textLenBytes (uint16)
|
||||||
|
* [M] text UTF-8
|
||||||
|
*/
|
||||||
|
public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||||
|
|
||||||
|
public static final short TYPE = 1;
|
||||||
|
public static final short VER = 1;
|
||||||
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
|
public final short subType;
|
||||||
|
public final short version;
|
||||||
|
public final String toBlockchainName;
|
||||||
|
public final int toBlockGlobalNumber;
|
||||||
|
public final byte[] toBlockHash32;
|
||||||
|
public final String message;
|
||||||
|
|
||||||
|
public TextRatingBody(short subType, short version, byte[] bodyBytes) {
|
||||||
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
this.subType = subType;
|
||||||
|
this.version = version;
|
||||||
|
|
||||||
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("TextRatingBody version must be 1, got=" + (version & 0xFFFF));
|
||||||
|
}
|
||||||
|
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING");
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
|
||||||
|
|
||||||
|
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
|
||||||
|
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
|
||||||
|
|
||||||
|
byte[] nameBytes = new byte[nameLen];
|
||||||
|
bb.get(nameBytes);
|
||||||
|
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||||
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
|
this.toBlockHash32 = new byte[32];
|
||||||
|
bb.get(this.toBlockHash32);
|
||||||
|
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
||||||
|
|
||||||
|
ensureNoTail(bb, "TextRatingBody");
|
||||||
|
}
|
||||||
|
|
||||||
|
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
|
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||||
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
Objects.requireNonNull(message, "message == null");
|
||||||
|
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
|
|
||||||
|
this.subType = MsgSubType.TEXT_RATING;
|
||||||
|
this.version = VER;
|
||||||
|
this.toBlockchainName = toBlockchainName;
|
||||||
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TextRatingBody check() {
|
||||||
|
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
|
||||||
|
}
|
||||||
|
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("RATING toBlockchainName is blank");
|
||||||
|
}
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
||||||
|
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
}
|
||||||
|
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] toBytes() {
|
||||||
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||||
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
|
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||||
|
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||||
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.put((byte) nameUtf8.length);
|
||||||
|
bb.put(nameUtf8);
|
||||||
|
bb.putInt(toBlockGlobalNumber);
|
||||||
|
bb.put(toBlockHash32);
|
||||||
|
bb.putShort((short) msgUtf8.length);
|
||||||
|
bb.put(msgUtf8);
|
||||||
|
return bb.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public String toBchName() { return toBlockchainName; }
|
||||||
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
|
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
|
||||||
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
|
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||||
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
|
byte[] bytes = new byte[len];
|
||||||
|
bb.get(bytes);
|
||||||
|
|
||||||
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
|
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||||
|
return s;
|
||||||
|
} catch (CharacterCodingException e) {
|
||||||
|
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
||||||
|
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
||||||
|
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,12 @@ public final class DatabaseInitializer {
|
|||||||
public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version";
|
public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version";
|
||||||
public static final int SCHEMA_VERSION_1 = 1;
|
public static final int SCHEMA_VERSION_1 = 1;
|
||||||
public static final int SCHEMA_VERSION_2 = 2;
|
public static final int SCHEMA_VERSION_2 = 2;
|
||||||
|
public static final int SCHEMA_VERSION_3 = 3;
|
||||||
|
public static final int SCHEMA_VERSION_4 = 4;
|
||||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
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_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
|
||||||
|
|
||||||
private DatabaseInitializer() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -28,7 +32,13 @@ public final class DatabaseInitializer {
|
|||||||
public static final short TEXT_EDIT_POST = 11;
|
public static final short TEXT_EDIT_POST = 11;
|
||||||
public static final short TEXT_REPLY = 20;
|
public static final short TEXT_REPLY = 20;
|
||||||
public static final short TEXT_EDIT_REPLY = 21;
|
public static final short TEXT_EDIT_REPLY = 21;
|
||||||
public static final short TEXT_REPOST = 30;
|
public static final short TEXT_RATING = 30;
|
||||||
|
public static final short TEXT_REPOST = 50;
|
||||||
|
public static final short TEXT_CHANNEL_META = 90;
|
||||||
|
public static final short TEXT_ENTRYPOINT = 100;
|
||||||
|
public static final short TEXT_EXERCISE = 110;
|
||||||
|
public static final short TEXT_SERVICE = 120;
|
||||||
|
public static final short TEXT_COURSE = 130;
|
||||||
|
|
||||||
public static final short REACTION_LIKE = 1;
|
public static final short REACTION_LIKE = 1;
|
||||||
public static final short REACTION_UNLIKE = 2;
|
public static final short REACTION_UNLIKE = 2;
|
||||||
@@ -82,6 +92,14 @@ public final class DatabaseInitializer {
|
|||||||
int currentVersion = readCurrentSchemaVersion(conn);
|
int currentVersion = readCurrentSchemaVersion(conn);
|
||||||
if (currentVersion < SCHEMA_VERSION_2) {
|
if (currentVersion < SCHEMA_VERSION_2) {
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_2;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_3) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_3;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_4) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,23 @@ public final class MsgSubType {
|
|||||||
/** EDIT_REPLY — редактирование исходного ответа. */
|
/** EDIT_REPLY — редактирование исходного ответа. */
|
||||||
public static final short TEXT_EDIT_REPLY = 21;
|
public static final short TEXT_EDIT_REPLY = 21;
|
||||||
|
|
||||||
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
|
/** RATING — target-based отзыв на конкретный блок. */
|
||||||
public static final short TEXT_REPOST = 30;
|
public static final short TEXT_RATING = 30;
|
||||||
|
|
||||||
|
/** REPOST — отложенная будущая заготовка репоста сообщения в линии канала. */
|
||||||
|
public static final short TEXT_REPOST = 50;
|
||||||
|
|
||||||
|
/** CHANNEL_META — скрытый технический снимок профиля канала. */
|
||||||
|
public static final short TEXT_CHANNEL_META = 90;
|
||||||
|
|
||||||
|
/** ENTRYPOINT — входная страница канала (line-based). */
|
||||||
|
public static final short TEXT_ENTRYPOINT = 100;
|
||||||
|
/** EXERCISE — упражнение/комплекс (line-based). */
|
||||||
|
public static final short TEXT_EXERCISE = 110;
|
||||||
|
/** SERVICE — услуга/процедура (line-based). */
|
||||||
|
public static final short TEXT_SERVICE = 120;
|
||||||
|
/** COURSE — курс (line-based). */
|
||||||
|
public static final short TEXT_COURSE = 130;
|
||||||
|
|
||||||
/* ===================== REACTION (msg_type=2) ===================== */
|
/* ===================== REACTION (msg_type=2) ===================== */
|
||||||
|
|
||||||
@@ -120,6 +135,18 @@ public final class MsgSubType {
|
|||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
public static final short USER_PARAM_TEXT_TEXT = 1;
|
public static final short USER_PARAM_TEXT_TEXT = 1;
|
||||||
|
|
||||||
|
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
|
||||||
|
|
||||||
|
public static final short STATUS_DONE_ONCE = 10;
|
||||||
|
public static final short STATUS_LEARNED = 20;
|
||||||
|
public static final short STATUS_SERVICE_PASSED = 30;
|
||||||
|
public static final short STATUS_CONFIRMED = 100;
|
||||||
|
public static final short STATUS_INTERESTED = 110;
|
||||||
|
public static final short STATUS_STARTED = 120;
|
||||||
|
public static final short STATUS_IN_STUDY = 130;
|
||||||
|
public static final short STATUS_ABANDONED = 140;
|
||||||
|
public static final short STATUS_COMPLETED = 150;
|
||||||
|
|
||||||
/* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */
|
/* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */
|
||||||
// Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
|
// Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
|
||||||
// не трогая уже занятые коды.
|
// не трогая уже занятые коды.
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ public final class ChannelNameStateDAO {
|
|||||||
channel_type_version,
|
channel_type_version,
|
||||||
channel_root_block_number,
|
channel_root_block_number,
|
||||||
channel_root_block_hash,
|
channel_root_block_hash,
|
||||||
created_at_ms
|
created_at_ms,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
ava_ar,
|
||||||
|
ava_sha256,
|
||||||
|
ava_size,
|
||||||
|
meta_updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, entry.getSlug());
|
ps.setString(1, entry.getSlug());
|
||||||
@@ -79,6 +83,34 @@ public final class ChannelNameStateDAO {
|
|||||||
ps.setInt(8, entry.getChannelRootBlockNumber());
|
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||||
ps.setBytes(9, entry.getChannelRootBlockHash());
|
ps.setBytes(9, entry.getChannelRootBlockHash());
|
||||||
ps.setLong(10, entry.getCreatedAtMs());
|
ps.setLong(10, entry.getCreatedAtMs());
|
||||||
|
ps.setString(11, entry.getAvaAr() == null ? "" : entry.getAvaAr());
|
||||||
|
ps.setString(12, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
|
||||||
|
ps.setLong(13, Math.max(0L, entry.getAvaSize()));
|
||||||
|
ps.setLong(14, Math.max(0L, entry.getMetaUpdatedAtMs()));
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateMeta(Connection c, ChannelNameStateEntry entry) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE channel_names_state
|
||||||
|
SET display_name = ?,
|
||||||
|
channel_description = ?,
|
||||||
|
ava_ar = ?,
|
||||||
|
ava_sha256 = ?,
|
||||||
|
ava_size = ?,
|
||||||
|
meta_updated_at_ms = ?
|
||||||
|
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, entry.getDisplayName() == null ? "" : entry.getDisplayName());
|
||||||
|
ps.setString(2, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
|
||||||
|
ps.setString(3, entry.getAvaAr() == null ? "" : entry.getAvaAr());
|
||||||
|
ps.setString(4, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
|
||||||
|
ps.setLong(5, Math.max(0L, entry.getAvaSize()));
|
||||||
|
ps.setLong(6, Math.max(0L, entry.getMetaUpdatedAtMs()));
|
||||||
|
ps.setString(7, entry.getOwnerBlockchainName());
|
||||||
|
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ public final class CurrentUsersDAO {
|
|||||||
|
|
||||||
/** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */
|
/** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */
|
||||||
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
|
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
|
||||||
|
return searchByLoginPrefix(c, prefix, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поиск по префиксу с optional-фильтром server PDA. Соединение НЕ закрывает. */
|
||||||
|
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix, Boolean isServer) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
login,
|
login,
|
||||||
@@ -163,6 +168,7 @@ public final class CurrentUsersDAO {
|
|||||||
client_key
|
client_key
|
||||||
FROM %s
|
FROM %s
|
||||||
WHERE LOWER(login) LIKE ?
|
WHERE LOWER(login) LIKE ?
|
||||||
|
AND (? IS NULL OR is_server = ?)
|
||||||
ORDER BY login
|
ORDER BY login
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||||
@@ -171,6 +177,13 @@ public final class CurrentUsersDAO {
|
|||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, prefix.toLowerCase() + "%");
|
ps.setString(1, prefix.toLowerCase() + "%");
|
||||||
|
if (isServer == null) {
|
||||||
|
ps.setNull(2, Types.BOOLEAN);
|
||||||
|
ps.setNull(3, Types.BOOLEAN);
|
||||||
|
} else {
|
||||||
|
ps.setBoolean(2, isServer);
|
||||||
|
ps.setBoolean(3, isServer);
|
||||||
|
}
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) result.add(mapRow(rs));
|
while (rs.next()) result.add(mapRow(rs));
|
||||||
}
|
}
|
||||||
@@ -181,8 +194,13 @@ public final class CurrentUsersDAO {
|
|||||||
|
|
||||||
/** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */
|
/** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */
|
||||||
public List<CurrentUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
|
public List<CurrentUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
|
||||||
|
return searchByLoginPrefix(prefix, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поиск по префиксу без внешнего соединения с optional-фильтром server PDA. */
|
||||||
|
public List<CurrentUserEntry> searchByLoginPrefix(String prefix, Boolean isServer) throws SQLException {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
return searchByLoginPrefix(c, prefix);
|
return searchByLoginPrefix(c, prefix, isServer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.DmSyncPeerStateEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
public final class DmSyncPeerStateDAO {
|
||||||
|
private static volatile DmSyncPeerStateDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private DmSyncPeerStateDAO() {}
|
||||||
|
|
||||||
|
public static DmSyncPeerStateDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (DmSyncPeerStateDAO.class) {
|
||||||
|
if (instance == null) instance = new DmSyncPeerStateDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DmSyncPeerStateEntry getOrCreate(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||||
|
DmSyncPeerStateEntry existing = get(ownerLogin, remoteServerLogin);
|
||||||
|
if (existing != null) {
|
||||||
|
if (remoteServerUrl != null && !remoteServerUrl.isBlank()
|
||||||
|
&& !remoteServerUrl.equals(existing.getRemoteServerUrl())) {
|
||||||
|
touchRemoteUrl(ownerLogin, remoteServerLogin, remoteServerUrl);
|
||||||
|
existing.setRemoteServerUrl(remoteServerUrl);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
INSERT INTO dm_sync_peer_state (
|
||||||
|
owner_login, remote_server_login, remote_server_url,
|
||||||
|
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||||
|
last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||||
|
ps.setLong(4, nowMs);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
existing = get(ownerLogin, remoteServerLogin);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new SQLException("Failed to create dm_sync_peer_state row");
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateSuccess(
|
||||||
|
String ownerLogin,
|
||||||
|
String remoteServerLogin,
|
||||||
|
String remoteServerUrl,
|
||||||
|
long cursorStoredAtMs,
|
||||||
|
String cursorMessageKey,
|
||||||
|
boolean bootstrapCompleted
|
||||||
|
) throws SQLException {
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
INSERT INTO dm_sync_peer_state (
|
||||||
|
owner_login, remote_server_login, remote_server_url,
|
||||||
|
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||||
|
last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
|
||||||
|
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||||
|
remote_server_url = EXCLUDED.remote_server_url,
|
||||||
|
cursor_stored_at_ms = EXCLUDED.cursor_stored_at_ms,
|
||||||
|
cursor_message_key = EXCLUDED.cursor_message_key,
|
||||||
|
bootstrap_completed = EXCLUDED.bootstrap_completed,
|
||||||
|
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||||
|
ps.setLong(4, Math.max(0L, cursorStoredAtMs));
|
||||||
|
ps.setString(5, cursorMessageKey == null ? "" : cursorMessageKey);
|
||||||
|
ps.setBoolean(6, bootstrapCompleted);
|
||||||
|
ps.setLong(7, nowMs);
|
||||||
|
ps.setLong(8, nowMs);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
INSERT INTO dm_sync_peer_state (
|
||||||
|
owner_login, remote_server_login, remote_server_url,
|
||||||
|
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||||
|
last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
|
||||||
|
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||||
|
remote_server_url = EXCLUDED.remote_server_url,
|
||||||
|
last_error = EXCLUDED.last_error,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||||
|
ps.setString(4, truncate(error));
|
||||||
|
ps.setLong(5, nowMs);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DmSyncPeerStateEntry get(String ownerLogin, String remoteServerLogin) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT owner_login, remote_server_login, remote_server_url,
|
||||||
|
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||||
|
last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
FROM dm_sync_peer_state
|
||||||
|
WHERE LOWER(owner_login) = LOWER(?)
|
||||||
|
AND LOWER(remote_server_login) = LOWER(?)
|
||||||
|
LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
DmSyncPeerStateEntry e = new DmSyncPeerStateEntry();
|
||||||
|
e.setOwnerLogin(rs.getString("owner_login"));
|
||||||
|
e.setRemoteServerLogin(rs.getString("remote_server_login"));
|
||||||
|
e.setRemoteServerUrl(rs.getString("remote_server_url"));
|
||||||
|
e.setCursorStoredAtMs(rs.getLong("cursor_stored_at_ms"));
|
||||||
|
e.setCursorMessageKey(rs.getString("cursor_message_key"));
|
||||||
|
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
|
||||||
|
long lastSyncAt = rs.getLong("last_sync_at_ms");
|
||||||
|
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAt);
|
||||||
|
e.setLastError(rs.getString("last_error"));
|
||||||
|
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void touchRemoteUrl(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE dm_sync_peer_state
|
||||||
|
SET remote_server_url = ?, updated_at_ms = ?
|
||||||
|
WHERE LOWER(owner_login) = LOWER(?)
|
||||||
|
AND LOWER(remote_server_login) = LOWER(?)
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, remoteServerUrl);
|
||||||
|
ps.setLong(2, System.currentTimeMillis());
|
||||||
|
ps.setString(3, ownerLogin);
|
||||||
|
ps.setString(4, remoteServerLogin);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String clean = value.trim();
|
||||||
|
if (clean.length() <= 500) return clean;
|
||||||
|
return clean.substring(0, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -416,6 +416,72 @@ public final class SignedMessagesDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SyncBatch listSyncBatch(
|
||||||
|
String ownerLogin,
|
||||||
|
long afterStoredAtMs,
|
||||||
|
String afterMessageKey,
|
||||||
|
int limit,
|
||||||
|
int maxBytes
|
||||||
|
) throws Exception {
|
||||||
|
int safeLimit = Math.max(1, Math.min(limit, 500));
|
||||||
|
int sqlLimit = safeLimit + 1;
|
||||||
|
String safeAfterMessageKey = afterMessageKey == null ? "" : afterMessageKey;
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
|
FROM %s
|
||||||
|
WHERE (
|
||||||
|
LOWER(target_login) = LOWER(?)
|
||||||
|
OR (
|
||||||
|
message_type IN (5, 6, 7, 8)
|
||||||
|
AND (LOWER(from_login) = LOWER(?) OR LOWER(to_login) = LOWER(?))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
created_at_ms > ?
|
||||||
|
OR (created_at_ms = ? AND (? = '' OR message_key > ?))
|
||||||
|
)
|
||||||
|
ORDER BY created_at_ms ASC, message_key ASC
|
||||||
|
LIMIT ?
|
||||||
|
""".formatted(messagesTable());
|
||||||
|
List<SignedMessageEntry> out = new ArrayList<>();
|
||||||
|
boolean hasMore = false;
|
||||||
|
int usedBytes = 0;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, ownerLogin);
|
||||||
|
ps.setString(3, ownerLogin);
|
||||||
|
ps.setLong(4, Math.max(0L, afterStoredAtMs));
|
||||||
|
ps.setLong(5, Math.max(0L, afterStoredAtMs));
|
||||||
|
ps.setString(6, safeAfterMessageKey);
|
||||||
|
ps.setString(7, safeAfterMessageKey);
|
||||||
|
ps.setInt(8, sqlLimit);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
SignedMessageEntry row = mapRow(rs);
|
||||||
|
byte[] raw = row.getRawBlock();
|
||||||
|
int rowBytes = raw == null ? 0 : raw.length;
|
||||||
|
if (out.size() >= safeLimit) {
|
||||||
|
hasMore = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!out.isEmpty() && maxBytes > 0 && usedBytes + rowBytes > maxBytes) {
|
||||||
|
hasMore = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.add(row);
|
||||||
|
usedBytes += rowBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new SyncBatch(out, hasMore, usedBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException {
|
private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO %s (
|
INSERT INTO %s (
|
||||||
@@ -748,6 +814,8 @@ public final class SignedMessagesDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record SyncBatch(List<SignedMessageEntry> items, boolean hasMore, int rawBytes) {}
|
||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
private interface SqlWork<T> {
|
private interface SqlWork<T> {
|
||||||
T run() throws Exception;
|
T run() throws Exception;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import java.util.List;
|
|||||||
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
|
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
|
||||||
* - login цели (channelLogin)
|
* - login цели (channelLogin)
|
||||||
* - blockchainName цели (channelBchName)
|
* - blockchainName цели (channelBchName)
|
||||||
* - count публикаций (TEXT_POST)
|
* - count публикаций (видимые line-based TEXT-сообщения канала)
|
||||||
* - last publication: bytes оригинального блока (для timestamp)
|
* - last publication: bytes оригинального блока (для timestamp)
|
||||||
* - last publication: bytes актуального блока (edit или orig) — для текста превью
|
* - last publication: bytes актуального блока (edit или orig) — для текста превью
|
||||||
*
|
*
|
||||||
@@ -92,7 +92,7 @@ public final class SubscriptionsDAO {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
|
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
|
||||||
* - count публикаций (TEXT_POST)
|
* - count публикаций (видимые line-based TEXT-сообщения канала)
|
||||||
* - последнюю публикацию (orig bytes) + её edit (если есть)
|
* - последнюю публикацию (orig bytes) + её edit (если есть)
|
||||||
*
|
*
|
||||||
* Поведение при 0 публикаций:
|
* Поведение при 0 публикаций:
|
||||||
@@ -131,7 +131,7 @@ public final class SubscriptionsDAO {
|
|||||||
ON s.channel_login = b.login
|
ON s.channel_login = b.login
|
||||||
AND s.channel_bch_name = b.bch_name
|
AND s.channel_bch_name = b.bch_name
|
||||||
WHERE b.msg_type = ?
|
WHERE b.msg_type = ?
|
||||||
AND b.msg_sub_type IN (?, ?)
|
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||||
GROUP BY b.login, b.bch_name
|
GROUP BY b.login, b.bch_name
|
||||||
),
|
),
|
||||||
last_pub AS (
|
last_pub AS (
|
||||||
@@ -144,7 +144,7 @@ public final class SubscriptionsDAO {
|
|||||||
ON s.channel_login = b.login
|
ON s.channel_login = b.login
|
||||||
AND s.channel_bch_name = b.bch_name
|
AND s.channel_bch_name = b.bch_name
|
||||||
WHERE b.msg_type = ?
|
WHERE b.msg_type = ?
|
||||||
AND b.msg_sub_type IN (?, ?)
|
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||||
GROUP BY b.login, b.bch_name
|
GROUP BY b.login, b.bch_name
|
||||||
),
|
),
|
||||||
last_pub_block AS (
|
last_pub_block AS (
|
||||||
@@ -209,11 +209,19 @@ public final class SubscriptionsDAO {
|
|||||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
|
||||||
|
|
||||||
// last_pub
|
// last_pub
|
||||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
|
||||||
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
|||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.KeyEncodingUtil;
|
||||||
|
import shine.db.entities.UserAccessServerRouteEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO локальной routing-проекции access servers пользователей.
|
||||||
|
*/
|
||||||
|
public final class UserAccessServersCurrentDAO {
|
||||||
|
|
||||||
|
private static volatile UserAccessServersCurrentDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private UserAccessServersCurrentDAO() {}
|
||||||
|
|
||||||
|
public static UserAccessServersCurrentDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (UserAccessServersCurrentDAO.class) {
|
||||||
|
if (instance == null) instance = new UserAccessServersCurrentDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<UserAccessServerRouteEntry> listByUserLogin(String userLogin) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return listByUserLogin(c, userLogin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<UserAccessServerRouteEntry> listByUserLogin(Connection c, String userLogin) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT user_login, server_login, server_url, server_client_key
|
||||||
|
FROM user_access_servers_current
|
||||||
|
WHERE LOWER(user_login) = LOWER(?)
|
||||||
|
ORDER BY server_login
|
||||||
|
""";
|
||||||
|
List<UserAccessServerRouteEntry> result = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, userLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
result.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> listUserLoginsByServerLogin(String serverLogin) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT user_login
|
||||||
|
FROM user_access_servers_current
|
||||||
|
WHERE LOWER(server_login) = LOWER(?)
|
||||||
|
ORDER BY user_login
|
||||||
|
""";
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, serverLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
result.add(rs.getString("user_login"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
|
||||||
|
entry.setUserLogin(rs.getString("user_login"));
|
||||||
|
entry.setServerLogin(rs.getString("server_login"));
|
||||||
|
entry.setServerUrl(rs.getString("server_url"));
|
||||||
|
entry.setServerClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("server_client_key")));
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -13,6 +13,10 @@ public class ChannelNameStateEntry {
|
|||||||
private int channelRootBlockNumber;
|
private int channelRootBlockNumber;
|
||||||
private byte[] channelRootBlockHash;
|
private byte[] channelRootBlockHash;
|
||||||
private long createdAtMs;
|
private long createdAtMs;
|
||||||
|
private String avaAr;
|
||||||
|
private String avaSha256;
|
||||||
|
private long avaSize;
|
||||||
|
private long metaUpdatedAtMs;
|
||||||
|
|
||||||
public String getSlug() {
|
public String getSlug() {
|
||||||
return slug;
|
return slug;
|
||||||
@@ -93,4 +97,36 @@ public class ChannelNameStateEntry {
|
|||||||
public void setCreatedAtMs(long createdAtMs) {
|
public void setCreatedAtMs(long createdAtMs) {
|
||||||
this.createdAtMs = createdAtMs;
|
this.createdAtMs = createdAtMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getAvaAr() {
|
||||||
|
return avaAr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvaAr(String avaAr) {
|
||||||
|
this.avaAr = avaAr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvaSha256() {
|
||||||
|
return avaSha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvaSha256(String avaSha256) {
|
||||||
|
this.avaSha256 = avaSha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getAvaSize() {
|
||||||
|
return avaSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvaSize(long avaSize) {
|
||||||
|
this.avaSize = avaSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getMetaUpdatedAtMs() {
|
||||||
|
return metaUpdatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMetaUpdatedAtMs(long metaUpdatedAtMs) {
|
||||||
|
this.metaUpdatedAtMs = metaUpdatedAtMs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
public class DmSyncPeerStateEntry {
|
||||||
|
private String ownerLogin;
|
||||||
|
private String remoteServerLogin;
|
||||||
|
private String remoteServerUrl;
|
||||||
|
private long cursorStoredAtMs;
|
||||||
|
private String cursorMessageKey;
|
||||||
|
private boolean bootstrapCompleted;
|
||||||
|
private Long lastSyncAtMs;
|
||||||
|
private String lastError;
|
||||||
|
private long updatedAtMs;
|
||||||
|
|
||||||
|
public String getOwnerLogin() { return ownerLogin; }
|
||||||
|
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||||
|
public String getRemoteServerLogin() { return remoteServerLogin; }
|
||||||
|
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
|
||||||
|
public String getRemoteServerUrl() { return remoteServerUrl; }
|
||||||
|
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
|
||||||
|
public long getCursorStoredAtMs() { return cursorStoredAtMs; }
|
||||||
|
public void setCursorStoredAtMs(long cursorStoredAtMs) { this.cursorStoredAtMs = cursorStoredAtMs; }
|
||||||
|
public String getCursorMessageKey() { return cursorMessageKey; }
|
||||||
|
public void setCursorMessageKey(String cursorMessageKey) { this.cursorMessageKey = cursorMessageKey; }
|
||||||
|
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
|
||||||
|
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
|
||||||
|
public Long getLastSyncAtMs() { return lastSyncAtMs; }
|
||||||
|
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
|
||||||
|
public String getLastError() { return lastError; }
|
||||||
|
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||||
|
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||||
|
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальная routing-проекция access server пользователя.
|
||||||
|
*
|
||||||
|
* Источник:
|
||||||
|
* - user_access_servers_current
|
||||||
|
*/
|
||||||
|
public class UserAccessServerRouteEntry {
|
||||||
|
|
||||||
|
private String userLogin;
|
||||||
|
private String serverLogin;
|
||||||
|
private String serverUrl;
|
||||||
|
private String serverClientKey;
|
||||||
|
|
||||||
|
public String getUserLogin() { return userLogin; }
|
||||||
|
public void setUserLogin(String userLogin) { this.userLogin = userLogin; }
|
||||||
|
|
||||||
|
public String getServerLogin() { return serverLogin; }
|
||||||
|
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
|
||||||
|
|
||||||
|
public String getServerUrl() { return serverUrl; }
|
||||||
|
public void setServerUrl(String serverUrl) { this.serverUrl = serverUrl; }
|
||||||
|
|
||||||
|
public String getServerClientKey() { return serverClientKey; }
|
||||||
|
public void setServerClientKey(String serverClientKey) { this.serverClientKey = serverClientKey; }
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ public final class CurrentUsersSql {
|
|||||||
current_users.blockchain_name AS blockchain_name,
|
current_users.blockchain_name AS blockchain_name,
|
||||||
current_users.client_key AS solana_key,
|
current_users.client_key AS solana_key,
|
||||||
current_users.blockchain_key AS blockchain_key,
|
current_users.blockchain_key AS blockchain_key,
|
||||||
current_users.client_key AS client_key
|
current_users.client_key AS client_key,
|
||||||
|
current_users.is_server AS is_server
|
||||||
FROM solana_user_pda_current current_users
|
FROM solana_user_pda_current current_users
|
||||||
) %s
|
) %s
|
||||||
""".formatted(alias);
|
""".formatted(alias);
|
||||||
|
|||||||
@@ -53,12 +53,16 @@ BEGIN
|
|||||||
s.updated_at_ms,
|
s.updated_at_ms,
|
||||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
FROM solana_user_pda_current u
|
FROM solana_user_pda_current u
|
||||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
CROSS JOIN LATERAL (
|
||||||
CASE
|
SELECT login_value
|
||||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
FROM jsonb_array_elements_text(
|
||||||
ELSE u.access_servers_json::jsonb
|
CASE
|
||||||
END
|
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||||
) AS access_server(login_value)
|
ELSE u.access_servers_json::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
|
WHERE ord <= 2
|
||||||
|
) AS access_server
|
||||||
JOIN solana_user_pda_current s
|
JOIN solana_user_pda_current s
|
||||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||||
AND s.is_server = TRUE
|
AND s.is_server = TRUE
|
||||||
@@ -92,12 +96,16 @@ BEGIN
|
|||||||
FOR affected_user IN
|
FOR affected_user IN
|
||||||
SELECT u.login
|
SELECT u.login
|
||||||
FROM solana_user_pda_current u
|
FROM solana_user_pda_current u
|
||||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
CROSS JOIN LATERAL (
|
||||||
CASE
|
SELECT login_value
|
||||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
FROM jsonb_array_elements_text(
|
||||||
ELSE u.access_servers_json::jsonb
|
CASE
|
||||||
END
|
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||||
) AS access_server(login_value)
|
ELSE u.access_servers_json::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
|
WHERE ord <= 2
|
||||||
|
) AS access_server
|
||||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||||
LOOP
|
LOOP
|
||||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor
|
||||||
|
ON signed_messages(target_login, created_at_ms, message_key);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower
|
||||||
|
ON signed_messages(LOWER(target_login), created_at_ms, message_key);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor
|
||||||
|
ON signed_messages(from_login, created_at_ms, message_key)
|
||||||
|
WHERE message_type IN (5, 6, 7, 8);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor
|
||||||
|
ON signed_messages(to_login, created_at_ms, message_key)
|
||||||
|
WHERE message_type IN (5, 6, 7, 8);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower
|
||||||
|
ON signed_messages(LOWER(from_login), created_at_ms, message_key)
|
||||||
|
WHERE message_type IN (5, 6, 7, 8);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower
|
||||||
|
ON signed_messages(LOWER(to_login), created_at_ms, message_key)
|
||||||
|
WHERE message_type IN (5, 6, 7, 8);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
|
||||||
|
owner_login TEXT NOT NULL,
|
||||||
|
remote_server_login TEXT NOT NULL,
|
||||||
|
remote_server_url TEXT NOT NULL,
|
||||||
|
cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
cursor_message_key TEXT NOT NULL DEFAULT '',
|
||||||
|
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
last_sync_at_ms BIGINT,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, remote_server_login)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
|
||||||
|
ON dm_sync_peer_state(owner_login);
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 3, 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;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE channel_names_state
|
||||||
|
ADD COLUMN IF NOT EXISTS ava_ar TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS ava_sha256 TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS ava_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS meta_updated_at_ms BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 4, 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;
|
||||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
|||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
VALUES (1, 2, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
schema_version = EXCLUDED.schema_version,
|
schema_version = EXCLUDED.schema_version,
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
@@ -162,12 +162,16 @@ BEGIN
|
|||||||
s.updated_at_ms,
|
s.updated_at_ms,
|
||||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
FROM solana_user_pda_current u
|
FROM solana_user_pda_current u
|
||||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
CROSS JOIN LATERAL (
|
||||||
CASE
|
SELECT login_value
|
||||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
FROM jsonb_array_elements_text(
|
||||||
ELSE u.access_servers_json::jsonb
|
CASE
|
||||||
END
|
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||||
) AS access_server(login_value)
|
ELSE u.access_servers_json::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
|
WHERE ord <= 2
|
||||||
|
) AS access_server
|
||||||
JOIN solana_user_pda_current s
|
JOIN solana_user_pda_current s
|
||||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||||
AND s.is_server = TRUE
|
AND s.is_server = TRUE
|
||||||
@@ -201,12 +205,16 @@ BEGIN
|
|||||||
FOR affected_user IN
|
FOR affected_user IN
|
||||||
SELECT u.login
|
SELECT u.login
|
||||||
FROM solana_user_pda_current u
|
FROM solana_user_pda_current u
|
||||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
CROSS JOIN LATERAL (
|
||||||
CASE
|
SELECT login_value
|
||||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
FROM jsonb_array_elements_text(
|
||||||
ELSE u.access_servers_json::jsonb
|
CASE
|
||||||
END
|
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||||
) AS access_server(login_value)
|
ELSE u.access_servers_json::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
|
WHERE ord <= 2
|
||||||
|
) AS access_server
|
||||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||||
LOOP
|
LOOP
|
||||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||||
@@ -536,7 +544,11 @@ CREATE TABLE IF NOT EXISTS channel_names_state (
|
|||||||
channel_type_version INTEGER NOT NULL DEFAULT 1,
|
channel_type_version INTEGER NOT NULL DEFAULT 1,
|
||||||
channel_root_block_number INTEGER NOT NULL,
|
channel_root_block_number INTEGER NOT NULL,
|
||||||
channel_root_block_hash BYTEA NOT NULL,
|
channel_root_block_hash BYTEA NOT NULL,
|
||||||
created_at_ms BIGINT NOT NULL
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
ava_ar TEXT NOT NULL DEFAULT '',
|
||||||
|
ava_sha256 TEXT NOT NULL DEFAULT '',
|
||||||
|
ava_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
meta_updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
|
||||||
@@ -652,6 +664,28 @@ CREATE INDEX IF NOT EXISTS idx_signed_messages_target
|
|||||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_base
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_base
|
||||||
ON signed_messages(base_key, message_type);
|
ON signed_messages(base_key, message_type);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor
|
||||||
|
ON signed_messages(target_login, created_at_ms, message_key);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower
|
||||||
|
ON signed_messages(LOWER(target_login), created_at_ms, message_key);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor
|
||||||
|
ON signed_messages(from_login, created_at_ms, message_key)
|
||||||
|
WHERE message_type IN (5, 6, 7, 8);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor
|
||||||
|
ON signed_messages(to_login, created_at_ms, message_key)
|
||||||
|
WHERE message_type IN (5, 6, 7, 8);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower
|
||||||
|
ON signed_messages(LOWER(from_login), created_at_ms, message_key)
|
||||||
|
WHERE message_type IN (5, 6, 7, 8);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower
|
||||||
|
ON signed_messages(LOWER(to_login), created_at_ms, message_key)
|
||||||
|
WHERE message_type IN (5, 6, 7, 8);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_receipt_incoming
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_receipt_incoming
|
||||||
ON signed_messages(target_login, receipt_ref_base_key)
|
ON signed_messages(target_login, receipt_ref_base_key)
|
||||||
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
|
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
|
||||||
@@ -672,6 +706,22 @@ CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
||||||
ON signed_message_session_delivery(session_id, delivered);
|
ON signed_message_session_delivery(session_id, delivered);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
|
||||||
|
owner_login TEXT NOT NULL,
|
||||||
|
remote_server_login TEXT NOT NULL,
|
||||||
|
remote_server_url TEXT NOT NULL,
|
||||||
|
cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
cursor_message_key TEXT NOT NULL DEFAULT '',
|
||||||
|
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
last_sync_at_ms BIGINT,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, remote_server_login)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
|
||||||
|
ON dm_sync_peer_state(owner_login);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS message_views_state (
|
CREATE TABLE IF NOT EXISTS message_views_state (
|
||||||
viewer_login TEXT NOT NULL,
|
viewer_login TEXT NOT NULL,
|
||||||
to_bch_name TEXT NOT NULL,
|
to_bch_name TEXT NOT NULL,
|
||||||
|
|||||||
+8
@@ -67,6 +67,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.ChannelNamesStateBootstrapper;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
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_GetMessageThread_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_GetGroupDialog_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
|
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_ListGroupChats200_Handler;
|
||||||
@@ -75,6 +76,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_GetChannelMessages_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_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_GetMessageThread_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_ListGroupChats200_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||||
@@ -88,6 +90,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
|||||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||||
@@ -100,6 +103,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
|
|||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||||
@@ -182,6 +186,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
|
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
|
||||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||||
|
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
||||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||||
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
||||||
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
||||||
@@ -198,6 +203,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||||
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||||
|
Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()),
|
||||||
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
||||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||||
@@ -262,6 +268,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
|
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
|
||||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||||
|
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
|
||||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||||
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
||||||
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
||||||
@@ -278,6 +285,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||||
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
||||||
|
Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class),
|
||||||
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
|
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
|
||||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||||
|
|||||||
+9
-1
@@ -27,6 +27,7 @@ public final class SolanaUserPdaImportService {
|
|||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||||
private static final String MAGIC = "SHiNE";
|
private static final String MAGIC = "SHiNE";
|
||||||
|
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 2;
|
||||||
|
|
||||||
private SolanaUserPdaImportService() {}
|
private SolanaUserPdaImportService() {}
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ public final class SolanaUserPdaImportService {
|
|||||||
String serverAddress = safe(serverProfile.serverAddress());
|
String serverAddress = safe(serverProfile.serverAddress());
|
||||||
if (serverAddress.isBlank()) continue;
|
if (serverAddress.isBlank()) continue;
|
||||||
routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress));
|
routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress));
|
||||||
|
if (routes.size() >= MAX_EFFECTIVE_ACCESS_SERVERS) break;
|
||||||
}
|
}
|
||||||
return new ArrayList<>(routes.values());
|
return new ArrayList<>(routes.values());
|
||||||
}
|
}
|
||||||
@@ -234,7 +236,13 @@ public final class SolanaUserPdaImportService {
|
|||||||
int n = u8(raw, c++);
|
int n = u8(raw, c++);
|
||||||
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||||
c += n;
|
c += n;
|
||||||
accessServers.add(normalizeLogin(accessServerLogin));
|
String normalizedAccessServerLogin = normalizeLogin(accessServerLogin);
|
||||||
|
if (normalizedAccessServerLogin == null || accessServers.contains(normalizedAccessServerLogin)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
|
||||||
|
accessServers.add(normalizedAccessServerLogin);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (blockType == 50) {
|
} else if (blockType == 50) {
|
||||||
int sessionsMode = u8(raw, c++);
|
int sessionsMode = u8(raw, c++);
|
||||||
|
|||||||
+175
-7
@@ -6,6 +6,7 @@ import blockchain.MsgSubType;
|
|||||||
import blockchain.body.BodyHasLine;
|
import blockchain.body.BodyHasLine;
|
||||||
import blockchain.body.BodyHasTarget;
|
import blockchain.body.BodyHasTarget;
|
||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
|
import blockchain.body.StatusActionBody;
|
||||||
import blockchain.body.TextLineBody;
|
import blockchain.body.TextLineBody;
|
||||||
import blockchain.body.UserParamBody;
|
import blockchain.body.UserParamBody;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -21,6 +22,7 @@ import server.sync.BlockchainResyncGuard;
|
|||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
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.ChannelNamesStateBootstrapper;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import server.sync.AddBlockSyncService;
|
import server.sync.AddBlockSyncService;
|
||||||
@@ -166,6 +168,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
||||||
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||||
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
|
case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
|
||||||
|
case "entrypoint_edit_forbidden" -> "TEXT_ENTRYPOINT нельзя редактировать через TEXT_EDIT_POST";
|
||||||
|
case "status_confirmed_target_must_be_status_action" -> "STATUS_CONFIRMED должен ссылаться на STATUS_ACTION";
|
||||||
|
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
||||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||||
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||||
default -> "Ошибка: " + code;
|
default -> "Ошибка: " + code;
|
||||||
@@ -277,6 +282,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChannelNameStateEntry channelNameStateEntry = null;
|
ChannelNameStateEntry channelNameStateEntry = null;
|
||||||
|
ChannelNameStateEntry channelMetaUpdateEntry = null;
|
||||||
Chat200CreateSeed chat200CreateSeed = null;
|
Chat200CreateSeed chat200CreateSeed = null;
|
||||||
if (block.body instanceof CreateChannelBody createChannelBody) {
|
if (block.body instanceof CreateChannelBody createChannelBody) {
|
||||||
int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
|
int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
|
||||||
@@ -302,14 +308,36 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String displayName = normalizedName;
|
||||||
|
String channelDescription = createChannelBody.channelDescription == null
|
||||||
|
? ""
|
||||||
|
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
|
||||||
|
String avaAr = "";
|
||||||
|
String avaSha256 = "";
|
||||||
|
long avaSize = 0L;
|
||||||
|
long metaUpdatedAtMs = 0L;
|
||||||
|
if (channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
|
||||||
|
try {
|
||||||
|
ChannelMetaTextParser.Parsed createMeta = ChannelMetaTextParser.parseStrict(channelDescription, normalizedName);
|
||||||
|
displayName = createMeta.title();
|
||||||
|
channelDescription = createMeta.description();
|
||||||
|
avaAr = createMeta.avaAr();
|
||||||
|
avaSha256 = createMeta.avaSha256();
|
||||||
|
avaSize = createMeta.avaSize();
|
||||||
|
metaUpdatedAtMs = block.timestamp * 1000L;
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, e.getMessage(), serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
channelNameStateEntry = new ChannelNameStateEntry();
|
channelNameStateEntry = new ChannelNameStateEntry();
|
||||||
channelNameStateEntry.setSlug(slug);
|
channelNameStateEntry.setSlug(slug);
|
||||||
channelNameStateEntry.setDisplayName(normalizedName);
|
channelNameStateEntry.setDisplayName(displayName);
|
||||||
channelNameStateEntry.setChannelDescription(
|
channelNameStateEntry.setChannelDescription(channelDescription);
|
||||||
createChannelBody.channelDescription == null
|
channelNameStateEntry.setAvaAr(avaAr);
|
||||||
? ""
|
channelNameStateEntry.setAvaSha256(avaSha256);
|
||||||
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription)
|
channelNameStateEntry.setAvaSize(avaSize);
|
||||||
);
|
channelNameStateEntry.setMetaUpdatedAtMs(metaUpdatedAtMs);
|
||||||
channelNameStateEntry.setOwnerLogin(login);
|
channelNameStateEntry.setOwnerLogin(login);
|
||||||
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
|
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
|
||||||
channelNameStateEntry.setChannelTypeCode(channelTypeCode);
|
channelNameStateEntry.setChannelTypeCode(channelTypeCode);
|
||||||
@@ -329,6 +357,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription();
|
chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription();
|
||||||
chat200CreateSeed.updatedAtMs = block.timestamp * 1000L;
|
chat200CreateSeed.updatedAtMs = block.timestamp * 1000L;
|
||||||
}
|
}
|
||||||
|
} else if ((block.type & 0xFFFF) == 1
|
||||||
|
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||||
|
&& block.body instanceof TextLineBody metaBody) {
|
||||||
|
int lineCode = metaBody.lineCode();
|
||||||
|
if (lineCode <= 0) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_meta_line", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
ExistingChannelState existingChannel;
|
||||||
|
try {
|
||||||
|
existingChannel = loadExistingChannelState(blockchainName, lineCode);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AddBlock: channel_meta_lookup_failed (login={}, blockchainName={}, lineCode={})",
|
||||||
|
login, blockchainName, lineCode, e);
|
||||||
|
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
if (existingChannel == null) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "channel_not_found", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
ChannelMetaTextParser.Parsed parsedMeta;
|
||||||
|
try {
|
||||||
|
parsedMeta = ChannelMetaTextParser.parseStrict(metaBody.message, existingChannel.slug);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, e.getMessage(), serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
channelMetaUpdateEntry = new ChannelNameStateEntry();
|
||||||
|
channelMetaUpdateEntry.setOwnerBlockchainName(blockchainName);
|
||||||
|
channelMetaUpdateEntry.setChannelRootBlockNumber(lineCode);
|
||||||
|
channelMetaUpdateEntry.setDisplayName(parsedMeta.title());
|
||||||
|
channelMetaUpdateEntry.setChannelDescription(parsedMeta.description());
|
||||||
|
channelMetaUpdateEntry.setAvaAr(parsedMeta.avaAr());
|
||||||
|
channelMetaUpdateEntry.setAvaSha256(parsedMeta.avaSha256());
|
||||||
|
channelMetaUpdateEntry.setAvaSize(parsedMeta.avaSize());
|
||||||
|
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((block.type & 0xFFFF) == 1
|
||||||
|
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
|
try {
|
||||||
|
String editError = validateEditPostTarget(blockchainName, block);
|
||||||
|
if (editError != null) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, editError, serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AddBlock: edit_post_target_check_failed (login={}, blockchainName={}, blockNumber={})",
|
||||||
|
login, blockchainName, block.blockNumber, e);
|
||||||
|
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((block.type & 0xFFFF) == 5) {
|
||||||
|
try {
|
||||||
|
String statusError = validateStatusActionTarget(block);
|
||||||
|
if (statusError != null) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, statusError, serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AddBlock: status_action_target_check_failed (login={}, blockchainName={}, blockNumber={})",
|
||||||
|
login, blockchainName, block.blockNumber, e);
|
||||||
|
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4.2) запрет дырок: blockNumber строго last+1
|
// 4.2) запрет дырок: blockNumber строго last+1
|
||||||
@@ -485,7 +573,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry);
|
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry);
|
||||||
|
|
||||||
if (chat200CreateSeed != null) {
|
if (chat200CreateSeed != null) {
|
||||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||||
@@ -544,6 +632,86 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
long updatedAtMs;
|
long updatedAtMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final class ExistingChannelState {
|
||||||
|
String slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateEditPostTarget(String ownerBch, BchBlockEntry block) throws Exception {
|
||||||
|
if (!(block.body instanceof TextLineBody editBody)) return null;
|
||||||
|
Integer targetBlockNumber = editBody.toBlockGlobalNumber();
|
||||||
|
byte[] targetHash = editBody.toBlockHashBytes();
|
||||||
|
if (targetBlockNumber == null || targetHash == null || targetHash.length != 32) return "bad_block_body";
|
||||||
|
|
||||||
|
BlockEntry target = blocksDAO.getByNumber(ownerBch, targetBlockNumber);
|
||||||
|
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (target.getMsgType() != 1) return null;
|
||||||
|
if (target.getMsgSubType() == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)) {
|
||||||
|
return "entrypoint_edit_forbidden";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateStatusActionTarget(BchBlockEntry block) throws Exception {
|
||||||
|
if (!(block.body instanceof StatusActionBody statusBody)) return "bad_block_body";
|
||||||
|
String targetBch = statusBody.toBchName();
|
||||||
|
Integer targetBlockNumber = statusBody.toBlockGlobalNumber();
|
||||||
|
byte[] targetHash = statusBody.toBlockHashBytes();
|
||||||
|
if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
|
||||||
|
return "bad_block_body";
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockEntry target = blocksDAO.getByNumber(targetBch, targetBlockNumber);
|
||||||
|
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int statusSubType = block.subType & 0xFFFF;
|
||||||
|
if (statusSubType == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) {
|
||||||
|
if (target.getMsgType() != 5) {
|
||||||
|
return "status_confirmed_target_must_be_status_action";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (target.getMsgType() != 1) return "status_action_target_not_allowed";
|
||||||
|
|
||||||
|
int targetSubType = target.getMsgSubType();
|
||||||
|
if (statusSubType == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|
||||||
|
|| statusSubType == (MsgSubType.STATUS_LEARNED & 0xFFFF)) {
|
||||||
|
return targetSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF) ? null : "status_action_target_not_allowed";
|
||||||
|
}
|
||||||
|
if (statusSubType == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)) {
|
||||||
|
return targetSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF) ? null : "status_action_target_not_allowed";
|
||||||
|
}
|
||||||
|
if (statusSubType == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|
||||||
|
|| statusSubType == (MsgSubType.STATUS_STARTED & 0xFFFF)
|
||||||
|
|| statusSubType == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|
||||||
|
|| statusSubType == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|
||||||
|
|| statusSubType == (MsgSubType.STATUS_COMPLETED & 0xFFFF)) {
|
||||||
|
return targetSubType == (MsgSubType.TEXT_COURSE & 0xFFFF) ? null : "status_action_target_not_allowed";
|
||||||
|
}
|
||||||
|
return "status_action_target_not_allowed";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception {
|
||||||
|
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT slug
|
||||||
|
FROM channel_names_state
|
||||||
|
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||||
|
LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerBch);
|
||||||
|
ps.setInt(2, rootBlockNumber);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
ExistingChannelState state = new ExistingChannelState();
|
||||||
|
state.slug = rs.getString("slug");
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void upsertChat200StateFromCreate(Chat200CreateSeed seed) throws Exception {
|
private void upsertChat200StateFromCreate(Chat200CreateSeed seed) throws Exception {
|
||||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||||
PreparedStatement ps = c.prepareStatement("""
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
|||||||
+6
-1
@@ -58,7 +58,8 @@ public final class BlockchainWriter {
|
|||||||
BlockchainStateEntry st,
|
BlockchainStateEntry st,
|
||||||
BlockEntry be,
|
BlockEntry be,
|
||||||
UserParamEntry userParamEntry,
|
UserParamEntry userParamEntry,
|
||||||
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
ChannelNameStateEntry channelNameStateEntry,
|
||||||
|
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
|
||||||
|
|
||||||
long nowMs = System.currentTimeMillis();
|
long nowMs = System.currentTimeMillis();
|
||||||
byte[] blockBytes = block.toBytes();
|
byte[] blockBytes = block.toBytes();
|
||||||
@@ -91,6 +92,10 @@ public final class BlockchainWriter {
|
|||||||
channelNameStateDAO.insert(c, channelNameStateEntry);
|
channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (channelMetaUpdateEntry != null) {
|
||||||
|
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||||
|
}
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
committed = true;
|
committed = true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class ChannelMetaTextParser {
|
||||||
|
public static final int MAX_TITLE_CHARS = 50;
|
||||||
|
public static final int MAX_DESCRIPTION_CHARS = 250;
|
||||||
|
private static final String LEGACY_PREFIX = "<SHiNE:";
|
||||||
|
private static final String SHORT_PREFIX = "<S:";
|
||||||
|
|
||||||
|
private ChannelMetaTextParser() {}
|
||||||
|
|
||||||
|
public static Parsed parseStrict(String rawText, String fallbackTitle) {
|
||||||
|
String text = String.valueOf(rawText == null ? "" : rawText);
|
||||||
|
String title = "";
|
||||||
|
String avaAr = "";
|
||||||
|
String avaSha256 = "";
|
||||||
|
long avaSize = 0L;
|
||||||
|
boolean seenTitle = false;
|
||||||
|
boolean seenAvatar = false;
|
||||||
|
|
||||||
|
int offset = 0;
|
||||||
|
while (startsWithTagPrefix(text, offset)) {
|
||||||
|
int end = text.indexOf('>', offset);
|
||||||
|
if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag");
|
||||||
|
int prefixLength = tagPrefixLength(text, offset);
|
||||||
|
String body = text.substring(offset + prefixLength, end);
|
||||||
|
if (body.startsWith("title;")) {
|
||||||
|
if (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
|
||||||
|
seenTitle = true;
|
||||||
|
title = parseTitleTag(body);
|
||||||
|
} else if (body.startsWith("avatar;") || body.startsWith("ava;")) {
|
||||||
|
if (seenAvatar) throw new IllegalArgumentException("duplicate_channel_meta_avatar");
|
||||||
|
seenAvatar = true;
|
||||||
|
Avatar avatar = parseAvatarTag(body);
|
||||||
|
avaAr = avatar.ar;
|
||||||
|
avaSha256 = avatar.sha256;
|
||||||
|
avaSize = avatar.size;
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("unknown_channel_meta_tag");
|
||||||
|
}
|
||||||
|
offset = end + 1;
|
||||||
|
if (offset < text.length() && text.charAt(offset) == '\n') offset += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
String description = text.substring(offset).trim();
|
||||||
|
if (countCodePoints(description) > MAX_DESCRIPTION_CHARS) {
|
||||||
|
throw new IllegalArgumentException("channel_meta_description_too_long");
|
||||||
|
}
|
||||||
|
|
||||||
|
String cleanTitle = title.isBlank() ? String.valueOf(fallbackTitle == null ? "" : fallbackTitle).trim() : title.trim();
|
||||||
|
validateTitle(cleanTitle);
|
||||||
|
|
||||||
|
return new Parsed(cleanTitle, description, avaAr, avaSha256, avaSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String parseTitleTag(String body) {
|
||||||
|
String prefix = "title;v=1;";
|
||||||
|
if (!body.startsWith(prefix)) throw new IllegalArgumentException("bad_channel_meta_title");
|
||||||
|
String title = body.substring(prefix.length()).trim();
|
||||||
|
validateTitle(title);
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Avatar parseAvatarTag(String body) {
|
||||||
|
String rawFields = body.startsWith("ava;")
|
||||||
|
? body.substring("ava;".length())
|
||||||
|
: body.substring("avatar;".length());
|
||||||
|
Map<String, String> fields = parseFields(rawFields);
|
||||||
|
if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version");
|
||||||
|
String ar = String.valueOf(fields.getOrDefault("ar", "")).trim();
|
||||||
|
String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase();
|
||||||
|
String sizeRaw = String.valueOf(fields.containsKey("sz") ? fields.get("sz") : fields.getOrDefault("size", "")).trim();
|
||||||
|
if (!ar.matches("^[A-Za-z0-9_-]{43}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_ar");
|
||||||
|
if (!sha256.matches("^[0-9a-f]{64}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_sha256");
|
||||||
|
long size;
|
||||||
|
try {
|
||||||
|
size = Long.parseLong(sizeRaw);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalArgumentException("bad_channel_meta_avatar_size");
|
||||||
|
}
|
||||||
|
if (size <= 0L) throw new IllegalArgumentException("bad_channel_meta_avatar_size");
|
||||||
|
return new Avatar(ar, sha256, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean startsWithTagPrefix(String text, int offset) {
|
||||||
|
return text.startsWith(LEGACY_PREFIX, offset) || text.startsWith(SHORT_PREFIX, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int tagPrefixLength(String text, int offset) {
|
||||||
|
if (text.startsWith(LEGACY_PREFIX, offset)) return LEGACY_PREFIX.length();
|
||||||
|
if (text.startsWith(SHORT_PREFIX, offset)) return SHORT_PREFIX.length();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> parseFields(String raw) {
|
||||||
|
Map<String, String> out = new HashMap<>();
|
||||||
|
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
|
||||||
|
int eq = part.indexOf('=');
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
String key = part.substring(0, eq).trim();
|
||||||
|
String value = part.substring(eq + 1).trim();
|
||||||
|
if (!key.isEmpty()) out.put(key, value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateTitle(String title) {
|
||||||
|
String value = String.valueOf(title == null ? "" : title);
|
||||||
|
if (value.indexOf('<') >= 0 || value.indexOf('>') >= 0 || value.indexOf(';') >= 0
|
||||||
|
|| value.indexOf('\t') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0
|
||||||
|
|| value.indexOf('\0') >= 0) {
|
||||||
|
throw new IllegalArgumentException("bad_channel_meta_title_chars");
|
||||||
|
}
|
||||||
|
if (countCodePoints(value) > MAX_TITLE_CHARS) {
|
||||||
|
throw new IllegalArgumentException("channel_meta_title_too_long");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int countCodePoints(String value) {
|
||||||
|
return String.valueOf(value == null ? "" : value).codePointCount(0, String.valueOf(value == null ? "" : value).length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Avatar(String ar, String sha256, long size) {}
|
||||||
|
|
||||||
|
public record Parsed(String title, String description, String avaAr, String avaSha256, long avaSize) {}
|
||||||
|
}
|
||||||
+73
-2
@@ -1,7 +1,9 @@
|
|||||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
import blockchain.BchBlockEntry;
|
import blockchain.BchBlockEntry;
|
||||||
|
import blockchain.MsgSubType;
|
||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
|
import blockchain.body.TextLineBody;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import shine.db.DbController;
|
import shine.db.DbController;
|
||||||
@@ -20,6 +22,7 @@ import java.util.Map;
|
|||||||
public final class ChannelNamesStateBootstrapper {
|
public final class ChannelNamesStateBootstrapper {
|
||||||
private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class);
|
private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class);
|
||||||
private static final int MSG_TYPE_TECH = 0;
|
private static final int MSG_TYPE_TECH = 0;
|
||||||
|
private static final int MSG_TYPE_TEXT = 1;
|
||||||
private static final int MSG_SUB_TYPE_CREATE_CHANNEL = 1;
|
private static final int MSG_SUB_TYPE_CREATE_CHANNEL = 1;
|
||||||
private static volatile boolean bootstrapped;
|
private static volatile boolean bootstrapped;
|
||||||
|
|
||||||
@@ -74,17 +77,30 @@ public final class ChannelNamesStateBootstrapper {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final String displayName;
|
String displayName;
|
||||||
final String slug;
|
final String slug;
|
||||||
final String channelDescription;
|
String channelDescription;
|
||||||
final int channelTypeCode;
|
final int channelTypeCode;
|
||||||
final int channelTypeVersion;
|
final int channelTypeVersion;
|
||||||
|
String avaAr = "";
|
||||||
|
String avaSha256 = "";
|
||||||
|
long avaSize = 0L;
|
||||||
|
long metaUpdatedAtMs = 0L;
|
||||||
try {
|
try {
|
||||||
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
|
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
|
||||||
slug = ChannelNameRules.toCanonicalSlug(displayName);
|
slug = ChannelNameRules.toCanonicalSlug(displayName);
|
||||||
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
|
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
|
||||||
channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
|
channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
|
||||||
channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion);
|
channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion);
|
||||||
|
if (channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
|
||||||
|
ChannelMetaTextParser.Parsed createMeta = ChannelMetaTextParser.parseStrict(channelDescription, displayName);
|
||||||
|
displayName = createMeta.title();
|
||||||
|
channelDescription = createMeta.description();
|
||||||
|
avaAr = createMeta.avaAr();
|
||||||
|
avaSha256 = createMeta.avaSha256();
|
||||||
|
avaSize = createMeta.avaSize();
|
||||||
|
metaUpdatedAtMs = parsed.timestamp * 1000L;
|
||||||
|
}
|
||||||
} catch (Exception badName) {
|
} catch (Exception badName) {
|
||||||
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
|
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
|
||||||
continue;
|
continue;
|
||||||
@@ -102,6 +118,10 @@ public final class ChannelNamesStateBootstrapper {
|
|||||||
entry.setSlug(slug);
|
entry.setSlug(slug);
|
||||||
entry.setDisplayName(displayName);
|
entry.setDisplayName(displayName);
|
||||||
entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
|
entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
|
||||||
|
entry.setAvaAr(avaAr);
|
||||||
|
entry.setAvaSha256(avaSha256);
|
||||||
|
entry.setAvaSize(avaSize);
|
||||||
|
entry.setMetaUpdatedAtMs(metaUpdatedAtMs);
|
||||||
entry.setOwnerLogin(ownerLogin);
|
entry.setOwnerLogin(ownerLogin);
|
||||||
entry.setOwnerBlockchainName(ownerBch);
|
entry.setOwnerBlockchainName(ownerBch);
|
||||||
entry.setChannelTypeCode(channelTypeCode);
|
entry.setChannelTypeCode(channelTypeCode);
|
||||||
@@ -116,6 +136,8 @@ public final class ChannelNamesStateBootstrapper {
|
|||||||
|
|
||||||
dao.clearAll(c);
|
dao.clearAll(c);
|
||||||
dao.insertAll(c, entries);
|
dao.insertAll(c, entries);
|
||||||
|
|
||||||
|
applyMetaUpdates(c, dao, entries, skipped);
|
||||||
c.commit();
|
c.commit();
|
||||||
log.info("channel_names_state bootstrapped: {}", entries.size());
|
log.info("channel_names_state bootstrapped: {}", entries.size());
|
||||||
if (!conflicts.isEmpty()) {
|
if (!conflicts.isEmpty()) {
|
||||||
@@ -148,4 +170,53 @@ public final class ChannelNamesStateBootstrapper {
|
|||||||
throw new RuntimeException("Failed to bootstrap channel_names_state", e);
|
throw new RuntimeException("Failed to bootstrap channel_names_state", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void applyMetaUpdates(Connection c,
|
||||||
|
ChannelNameStateDAO dao,
|
||||||
|
List<ChannelNameStateEntry> entries,
|
||||||
|
List<String> skipped) throws Exception {
|
||||||
|
Map<String, String> fallbackTitles = new LinkedHashMap<>();
|
||||||
|
for (ChannelNameStateEntry entry : entries) {
|
||||||
|
fallbackTitles.put(entry.getOwnerBlockchainName() + "#" + entry.getChannelRootBlockNumber(), entry.getSlug());
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT bch_name, block_number, block_bytes
|
||||||
|
FROM blocks
|
||||||
|
WHERE msg_type = ? AND msg_sub_type = ?
|
||||||
|
ORDER BY bch_name, block_number
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setInt(1, MSG_TYPE_TEXT);
|
||||||
|
ps.setInt(2, MsgSubType.TEXT_CHANNEL_META);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String ownerBch = rs.getString("bch_name");
|
||||||
|
int blockNumber = rs.getInt("block_number");
|
||||||
|
byte[] blockBytes = rs.getBytes("block_bytes");
|
||||||
|
try {
|
||||||
|
BchBlockEntry parsed = new BchBlockEntry(blockBytes);
|
||||||
|
if (!(parsed.body instanceof TextLineBody metaBody)) continue;
|
||||||
|
int lineCode = metaBody.lineCode();
|
||||||
|
String fallbackTitle = fallbackTitles.get(ownerBch + "#" + lineCode);
|
||||||
|
if (fallbackTitle == null) continue;
|
||||||
|
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(metaBody.message, fallbackTitle);
|
||||||
|
|
||||||
|
ChannelNameStateEntry update = new ChannelNameStateEntry();
|
||||||
|
update.setOwnerBlockchainName(ownerBch);
|
||||||
|
update.setChannelRootBlockNumber(lineCode);
|
||||||
|
update.setDisplayName(parsedMeta.title());
|
||||||
|
update.setChannelDescription(parsedMeta.description());
|
||||||
|
update.setAvaAr(parsedMeta.avaAr());
|
||||||
|
update.setAvaSha256(parsedMeta.avaSha256());
|
||||||
|
update.setAvaSize(parsedMeta.avaSize());
|
||||||
|
update.setMetaUpdatedAtMs(parsed.timestamp * 1000L);
|
||||||
|
dao.updateMeta(c, update);
|
||||||
|
} catch (Exception e) {
|
||||||
|
skipped.add(ownerBch + "#" + blockNumber + " (invalid_meta)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+213
-60
@@ -22,9 +22,8 @@ final class ChannelsReadSupport {
|
|||||||
static final int MSG_TYPE_TEXT = 1;
|
static final int MSG_TYPE_TEXT = 1;
|
||||||
static final int MSG_TYPE_REACTION = 2;
|
static final int MSG_TYPE_REACTION = 2;
|
||||||
static final int MSG_TYPE_TECH = 0;
|
static final int MSG_TYPE_TECH = 0;
|
||||||
|
static final int MSG_TYPE_STATUS_ACTION = 5;
|
||||||
static final String STORIES_CHANNEL_NAME = "stories";
|
static final String STORIES_CHANNEL_NAME = "stories";
|
||||||
static final String COMMAND_PREFIX = "/.";
|
|
||||||
static final String COMMAND_DESC = "desc";
|
|
||||||
static final String COMMAND_ADD = "add";
|
static final String COMMAND_ADD = "add";
|
||||||
static final String COMMAND_REMOVE = "remove";
|
static final String COMMAND_REMOVE = "remove";
|
||||||
|
|
||||||
@@ -47,11 +46,40 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
if (rootNumber == 0) {
|
if (rootNumber == 0) {
|
||||||
meta.channelName = STORIES_CHANNEL_NAME;
|
meta.channelName = STORIES_CHANNEL_NAME;
|
||||||
meta.channelDescription = detectLatestDescriptionCommand(c, ownerBch, 0);
|
meta.displayName = STORIES_CHANNEL_NAME;
|
||||||
|
meta.channelDescription = "";
|
||||||
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_STORIES & 0xFFFF;
|
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_STORIES & 0xFFFF;
|
||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String stateSql = """
|
||||||
|
SELECT slug, display_name, channel_description, channel_type_code, channel_type_version,
|
||||||
|
ava_ar, ava_sha256, ava_size, meta_updated_at_ms
|
||||||
|
FROM channel_names_state
|
||||||
|
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(stateSql)) {
|
||||||
|
ps.setString(1, ownerBch);
|
||||||
|
ps.setInt(2, rootNumber);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
meta.channelName = rs.getString("slug");
|
||||||
|
meta.displayName = rs.getString("display_name");
|
||||||
|
meta.channelDescription = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
|
||||||
|
meta.channelTypeCode = rs.getInt("channel_type_code");
|
||||||
|
meta.channelTypeVersion = rs.getInt("channel_type_version");
|
||||||
|
meta.avaAr = String.valueOf(rs.getString("ava_ar") == null ? "" : rs.getString("ava_ar"));
|
||||||
|
meta.avaSha256 = String.valueOf(rs.getString("ava_sha256") == null ? "" : rs.getString("ava_sha256"));
|
||||||
|
meta.avaSize = rs.getLong("ava_size");
|
||||||
|
meta.metaUpdatedAtMs = rs.getLong("meta_updated_at_ms");
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException ignored) {
|
||||||
|
// fallback ниже для окружений до миграции.
|
||||||
|
}
|
||||||
|
|
||||||
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
|
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
@@ -68,21 +96,29 @@ final class ChannelsReadSupport {
|
|||||||
BodyRecord body = e.body;
|
BodyRecord body = e.body;
|
||||||
if (body instanceof CreateChannelBody ccb) {
|
if (body instanceof CreateChannelBody ccb) {
|
||||||
meta.channelName = ccb.channelName;
|
meta.channelName = ccb.channelName;
|
||||||
|
meta.displayName = ccb.channelName;
|
||||||
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||||
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
|
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
|
||||||
meta.channelTypeVersion = Short.toUnsignedInt(ccb.channelTypeVersion);
|
meta.channelTypeVersion = Short.toUnsignedInt(ccb.channelTypeVersion);
|
||||||
|
if (meta.channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
|
||||||
|
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(meta.channelDescription, meta.channelName);
|
||||||
|
meta.displayName = parsedMeta.title();
|
||||||
|
meta.channelDescription = parsedMeta.description();
|
||||||
|
meta.avaAr = parsedMeta.avaAr();
|
||||||
|
meta.avaSha256 = parsedMeta.avaSha256();
|
||||||
|
meta.avaSize = parsedMeta.avaSize();
|
||||||
|
meta.metaUpdatedAtMs = e.timestamp * 1000L;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
meta.channelName = null;
|
meta.channelName = null;
|
||||||
|
meta.displayName = null;
|
||||||
meta.channelDescription = "";
|
meta.channelDescription = "";
|
||||||
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
||||||
}
|
}
|
||||||
String updatedDescription = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
|
||||||
if (updatedDescription != null) {
|
|
||||||
meta.channelDescription = updatedDescription;
|
|
||||||
}
|
|
||||||
return meta;
|
return meta;
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
meta.channelName = null;
|
meta.channelName = null;
|
||||||
|
meta.displayName = null;
|
||||||
meta.channelDescription = "";
|
meta.channelDescription = "";
|
||||||
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
||||||
return meta;
|
return meta;
|
||||||
@@ -91,13 +127,17 @@ final class ChannelsReadSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static int countPosts(Connection c, String ownerBch, int lineCode) throws SQLException {
|
static int countPosts(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||||
String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?";
|
String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
||||||
ps.setInt(5, lineCode);
|
ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT);
|
||||||
|
ps.setInt(6, MsgSubType.TEXT_EXERCISE);
|
||||||
|
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
||||||
|
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
||||||
|
ps.setInt(9, lineCode);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
return rs.next() ? rs.getInt("cnt") : 0;
|
return rs.next() ? rs.getInt("cnt") : 0;
|
||||||
}
|
}
|
||||||
@@ -106,9 +146,9 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
||||||
ORDER BY block_number DESC
|
ORDER BY block_number DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""";
|
||||||
@@ -117,7 +157,11 @@ final class ChannelsReadSupport {
|
|||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
||||||
ps.setInt(5, lineCode);
|
ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT);
|
||||||
|
ps.setInt(6, MsgSubType.TEXT_EXERCISE);
|
||||||
|
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
||||||
|
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
||||||
|
ps.setInt(9, lineCode);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (!rs.next()) return null;
|
if (!rs.next()) return null;
|
||||||
PostBlock pb = new PostBlock();
|
PostBlock pb = new PostBlock();
|
||||||
@@ -133,7 +177,7 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
static PostBlock loadLastVersion(Connection c, String ownerBch, int originalBlockNumber, byte[] originalHash) throws SQLException {
|
static PostBlock loadLastVersion(Connection c, String ownerBch, int originalBlockNumber, byte[] originalHash) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
||||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||||
@@ -169,6 +213,10 @@ final class ChannelsReadSupport {
|
|||||||
ti.text = tlb.message;
|
ti.text = tlb.message;
|
||||||
} else if (e.body instanceof TextReplyBody trb) {
|
} else if (e.body instanceof TextReplyBody trb) {
|
||||||
ti.text = trb.message;
|
ti.text = trb.message;
|
||||||
|
} else if (e.body instanceof blockchain.body.TextRatingBody trb) {
|
||||||
|
ti.text = trb.message;
|
||||||
|
} else if (e.body instanceof blockchain.body.StatusActionBody sab) {
|
||||||
|
ti.text = sab.message;
|
||||||
} else if (e.body instanceof TextBody tb) {
|
} else if (e.body instanceof TextBody tb) {
|
||||||
ti.text = tb.message;
|
ti.text = tb.message;
|
||||||
}
|
}
|
||||||
@@ -183,7 +231,7 @@ final class ChannelsReadSupport {
|
|||||||
String sql = """
|
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
|
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
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
||||||
ORDER BY block_number
|
ORDER BY block_number
|
||||||
""" + order + " LIMIT ?";
|
""" + order + " LIMIT ?";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@@ -191,8 +239,12 @@ final class ChannelsReadSupport {
|
|||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
||||||
ps.setInt(5, lineCode);
|
ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT);
|
||||||
ps.setInt(6, limit);
|
ps.setInt(6, MsgSubType.TEXT_EXERCISE);
|
||||||
|
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
||||||
|
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
||||||
|
ps.setInt(9, lineCode);
|
||||||
|
ps.setInt(10, limit);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostBlock> out = new ArrayList<>();
|
List<PostBlock> out = new ArrayList<>();
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
@@ -257,21 +309,47 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
||||||
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
||||||
|
int likesCount = 0;
|
||||||
|
int repliesCount = 0;
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, bch);
|
ps.setString(1, bch);
|
||||||
ps.setInt(2, blockNumber);
|
ps.setInt(2, blockNumber);
|
||||||
ps.setBytes(3, blockHash);
|
ps.setBytes(3, blockHash);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (!rs.next()) return new int[] {0, 0};
|
if (rs.next()) {
|
||||||
return new int[] {rs.getInt("likes_count"), rs.getInt("replies_count")};
|
likesCount = rs.getInt("likes_count");
|
||||||
|
repliesCount = rs.getInt("replies_count");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
String ratingsSql = """
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM blocks
|
||||||
|
WHERE msg_type = ?
|
||||||
|
AND msg_sub_type = ?
|
||||||
|
AND to_bch_name = ?
|
||||||
|
AND to_block_number = ?
|
||||||
|
AND to_block_hash = ?
|
||||||
|
""";
|
||||||
|
int ratingsCount = 0;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(ratingsSql)) {
|
||||||
|
ps.setInt(1, MSG_TYPE_TEXT);
|
||||||
|
ps.setInt(2, MsgSubType.TEXT_RATING);
|
||||||
|
ps.setString(3, bch);
|
||||||
|
ps.setInt(4, blockNumber);
|
||||||
|
ps.setBytes(5, blockHash);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
ratingsCount = rs.getInt(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new int[] {likesCount, repliesCount, ratingsCount};
|
||||||
}
|
}
|
||||||
|
|
||||||
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||||
if (rootNumber == 0) {
|
if (rootNumber == 0) {
|
||||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, 0);
|
return "";
|
||||||
return fromCommand == null ? "" : fromCommand;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preferred source: persisted state (fast path, works for CreateChannelBody v2).
|
// Preferred source: persisted state (fast path, works for CreateChannelBody v2).
|
||||||
@@ -287,8 +365,7 @@ final class ChannelsReadSupport {
|
|||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (rs.next()) {
|
if (rs.next()) {
|
||||||
String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
|
String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
|
||||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
return saved;
|
||||||
return fromCommand == null ? saved : fromCommand;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (SQLException ignored) {
|
} catch (SQLException ignored) {
|
||||||
@@ -306,8 +383,6 @@ final class ChannelsReadSupport {
|
|||||||
BchBlockEntry e = new BchBlockEntry(bytes);
|
BchBlockEntry e = new BchBlockEntry(bytes);
|
||||||
BodyRecord body = e.body;
|
BodyRecord body = e.body;
|
||||||
if (body instanceof CreateChannelBody ccb) {
|
if (body instanceof CreateChannelBody ccb) {
|
||||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
|
||||||
if (fromCommand != null) return fromCommand;
|
|
||||||
return ccb.channelDescription == null ? "" : ccb.channelDescription;
|
return ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
@@ -363,45 +438,97 @@ final class ChannelsReadSupport {
|
|||||||
return meta.channelTypeVersion;
|
return meta.channelTypeVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String detectLatestDescriptionCommand(Connection c, String ownerBch, int lineCode) throws SQLException {
|
static List<MetaEvent> channelMetaEvents(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||||
String sql = """
|
List<MetaEvent> out = new ArrayList<>();
|
||||||
SELECT block_bytes
|
if (lineCode <= 0) return out;
|
||||||
|
ChannelMeta base = detectChannelMeta(c, ownerBch, lineCode);
|
||||||
|
String rootSql = """
|
||||||
|
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND block_number=?
|
||||||
ORDER BY block_number DESC
|
LIMIT 1
|
||||||
LIMIT 300
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(rootSql)) {
|
||||||
|
ps.setString(1, ownerBch);
|
||||||
|
ps.setInt(2, MSG_TYPE_TECH);
|
||||||
|
ps.setInt(3, 1);
|
||||||
|
ps.setInt(4, lineCode);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
MetaEvent event = new MetaEvent();
|
||||||
|
event.kind = "created";
|
||||||
|
event.login = rs.getString("login");
|
||||||
|
event.bchName = rs.getString("bch_name");
|
||||||
|
event.blockNumber = rs.getInt("block_number");
|
||||||
|
event.blockHash = rs.getBytes("block_hash");
|
||||||
|
event.lineStep = null;
|
||||||
|
try {
|
||||||
|
BchBlockEntry entry = new BchBlockEntry(rs.getBytes("block_bytes"));
|
||||||
|
event.createdAtMs = entry.timestamp * 1000L;
|
||||||
|
if (entry.body instanceof CreateChannelBody ccb) {
|
||||||
|
event.title = ccb.channelName;
|
||||||
|
event.description = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||||
|
if (Short.toUnsignedInt(ccb.channelTypeCode) == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
|
||||||
|
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(event.description, ccb.channelName);
|
||||||
|
event.title = parsedMeta.title();
|
||||||
|
event.description = parsedMeta.description();
|
||||||
|
event.avaAr = parsedMeta.avaAr();
|
||||||
|
event.avaSha256 = parsedMeta.avaSha256();
|
||||||
|
event.avaSize = parsedMeta.avaSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
event.createdAtMs = 0L;
|
||||||
|
}
|
||||||
|
if (event.title == null || event.title.isBlank()) {
|
||||||
|
event.title = base.displayName == null || base.displayName.isBlank() ? base.channelName : base.displayName;
|
||||||
|
}
|
||||||
|
if (event.description == null) event.description = "";
|
||||||
|
if (event.avaAr == null) event.avaAr = "";
|
||||||
|
if (event.avaSha256 == null) event.avaSha256 = "";
|
||||||
|
out.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||||
|
FROM blocks
|
||||||
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
|
||||||
|
ORDER BY block_number ASC
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
ps.setInt(3, MsgSubType.TEXT_CHANNEL_META);
|
||||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
ps.setInt(4, lineCode);
|
||||||
ps.setInt(5, lineCode);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
TextInfo info = parseTextAndTime(rs.getBytes("block_bytes"));
|
byte[] bytes = rs.getBytes("block_bytes");
|
||||||
CommandInfo commandInfo = parseCommandText(info.text);
|
TextInfo info = parseTextAndTime(bytes);
|
||||||
if (commandInfo != null && COMMAND_DESC.equals(commandInfo.command)) {
|
try {
|
||||||
return commandInfo.arg;
|
ChannelMetaTextParser.Parsed parsed = ChannelMetaTextParser.parseStrict(info.text, base.channelName);
|
||||||
|
MetaEvent event = new MetaEvent();
|
||||||
|
event.kind = "profile_updated";
|
||||||
|
event.login = rs.getString("login");
|
||||||
|
event.bchName = rs.getString("bch_name");
|
||||||
|
event.blockNumber = rs.getInt("block_number");
|
||||||
|
event.blockHash = rs.getBytes("block_hash");
|
||||||
|
event.lineStep = (Integer) rs.getObject("this_line_number");
|
||||||
|
event.createdAtMs = info.createdAtMs;
|
||||||
|
event.title = parsed.title();
|
||||||
|
event.description = parsed.description();
|
||||||
|
event.avaAr = parsed.avaAr();
|
||||||
|
event.avaSha256 = parsed.avaSha256();
|
||||||
|
event.avaSize = parsed.avaSize();
|
||||||
|
out.add(event);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Невалидный meta-блок не участвует в системной истории UI.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return out;
|
||||||
}
|
|
||||||
|
|
||||||
static CommandInfo parseCommandText(String text) {
|
|
||||||
String value = String.valueOf(text == null ? "" : text).trim();
|
|
||||||
if (!value.startsWith(COMMAND_PREFIX)) return null;
|
|
||||||
|
|
||||||
String raw = value.substring(COMMAND_PREFIX.length()).trim();
|
|
||||||
if (raw.isEmpty()) return null;
|
|
||||||
|
|
||||||
int sp = raw.indexOf(' ');
|
|
||||||
String cmd = (sp < 0 ? raw : raw.substring(0, sp)).trim().toLowerCase();
|
|
||||||
String arg = sp < 0 ? "" : raw.substring(sp + 1).trim();
|
|
||||||
if (cmd.isEmpty()) return null;
|
|
||||||
return new CommandInfo(cmd, arg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException {
|
static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException {
|
||||||
@@ -506,6 +633,22 @@ final class ChannelsReadSupport {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static boolean isChannelFeedSubType(int subType) {
|
||||||
|
return subType == MsgSubType.TEXT_POST
|
||||||
|
|| subType == MsgSubType.TEXT_REPOST
|
||||||
|
|| subType == MsgSubType.TEXT_ENTRYPOINT
|
||||||
|
|| subType == MsgSubType.TEXT_EXERCISE
|
||||||
|
|| subType == MsgSubType.TEXT_SERVICE
|
||||||
|
|| subType == MsgSubType.TEXT_COURSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean supportsEditPostVersions(int subType) {
|
||||||
|
return subType == MsgSubType.TEXT_POST
|
||||||
|
|| subType == MsgSubType.TEXT_EXERCISE
|
||||||
|
|| subType == MsgSubType.TEXT_SERVICE
|
||||||
|
|| subType == MsgSubType.TEXT_COURSE;
|
||||||
|
}
|
||||||
|
|
||||||
static final class PostBlock {
|
static final class PostBlock {
|
||||||
String login;
|
String login;
|
||||||
String bchName;
|
String bchName;
|
||||||
@@ -526,19 +669,29 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
static final class ChannelMeta {
|
static final class ChannelMeta {
|
||||||
String channelName;
|
String channelName;
|
||||||
|
String displayName;
|
||||||
String channelDescription;
|
String channelDescription;
|
||||||
int channelTypeCode;
|
int channelTypeCode;
|
||||||
int channelTypeVersion;
|
int channelTypeVersion;
|
||||||
|
String avaAr = "";
|
||||||
|
String avaSha256 = "";
|
||||||
|
long avaSize = 0L;
|
||||||
|
long metaUpdatedAtMs = 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class CommandInfo {
|
static final class MetaEvent {
|
||||||
final String command;
|
String kind;
|
||||||
final String arg;
|
String login;
|
||||||
|
String bchName;
|
||||||
CommandInfo(String command, String arg) {
|
int blockNumber;
|
||||||
this.command = command;
|
byte[] blockHash;
|
||||||
this.arg = arg;
|
Integer lineStep;
|
||||||
}
|
long createdAtMs;
|
||||||
|
String title;
|
||||||
|
String description;
|
||||||
|
String avaAr;
|
||||||
|
String avaSha256;
|
||||||
|
long avaSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class PairChannelSelector {
|
static final class PairChannelSelector {
|
||||||
|
|||||||
+28
-1
@@ -57,7 +57,12 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
|
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
|
||||||
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode);
|
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode);
|
||||||
channel.setChannelName(meta.channelName);
|
channel.setChannelName(meta.channelName);
|
||||||
|
channel.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
|
||||||
channel.setChannelDescription(meta.channelDescription);
|
channel.setChannelDescription(meta.channelDescription);
|
||||||
|
channel.setAvaAr(meta.avaAr);
|
||||||
|
channel.setAvaSha256(meta.avaSha256);
|
||||||
|
channel.setAvaSize(meta.avaSize);
|
||||||
|
channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
|
||||||
channel.setChannelTypeCode(meta.channelTypeCode);
|
channel.setChannelTypeCode(meta.channelTypeCode);
|
||||||
channel.setChannelTypeVersion(meta.channelTypeVersion);
|
channel.setChannelTypeVersion(meta.channelTypeVersion);
|
||||||
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||||
@@ -66,6 +71,27 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
channel.setChannelRoot(rootRef);
|
channel.setChannelRoot(rootRef);
|
||||||
resp.setChannel(channel);
|
resp.setChannel(channel);
|
||||||
|
|
||||||
|
List<Net_GetChannelMessages_Response.ChannelMetaEvent> metaEvents = new ArrayList<>();
|
||||||
|
for (ChannelsReadSupport.MetaEvent event : ChannelsReadSupport.channelMetaEvents(c, ownerBch, lineCode)) {
|
||||||
|
Net_GetChannelMessages_Response.ChannelMetaEvent outEvent = new Net_GetChannelMessages_Response.ChannelMetaEvent();
|
||||||
|
outEvent.setKind(event.kind);
|
||||||
|
Net_GetChannelMessages_Response.BlockRef eventRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||||
|
eventRef.setBlockNumber(event.blockNumber);
|
||||||
|
eventRef.setBlockHash(ChannelsReadSupport.toHex(event.blockHash));
|
||||||
|
outEvent.setMessageRef(eventRef);
|
||||||
|
outEvent.setAuthorLogin(event.login);
|
||||||
|
outEvent.setAuthorBlockchainName(event.bchName);
|
||||||
|
outEvent.setLineStep(event.lineStep);
|
||||||
|
outEvent.setCreatedAtMs(event.createdAtMs);
|
||||||
|
outEvent.setTitle(event.title);
|
||||||
|
outEvent.setDescription(event.description);
|
||||||
|
outEvent.setAvaAr(event.avaAr);
|
||||||
|
outEvent.setAvaSha256(event.avaSha256);
|
||||||
|
outEvent.setAvaSize(event.avaSize);
|
||||||
|
metaEvents.add(outEvent);
|
||||||
|
}
|
||||||
|
resp.setMetaEvents(metaEvents);
|
||||||
|
|
||||||
List<ChannelsReadSupport.PostBlock> posts = new ArrayList<>(
|
List<ChannelsReadSupport.PostBlock> posts = new ArrayList<>(
|
||||||
ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc)
|
ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc)
|
||||||
);
|
);
|
||||||
@@ -117,7 +143,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
v1.setCreatedAtMs(postText.createdAtMs);
|
v1.setCreatedAtMs(postText.createdAtMs);
|
||||||
versionsOut.add(v1);
|
versionsOut.add(v1);
|
||||||
|
|
||||||
if (post.msgSubType == MsgSubType.TEXT_POST) {
|
if (ChannelsReadSupport.supportsEditPostVersions(post.msgSubType)) {
|
||||||
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
|
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
|
||||||
for (ChannelsReadSupport.PostBlock edit : edits) {
|
for (ChannelsReadSupport.PostBlock edit : edits) {
|
||||||
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||||
@@ -141,6 +167,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
||||||
item.setLikesCount(stats[0]);
|
item.setLikesCount(stats[0]);
|
||||||
item.setRepliesCount(stats[1]);
|
item.setRepliesCount(stats[1]);
|
||||||
|
item.setRatingsCount(stats[2]);
|
||||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||||
|
|
||||||
items.add(item);
|
items.add(item);
|
||||||
|
|||||||
+22
-11
@@ -17,6 +17,7 @@ import shine.db.DbController;
|
|||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -89,7 +90,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception {
|
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception {
|
||||||
if (depthDown <= 0) return List.of();
|
if (depthDown <= 0) return List.of();
|
||||||
List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
|
List<PostRow> replies = findRepliesAndRatings(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
|
||||||
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
|
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
|
||||||
for (PostRow row : replies) {
|
for (PostRow row : replies) {
|
||||||
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
|
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
|
||||||
@@ -100,24 +101,29 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<PostRow> findReplies(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
|
private List<PostRow> findRepliesAndRatings(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE msg_type=1 AND msg_sub_type=?
|
WHERE msg_type=1 AND msg_sub_type IN (?, ?)
|
||||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||||
ORDER BY block_number ASC
|
|
||||||
LIMIT ?
|
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setInt(1, MsgSubType.TEXT_REPLY);
|
ps.setInt(1, MsgSubType.TEXT_REPLY);
|
||||||
ps.setString(2, toBchName);
|
ps.setInt(2, MsgSubType.TEXT_RATING);
|
||||||
ps.setInt(3, toBlockNumber);
|
ps.setString(3, toBchName);
|
||||||
ps.setBytes(4, toBlockHash);
|
ps.setInt(4, toBlockNumber);
|
||||||
ps.setInt(5, limit);
|
ps.setBytes(5, toBlockHash);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostRow> out = new ArrayList<>();
|
List<PostRow> out = new ArrayList<>();
|
||||||
while (rs.next()) out.add(mapRow(rs));
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
out.sort(Comparator
|
||||||
|
.comparingLong((PostRow row) -> ChannelsReadSupport.parseTextAndTime(row.blockBytes).createdAtMs)
|
||||||
|
.thenComparing(row -> String.valueOf(row.bchName))
|
||||||
|
.thenComparingInt(row -> row.blockNumber));
|
||||||
|
if (out.size() > limit) {
|
||||||
|
return new ArrayList<>(out.subList(0, limit));
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,8 +214,12 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
first.setCreatedAtMs(base.createdAtMs);
|
first.setCreatedAtMs(base.createdAtMs);
|
||||||
versions.add(first);
|
versions.add(first);
|
||||||
|
|
||||||
if (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_POST) {
|
if (row.msgSubType == MsgSubType.TEXT_REPLY
|
||||||
short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST;
|
|| row.msgSubType == MsgSubType.TEXT_RATING
|
||||||
|
|| ChannelsReadSupport.supportsEditPostVersions(row.msgSubType)) {
|
||||||
|
short editType = (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_RATING)
|
||||||
|
? MsgSubType.TEXT_EDIT_REPLY
|
||||||
|
: MsgSubType.TEXT_EDIT_POST;
|
||||||
for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
|
for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
|
||||||
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||||
Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem();
|
Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem();
|
||||||
@@ -233,6 +243,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
||||||
node.setLikesCount(stats[0]);
|
node.setLikesCount(stats[0]);
|
||||||
node.setRepliesCount(stats[1]);
|
node.setRepliesCount(stats[1]);
|
||||||
|
node.setRatingsCount(stats[2]);
|
||||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||||
if (row.lineCode != null && row.lineCode >= 0) {
|
if (row.lineCode != null && row.lineCode >= 0) {
|
||||||
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
||||||
|
|||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import blockchain.BchBlockEntry;
|
||||||
|
import blockchain.body.StatusActionBody;
|
||||||
|
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_GetChannelMessages_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
|
||||||
|
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_GetPersonalDiary_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_GetPersonalDiary_Handler.class);
|
||||||
|
private static final String DIARY_CHANNEL_NAME = "diary";
|
||||||
|
private static final String DIARY_DISPLAY_NAME = "Личный дневник";
|
||||||
|
private static final String DIARY_DESCRIPTION = "История ваших действий по упражнениям, услугам и курсам";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_GetPersonalDiary_Request req = (Net_GetPersonalDiary_Request) baseRequest;
|
||||||
|
String requestedLogin = String.valueOf(req.getLogin() == null ? "" : req.getLogin()).trim();
|
||||||
|
if (requestedLogin.isBlank() && (ctx == null || ctx.getLogin() == null || ctx.getLogin().isBlank())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login");
|
||||||
|
}
|
||||||
|
|
||||||
|
int limit = req.getLimit() == null ? 200 : req.getLimit();
|
||||||
|
if (limit <= 0 || limit > 1000) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
|
||||||
|
}
|
||||||
|
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||||
|
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
String viewerLogin = ctx != null ? String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim() : "";
|
||||||
|
String canonicalLogin = !viewerLogin.isBlank()
|
||||||
|
? viewerLogin
|
||||||
|
: ChannelsReadSupport.canonicalLogin(c, requestedLogin);
|
||||||
|
if (canonicalLogin == null || canonicalLogin.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден");
|
||||||
|
}
|
||||||
|
if (!viewerLogin.isBlank() && !viewerLogin.equalsIgnoreCase(canonicalLogin)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 403, "forbidden", "Личный дневник доступен только владельцу");
|
||||||
|
}
|
||||||
|
|
||||||
|
String ownerBch = loadPrimaryBlockchainName(c, canonicalLogin);
|
||||||
|
if (ownerBch == null || ownerBch.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "blockchain_not_found", "Не найден blockchain пользователя");
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_GetChannelMessages_Response resp = new Net_GetChannelMessages_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
|
||||||
|
Net_GetChannelMessages_Response.Channel channel = new Net_GetChannelMessages_Response.Channel();
|
||||||
|
channel.setOwnerLogin(canonicalLogin);
|
||||||
|
channel.setOwnerBlockchainName(ownerBch);
|
||||||
|
channel.setChannelName(DIARY_CHANNEL_NAME);
|
||||||
|
channel.setDisplayName(DIARY_DISPLAY_NAME);
|
||||||
|
channel.setChannelDescription(DIARY_DESCRIPTION);
|
||||||
|
channel.setChannelTypeCode(900);
|
||||||
|
channel.setChannelTypeVersion(1);
|
||||||
|
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||||
|
rootRef.setBlockNumber(0);
|
||||||
|
rootRef.setBlockHash(ChannelsReadSupport.toHex(new byte[32]));
|
||||||
|
channel.setChannelRoot(rootRef);
|
||||||
|
resp.setChannel(channel);
|
||||||
|
resp.setMetaEvents(new ArrayList<>());
|
||||||
|
|
||||||
|
List<Net_GetChannelMessages_Response.MessageItem> items = loadDiaryItems(c, canonicalLogin, limit, asc);
|
||||||
|
resp.setMessages(items);
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("GetPersonalDiary failed", e);
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String loadPrimaryBlockchainName(Connection c, String canonicalLogin) throws Exception {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT blockchain_name
|
||||||
|
FROM blockchain_state
|
||||||
|
WHERE login = ?
|
||||||
|
ORDER BY blockchain_name
|
||||||
|
LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, canonicalLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getString("blockchain_name") : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Net_GetChannelMessages_Response.MessageItem> loadDiaryItems(Connection c, String canonicalLogin, int limit, boolean asc) throws Exception {
|
||||||
|
String order = asc ? "ASC" : "DESC";
|
||||||
|
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
|
||||||
|
FROM blocks
|
||||||
|
WHERE login = ? AND msg_type = ?
|
||||||
|
ORDER BY block_number
|
||||||
|
""" + order + " LIMIT ?")) {
|
||||||
|
ps.setString(1, canonicalLogin);
|
||||||
|
ps.setInt(2, ChannelsReadSupport.MSG_TYPE_STATUS_ACTION);
|
||||||
|
ps.setInt(3, limit);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
byte[] blockBytes = rs.getBytes("block_bytes");
|
||||||
|
BchBlockEntry entry = new BchBlockEntry(blockBytes);
|
||||||
|
if (!(entry.body instanceof StatusActionBody statusBody)) continue;
|
||||||
|
|
||||||
|
Net_GetChannelMessages_Response.MessageItem item = new Net_GetChannelMessages_Response.MessageItem();
|
||||||
|
Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef();
|
||||||
|
ref.setBlockNumber(rs.getInt("block_number"));
|
||||||
|
ref.setBlockHash(ChannelsReadSupport.toHex(rs.getBytes("block_hash")));
|
||||||
|
item.setMessageRef(ref);
|
||||||
|
item.setMsgSubType(rs.getInt("msg_sub_type"));
|
||||||
|
item.setAuthorLogin(rs.getString("login"));
|
||||||
|
item.setAuthorBlockchainName(rs.getString("bch_name"));
|
||||||
|
item.setCreatedAtMs(entry.timestamp * 1000L);
|
||||||
|
item.setLikesCount(0);
|
||||||
|
item.setLikedByMe(false);
|
||||||
|
item.setRepliesCount(0);
|
||||||
|
item.setRatingsCount(0);
|
||||||
|
item.setTargetBlockchainName(statusBody.toBchName());
|
||||||
|
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
|
||||||
|
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
|
||||||
|
|
||||||
|
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
|
||||||
|
c,
|
||||||
|
rs.getString("bch_name"),
|
||||||
|
rs.getInt("block_number"),
|
||||||
|
rs.getBytes("block_hash"),
|
||||||
|
statusBody.message == null ? "" : statusBody.message,
|
||||||
|
entry.timestamp * 1000L
|
||||||
|
);
|
||||||
|
item.setVersions(versions);
|
||||||
|
item.setVersionsTotal(versions.size());
|
||||||
|
item.setText(versions.get(versions.size() - 1).getText());
|
||||||
|
|
||||||
|
fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes());
|
||||||
|
out.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Net_GetChannelMessages_Response.VersionItem> loadVersionsForDiaryItem(Connection c,
|
||||||
|
String ownerBch,
|
||||||
|
int originalBlockNumber,
|
||||||
|
byte[] originalBlockHash,
|
||||||
|
String originalText,
|
||||||
|
long originalCreatedAtMs) throws Exception {
|
||||||
|
List<Net_GetChannelMessages_Response.VersionItem> versions = new ArrayList<>();
|
||||||
|
|
||||||
|
Net_GetChannelMessages_Response.VersionItem first = new Net_GetChannelMessages_Response.VersionItem();
|
||||||
|
first.setVersionIndex(1);
|
||||||
|
first.setBlockNumber(originalBlockNumber);
|
||||||
|
first.setBlockHash(ChannelsReadSupport.toHex(originalBlockHash));
|
||||||
|
first.setText(originalText == null ? "" : originalText);
|
||||||
|
first.setCreatedAtMs(originalCreatedAtMs);
|
||||||
|
versions.add(first);
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT block_number, block_hash, block_bytes
|
||||||
|
FROM blocks
|
||||||
|
WHERE bch_name = ?
|
||||||
|
AND msg_type = ?
|
||||||
|
AND msg_sub_type = ?
|
||||||
|
AND to_block_number = ?
|
||||||
|
AND to_block_hash = ?
|
||||||
|
AND (to_bch_name = ? OR to_bch_name IS NULL OR to_bch_name = '')
|
||||||
|
ORDER BY block_number ASC
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerBch);
|
||||||
|
ps.setInt(2, ChannelsReadSupport.MSG_TYPE_TEXT);
|
||||||
|
ps.setInt(3, shine.db.MsgSubType.TEXT_EDIT_REPLY);
|
||||||
|
ps.setInt(4, originalBlockNumber);
|
||||||
|
ps.setBytes(5, originalBlockHash);
|
||||||
|
ps.setString(6, ownerBch);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(rs.getBytes("block_bytes"));
|
||||||
|
Net_GetChannelMessages_Response.VersionItem item = new Net_GetChannelMessages_Response.VersionItem();
|
||||||
|
item.setVersionIndex(versions.size() + 1);
|
||||||
|
item.setBlockNumber(rs.getInt("block_number"));
|
||||||
|
item.setBlockHash(ChannelsReadSupport.toHex(rs.getBytes("block_hash")));
|
||||||
|
item.setText(editText.text);
|
||||||
|
item.setCreatedAtMs(editText.createdAtMs);
|
||||||
|
versions.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillTargetDetails(Connection c,
|
||||||
|
Net_GetChannelMessages_Response.MessageItem item,
|
||||||
|
String targetBch,
|
||||||
|
Integer targetBlockNumber,
|
||||||
|
byte[] targetHash) throws Exception {
|
||||||
|
if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
|
||||||
|
FROM blocks
|
||||||
|
WHERE bch_name = ? AND block_number = ?
|
||||||
|
LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, targetBch);
|
||||||
|
ps.setInt(2, targetBlockNumber);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return;
|
||||||
|
byte[] actualHash = rs.getBytes("block_hash");
|
||||||
|
if (actualHash == null || !java.util.Arrays.equals(actualHash, targetHash)) return;
|
||||||
|
item.setTargetMsgSubType(rs.getInt("msg_sub_type"));
|
||||||
|
item.setTargetAuthorLogin(rs.getString("login"));
|
||||||
|
item.setTargetAuthorBlockchainName(rs.getString("bch_name"));
|
||||||
|
ChannelsReadSupport.TextInfo textInfo = ChannelsReadSupport.parseTextAndTime(rs.getBytes("block_bytes"));
|
||||||
|
item.setTargetText(textInfo.text);
|
||||||
|
item.setTargetCreatedAtMs(textInfo.createdAtMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -65,7 +65,12 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
channelRef.setOwnerLogin(key.ownerLogin);
|
channelRef.setOwnerLogin(key.ownerLogin);
|
||||||
channelRef.setOwnerBlockchainName(key.ownerBch);
|
channelRef.setOwnerBlockchainName(key.ownerBch);
|
||||||
channelRef.setChannelName(meta.channelName);
|
channelRef.setChannelName(meta.channelName);
|
||||||
|
channelRef.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
|
||||||
channelRef.setChannelDescription(meta.channelDescription);
|
channelRef.setChannelDescription(meta.channelDescription);
|
||||||
|
channelRef.setAvaAr(meta.avaAr);
|
||||||
|
channelRef.setAvaSha256(meta.avaSha256);
|
||||||
|
channelRef.setAvaSize(meta.avaSize);
|
||||||
|
channelRef.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
|
||||||
channelRef.setChannelTypeCode(meta.channelTypeCode);
|
channelRef.setChannelTypeCode(meta.channelTypeCode);
|
||||||
channelRef.setChannelTypeVersion(meta.channelTypeVersion);
|
channelRef.setChannelTypeVersion(meta.channelTypeVersion);
|
||||||
channelRef.setPersonal(meta.channelTypeCode == 100);
|
channelRef.setPersonal(meta.channelTypeCode == 100);
|
||||||
|
|||||||
+7
-2
@@ -58,7 +58,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
|||||||
AND block_number = ?
|
AND block_number = ?
|
||||||
AND block_hash = ?
|
AND block_hash = ?
|
||||||
AND msg_type = ?
|
AND msg_type = ?
|
||||||
AND msg_sub_type = ?
|
AND msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||||
%s
|
%s
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""".formatted(strictChannelMatch ? "AND line_code = ?" : "");
|
""".formatted(strictChannelMatch ? "AND line_code = ?" : "");
|
||||||
@@ -98,8 +98,13 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
|||||||
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
|
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
|
||||||
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
|
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
|
||||||
existsPs.setInt(5, MsgSubType.TEXT_POST);
|
existsPs.setInt(5, MsgSubType.TEXT_POST);
|
||||||
|
existsPs.setInt(6, MsgSubType.TEXT_REPOST);
|
||||||
|
existsPs.setInt(7, MsgSubType.TEXT_ENTRYPOINT);
|
||||||
|
existsPs.setInt(8, MsgSubType.TEXT_EXERCISE);
|
||||||
|
existsPs.setInt(9, MsgSubType.TEXT_SERVICE);
|
||||||
|
existsPs.setInt(10, MsgSubType.TEXT_COURSE);
|
||||||
if (strictChannelMatch) {
|
if (strictChannelMatch) {
|
||||||
existsPs.setInt(6, expectedRoot);
|
existsPs.setInt(11, expectedRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean exists;
|
boolean exists;
|
||||||
|
|||||||
+95
@@ -8,6 +8,7 @@ import java.util.List;
|
|||||||
public class Net_GetChannelMessages_Response extends Net_Response {
|
public class Net_GetChannelMessages_Response extends Net_Response {
|
||||||
private Channel channel;
|
private Channel channel;
|
||||||
private List<MessageItem> messages = new ArrayList<>();
|
private List<MessageItem> messages = new ArrayList<>();
|
||||||
|
private List<ChannelMetaEvent> metaEvents = new ArrayList<>();
|
||||||
|
|
||||||
public Channel getChannel() { return channel; }
|
public Channel getChannel() { return channel; }
|
||||||
public void setChannel(Channel channel) { this.channel = channel; }
|
public void setChannel(Channel channel) { this.channel = channel; }
|
||||||
@@ -15,12 +16,20 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public List<MessageItem> getMessages() { return messages; }
|
public List<MessageItem> getMessages() { return messages; }
|
||||||
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||||
|
|
||||||
|
public List<ChannelMetaEvent> getMetaEvents() { return metaEvents; }
|
||||||
|
public void setMetaEvents(List<ChannelMetaEvent> metaEvents) { this.metaEvents = metaEvents; }
|
||||||
|
|
||||||
|
|
||||||
public static class Channel {
|
public static class Channel {
|
||||||
private String ownerLogin;
|
private String ownerLogin;
|
||||||
private String ownerBlockchainName;
|
private String ownerBlockchainName;
|
||||||
private String channelName;
|
private String channelName;
|
||||||
|
private String displayName;
|
||||||
private String channelDescription;
|
private String channelDescription;
|
||||||
|
private String avaAr;
|
||||||
|
private String avaSha256;
|
||||||
|
private Long avaSize;
|
||||||
|
private Long metaUpdatedAtMs;
|
||||||
private Integer channelTypeCode;
|
private Integer channelTypeCode;
|
||||||
private Integer channelTypeVersion;
|
private Integer channelTypeVersion;
|
||||||
private BlockRef channelRoot;
|
private BlockRef channelRoot;
|
||||||
@@ -34,9 +43,24 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public String getChannelName() { return channelName; }
|
public String getChannelName() { return channelName; }
|
||||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||||
|
|
||||||
|
public String getDisplayName() { return displayName; }
|
||||||
|
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||||
|
|
||||||
public String getChannelDescription() { return channelDescription; }
|
public String getChannelDescription() { return channelDescription; }
|
||||||
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
||||||
|
|
||||||
|
public String getAvaAr() { return avaAr; }
|
||||||
|
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
|
||||||
|
|
||||||
|
public String getAvaSha256() { return avaSha256; }
|
||||||
|
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
|
||||||
|
|
||||||
|
public Long getAvaSize() { return avaSize; }
|
||||||
|
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
|
||||||
|
|
||||||
|
public Long getMetaUpdatedAtMs() { return metaUpdatedAtMs; }
|
||||||
|
public void setMetaUpdatedAtMs(Long metaUpdatedAtMs) { this.metaUpdatedAtMs = metaUpdatedAtMs; }
|
||||||
|
|
||||||
public Integer getChannelTypeCode() { return channelTypeCode; }
|
public Integer getChannelTypeCode() { return channelTypeCode; }
|
||||||
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
|
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
|
||||||
|
|
||||||
@@ -47,6 +71,53 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class ChannelMetaEvent {
|
||||||
|
private String kind;
|
||||||
|
private BlockRef messageRef;
|
||||||
|
private String authorLogin;
|
||||||
|
private String authorBlockchainName;
|
||||||
|
private Integer lineStep;
|
||||||
|
private long createdAtMs;
|
||||||
|
private String title;
|
||||||
|
private String description;
|
||||||
|
private String avaAr;
|
||||||
|
private String avaSha256;
|
||||||
|
private Long avaSize;
|
||||||
|
|
||||||
|
public String getKind() { return kind; }
|
||||||
|
public void setKind(String kind) { this.kind = kind; }
|
||||||
|
|
||||||
|
public BlockRef getMessageRef() { return messageRef; }
|
||||||
|
public void setMessageRef(BlockRef messageRef) { this.messageRef = messageRef; }
|
||||||
|
|
||||||
|
public String getAuthorLogin() { return authorLogin; }
|
||||||
|
public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; }
|
||||||
|
|
||||||
|
public String getAuthorBlockchainName() { return authorBlockchainName; }
|
||||||
|
public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; }
|
||||||
|
|
||||||
|
public Integer getLineStep() { return lineStep; }
|
||||||
|
public void setLineStep(Integer lineStep) { this.lineStep = lineStep; }
|
||||||
|
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
|
||||||
|
public String getAvaAr() { return avaAr; }
|
||||||
|
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
|
||||||
|
|
||||||
|
public String getAvaSha256() { return avaSha256; }
|
||||||
|
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
|
||||||
|
|
||||||
|
public Long getAvaSize() { return avaSize; }
|
||||||
|
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
|
||||||
|
}
|
||||||
|
|
||||||
public static class MessageItem {
|
public static class MessageItem {
|
||||||
private BlockRef messageRef;
|
private BlockRef messageRef;
|
||||||
private Integer msgSubType;
|
private Integer msgSubType;
|
||||||
@@ -56,11 +127,17 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
private String targetBlockchainName;
|
private String targetBlockchainName;
|
||||||
private Integer targetBlockNumber;
|
private Integer targetBlockNumber;
|
||||||
private String targetBlockHash;
|
private String targetBlockHash;
|
||||||
|
private Integer targetMsgSubType;
|
||||||
|
private String targetText;
|
||||||
|
private String targetAuthorLogin;
|
||||||
|
private String targetAuthorBlockchainName;
|
||||||
|
private Long targetCreatedAtMs;
|
||||||
private long createdAtMs;
|
private long createdAtMs;
|
||||||
private String text;
|
private String text;
|
||||||
private int likesCount;
|
private int likesCount;
|
||||||
private boolean likedByMe;
|
private boolean likedByMe;
|
||||||
private int repliesCount;
|
private int repliesCount;
|
||||||
|
private int ratingsCount;
|
||||||
private int versionsTotal;
|
private int versionsTotal;
|
||||||
private List<VersionItem> versions = new ArrayList<>();
|
private List<VersionItem> versions = new ArrayList<>();
|
||||||
|
|
||||||
@@ -87,6 +164,21 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public String getTargetBlockHash() { return targetBlockHash; }
|
public String getTargetBlockHash() { return targetBlockHash; }
|
||||||
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||||
|
|
||||||
|
public Integer getTargetMsgSubType() { return targetMsgSubType; }
|
||||||
|
public void setTargetMsgSubType(Integer targetMsgSubType) { this.targetMsgSubType = targetMsgSubType; }
|
||||||
|
|
||||||
|
public String getTargetText() { return targetText; }
|
||||||
|
public void setTargetText(String targetText) { this.targetText = targetText; }
|
||||||
|
|
||||||
|
public String getTargetAuthorLogin() { return targetAuthorLogin; }
|
||||||
|
public void setTargetAuthorLogin(String targetAuthorLogin) { this.targetAuthorLogin = targetAuthorLogin; }
|
||||||
|
|
||||||
|
public String getTargetAuthorBlockchainName() { return targetAuthorBlockchainName; }
|
||||||
|
public void setTargetAuthorBlockchainName(String targetAuthorBlockchainName) { this.targetAuthorBlockchainName = targetAuthorBlockchainName; }
|
||||||
|
|
||||||
|
public Long getTargetCreatedAtMs() { return targetCreatedAtMs; }
|
||||||
|
public void setTargetCreatedAtMs(Long targetCreatedAtMs) { this.targetCreatedAtMs = targetCreatedAtMs; }
|
||||||
|
|
||||||
public long getCreatedAtMs() { return createdAtMs; }
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
|
||||||
@@ -102,6 +194,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public int getRepliesCount() { return repliesCount; }
|
public int getRepliesCount() { return repliesCount; }
|
||||||
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
||||||
|
|
||||||
|
public int getRatingsCount() { return ratingsCount; }
|
||||||
|
public void setRatingsCount(int ratingsCount) { this.ratingsCount = ratingsCount; }
|
||||||
|
|
||||||
public int getVersionsTotal() { return versionsTotal; }
|
public int getVersionsTotal() { return versionsTotal; }
|
||||||
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
|
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
|
||||||
|
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_GetPersonalDiary_Request extends Net_Request {
|
||||||
|
private String login;
|
||||||
|
private Integer limit;
|
||||||
|
private String sort;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
|
||||||
|
public String getSort() { return sort; }
|
||||||
|
public void setSort(String sort) { this.sort = sort; }
|
||||||
|
}
|
||||||
+20
@@ -46,7 +46,12 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
private String ownerLogin;
|
private String ownerLogin;
|
||||||
private String ownerBlockchainName;
|
private String ownerBlockchainName;
|
||||||
private String channelName;
|
private String channelName;
|
||||||
|
private String displayName;
|
||||||
private String channelDescription;
|
private String channelDescription;
|
||||||
|
private String avaAr;
|
||||||
|
private String avaSha256;
|
||||||
|
private Long avaSize;
|
||||||
|
private Long metaUpdatedAtMs;
|
||||||
private Integer channelTypeCode;
|
private Integer channelTypeCode;
|
||||||
private Integer channelTypeVersion;
|
private Integer channelTypeVersion;
|
||||||
private boolean personal;
|
private boolean personal;
|
||||||
@@ -61,9 +66,24 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
public String getChannelName() { return channelName; }
|
public String getChannelName() { return channelName; }
|
||||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||||
|
|
||||||
|
public String getDisplayName() { return displayName; }
|
||||||
|
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||||
|
|
||||||
public String getChannelDescription() { return channelDescription; }
|
public String getChannelDescription() { return channelDescription; }
|
||||||
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
||||||
|
|
||||||
|
public String getAvaAr() { return avaAr; }
|
||||||
|
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
|
||||||
|
|
||||||
|
public String getAvaSha256() { return avaSha256; }
|
||||||
|
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
|
||||||
|
|
||||||
|
public Long getAvaSize() { return avaSize; }
|
||||||
|
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
|
||||||
|
|
||||||
|
public Long getMetaUpdatedAtMs() { return metaUpdatedAtMs; }
|
||||||
|
public void setMetaUpdatedAtMs(Long metaUpdatedAtMs) { this.metaUpdatedAtMs = metaUpdatedAtMs; }
|
||||||
|
|
||||||
public Integer getChannelTypeCode() { return channelTypeCode; }
|
public Integer getChannelTypeCode() { return channelTypeCode; }
|
||||||
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
|
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -35,10 +35,11 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String prefix = req.getPrefix().trim();
|
String prefix = req.getPrefix().trim();
|
||||||
|
Boolean isServer = req.getIsServer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
CurrentUsersDAO dao = CurrentUsersDAO.getInstance();
|
CurrentUsersDAO dao = CurrentUsersDAO.getInstance();
|
||||||
List<CurrentUserEntry> users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5
|
List<CurrentUserEntry> users = dao.searchByLoginPrefix(prefix, isServer); // case-insensitive + LIMIT 5
|
||||||
|
|
||||||
List<String> logins = new ArrayList<>();
|
List<String> logins = new ArrayList<>();
|
||||||
for (CurrentUserEntry u : users) {
|
for (CurrentUserEntry u : users) {
|
||||||
@@ -53,7 +54,7 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler {
|
|||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
resp.setLogins(logins);
|
resp.setLogins(logins);
|
||||||
|
|
||||||
log.info("✅ SearchUsers ok: prefix='{}' -> {}", prefix, logins.size());
|
log.info("✅ SearchUsers ok: prefix='{}', isServer={} -> {}", prefix, isServer, logins.size());
|
||||||
return resp;
|
return resp;
|
||||||
|
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
|
|||||||
+4
-1
@@ -18,7 +18,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
|||||||
public class Net_SearchUsers_Request extends Net_Request {
|
public class Net_SearchUsers_Request extends Net_Request {
|
||||||
|
|
||||||
private String prefix;
|
private String prefix;
|
||||||
|
private Boolean isServer;
|
||||||
|
|
||||||
public String getPrefix() { return prefix; }
|
public String getPrefix() { return prefix; }
|
||||||
public void setPrefix(String prefix) { this.prefix = prefix; }
|
public void setPrefix(String prefix) { this.prefix = prefix; }
|
||||||
}
|
public Boolean getIsServer() { return isServer; }
|
||||||
|
public void setIsServer(Boolean isServer) { this.isServer = isServer; }
|
||||||
|
}
|
||||||
|
|||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
import shine.db.dao.SignedMessagesDAO;
|
||||||
|
import shine.db.entities.SignedMessageEntry;
|
||||||
|
|
||||||
|
public final class DmSyncApplySupport {
|
||||||
|
private DmSyncApplySupport() {}
|
||||||
|
|
||||||
|
public static ApplyResult applySyncedBlob(String ownerLogin, String blobB64) throws Exception {
|
||||||
|
if (ownerLogin == null || ownerLogin.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("EMPTY_OWNER_LOGIN");
|
||||||
|
}
|
||||||
|
if (blobB64 == null || blobB64.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("EMPTY_BLOB");
|
||||||
|
}
|
||||||
|
|
||||||
|
SignedMessageBlock block = SignedMessagesCore.parseFromB64(blobB64);
|
||||||
|
SignedMessagesCore.verifyUsersAndSignature(block);
|
||||||
|
SignedMessageEntry entry = SignedMessagesCore.toEntry(block, "DmSyncBatch", null);
|
||||||
|
String owner = ownerLogin.trim();
|
||||||
|
boolean ownerMatchesTarget = entry.getTargetLogin().equalsIgnoreCase(owner);
|
||||||
|
boolean ownerMatchesDeletePair = block.isDeleteType()
|
||||||
|
&& (entry.getFromLogin().equalsIgnoreCase(owner) || entry.getToLogin().equalsIgnoreCase(owner));
|
||||||
|
if (!ownerMatchesTarget && !ownerMatchesDeletePair) {
|
||||||
|
throw new IllegalArgumentException("TARGET_LOGIN_MISMATCH");
|
||||||
|
}
|
||||||
|
|
||||||
|
SignedMessagesDAO.ApplyStatus status;
|
||||||
|
if (block.isContentType()) {
|
||||||
|
status = SignedMessagesDAO.getInstance().upsertIncomingCopy(entry);
|
||||||
|
} else if (block.isReadReceiptType()) {
|
||||||
|
status = SignedMessagesDAO.getInstance().insertIfAbsent(entry);
|
||||||
|
} else if (block.isMessageDeleteType()) {
|
||||||
|
status = SignedMessagesDAO.getInstance().applyDeleteMessage(entry);
|
||||||
|
} else if (block.isConversationDeleteType()) {
|
||||||
|
status = SignedMessagesDAO.getInstance().applyDeleteConversation(entry);
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ApplyResult(entry.getMessageKey(), entry.getBaseKey(), entry.getMessageType(), status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ApplyResult(
|
||||||
|
String messageKey,
|
||||||
|
String baseKey,
|
||||||
|
int messageType,
|
||||||
|
SignedMessagesDAO.ApplyStatus status
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+22
-3
@@ -26,6 +26,7 @@ import java.util.Set;
|
|||||||
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private static final int TYPE_INVITE = 100;
|
private static final int TYPE_INVITE = 100;
|
||||||
|
private static final int TYPE_CONNECT_START = 170;
|
||||||
private static final long PUSH_CALL_TTL_MS = 10_000L;
|
private static final long PUSH_CALL_TTL_MS = 10_000L;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -38,8 +39,11 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
|||||||
String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim();
|
String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim();
|
||||||
String callId = req.getCallId() == null ? "" : req.getCallId().trim();
|
String callId = req.getCallId() == null ? "" : req.getCallId().trim();
|
||||||
int type = req.getType() == null ? TYPE_INVITE : req.getType();
|
int type = req.getType() == null ? TYPE_INVITE : req.getType();
|
||||||
if (toRequest.isBlank() || callId.isBlank() || type != TYPE_INVITE) {
|
String data = req.getData() == null ? "" : req.getData().trim();
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны");
|
boolean isInvite = type == TYPE_INVITE;
|
||||||
|
boolean isConnectStart = type == TYPE_CONNECT_START;
|
||||||
|
if (toRequest.isBlank() || callId.isBlank() || (!isInvite && !isConnectStart)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100|170 обязательны");
|
||||||
}
|
}
|
||||||
|
|
||||||
CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest);
|
CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest);
|
||||||
@@ -69,13 +73,28 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
|||||||
payload.put("fromSessionId", ctx.getSessionId());
|
payload.put("fromSessionId", ctx.getSessionId());
|
||||||
payload.put("toLogin", to);
|
payload.put("toLogin", to);
|
||||||
payload.put("callId", callId);
|
payload.put("callId", callId);
|
||||||
payload.put("type", TYPE_INVITE);
|
payload.put("type", type);
|
||||||
payload.put("timeMs", timeMs);
|
payload.put("timeMs", timeMs);
|
||||||
|
if (!data.isBlank()) {
|
||||||
|
payload.put("data", data);
|
||||||
|
}
|
||||||
|
|
||||||
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload);
|
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload);
|
||||||
if (sent) wsDelivered++;
|
if (sent) wsDelivered++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isConnectStart) {
|
||||||
|
Net_CallInviteBroadcast_Response resp = new Net_CallInviteBroadcast_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setCallId(callId);
|
||||||
|
resp.setDeliveredWsSessions(wsDelivered);
|
||||||
|
resp.setDeliveredFcmSessions(0);
|
||||||
|
resp.setDeliveredWebPushSessions(0);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
for (ActiveSessionEntry session : allTargetSessions) {
|
for (ActiveSessionEntry session : allTargetSessions) {
|
||||||
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
|
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
|
||||||
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) {
|
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) {
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
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.messages.entyties.Net_DmSyncBatch_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.SignedMessagesDAO;
|
||||||
|
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||||
|
import shine.db.entities.SignedMessageEntry;
|
||||||
|
import shine.db.entities.UserAccessServerRouteEntry;
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
|
||||||
|
private static final int DEFAULT_LIMIT = 500;
|
||||||
|
private static final int MAX_LIMIT = 500;
|
||||||
|
private static final int DEFAULT_MAX_BYTES = 3_000_000;
|
||||||
|
private static final int MAX_BYTES_CAP = 5_000_000;
|
||||||
|
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
|
Net_DmSyncBatch_Request req = (Net_DmSyncBatch_Request) baseRequest;
|
||||||
|
String ownerLogin = normalizeOriginal(req.getOwnerLogin());
|
||||||
|
if (ownerLogin == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_OWNER_LOGIN", "ownerLogin обязателен");
|
||||||
|
}
|
||||||
|
|
||||||
|
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
|
||||||
|
if (ownServerLogin == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "LOCAL_SERVER_NOT_CONFIGURED", "server.SHiNE.login не настроен");
|
||||||
|
}
|
||||||
|
if (!isLocalAccessServer(ownerLogin, ownServerLogin)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 403, "LOCAL_SERVER_NOT_ACCESS_SERVER", "Локальный сервер не является access-сервером пользователя");
|
||||||
|
}
|
||||||
|
|
||||||
|
int limit = clamp(req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit(), 1, MAX_LIMIT);
|
||||||
|
int maxBytes = clamp(req.getMaxBytes() == null ? DEFAULT_MAX_BYTES : req.getMaxBytes(), 64_000, MAX_BYTES_CAP);
|
||||||
|
long afterStoredAtMs = Math.max(0L, req.getAfterStoredAtMs() == null ? 0L : req.getAfterStoredAtMs());
|
||||||
|
String afterMessageKey = req.getAfterMessageKey() == null ? "" : req.getAfterMessageKey().trim();
|
||||||
|
|
||||||
|
SignedMessagesDAO.SyncBatch batch = SignedMessagesDAO.getInstance().listSyncBatch(
|
||||||
|
ownerLogin,
|
||||||
|
afterStoredAtMs,
|
||||||
|
afterMessageKey,
|
||||||
|
limit,
|
||||||
|
maxBytes
|
||||||
|
);
|
||||||
|
|
||||||
|
Net_DmSyncBatch_Response resp = new Net_DmSyncBatch_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setOwnerLogin(ownerLogin);
|
||||||
|
resp.setLimit(limit);
|
||||||
|
resp.setRawBytes(batch.rawBytes());
|
||||||
|
resp.setHasMore(batch.hasMore());
|
||||||
|
resp.setNextStoredAtMs(afterStoredAtMs);
|
||||||
|
resp.setNextMessageKey(afterMessageKey);
|
||||||
|
|
||||||
|
List<Net_DmSyncBatch_Response.Item> items = new ArrayList<>();
|
||||||
|
Base64.Encoder encoder = Base64.getEncoder();
|
||||||
|
for (SignedMessageEntry entry : batch.items()) {
|
||||||
|
Net_DmSyncBatch_Response.Item item = new Net_DmSyncBatch_Response.Item();
|
||||||
|
item.setMessageKey(entry.getMessageKey());
|
||||||
|
item.setBaseKey(entry.getBaseKey());
|
||||||
|
item.setTargetLogin(entry.getTargetLogin());
|
||||||
|
item.setFromLogin(entry.getFromLogin());
|
||||||
|
item.setToLogin(entry.getToLogin());
|
||||||
|
item.setMessageType(entry.getMessageType());
|
||||||
|
item.setTimeMs(entry.getTimeMs());
|
||||||
|
item.setStoredAtMs(entry.getCreatedAtMs());
|
||||||
|
item.setBlobB64(encoder.encodeToString(entry.getRawBlock()));
|
||||||
|
items.add(item);
|
||||||
|
resp.setNextStoredAtMs(entry.getCreatedAtMs());
|
||||||
|
resp.setNextMessageKey(entry.getMessageKey());
|
||||||
|
}
|
||||||
|
resp.setItems(items);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception {
|
||||||
|
for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin)) {
|
||||||
|
if (route == null || route.getServerLogin() == null) continue;
|
||||||
|
if (ownServerLogin.equals(normalize(route.getServerLogin()))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clamp(int value, int min, int max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim().toLowerCase();
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeOriginal(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim();
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -53,6 +53,11 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
|||||||
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (status.applied()) {
|
if (status.applied()) {
|
||||||
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
||||||
|
server.sync.DmFederationService.fanOutIncomingToRecipientAccessServers(
|
||||||
|
incoming.toLogin,
|
||||||
|
req.getIncomingBlobB64().trim(),
|
||||||
|
req.getSourceServerLogin()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||||
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
||||||
|
|||||||
+204
-3
@@ -12,10 +12,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
|||||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Response;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.push.WebPushSender;
|
||||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||||
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.ActiveSessionsDAO;
|
||||||
import shine.db.dao.CurrentUsersDAO;
|
import shine.db.dao.CurrentUsersDAO;
|
||||||
import shine.db.entities.ActiveSessionEntry;
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
import shine.db.entities.CurrentUserEntry;
|
import shine.db.entities.CurrentUserEntry;
|
||||||
@@ -34,6 +36,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
|||||||
private static final String TARGET_MODE_SINGLE = "single_session";
|
private static final String TARGET_MODE_SINGLE = "single_session";
|
||||||
private static final String TARGET_MODE_ALL = "all_sessions";
|
private static final String TARGET_MODE_ALL = "all_sessions";
|
||||||
private static final long ALLOWED_SKEW_MS = 30_000L;
|
private static final long ALLOWED_SKEW_MS = 30_000L;
|
||||||
|
private static final long PUSH_CALL_TTL_MS = 10_000L;
|
||||||
|
private static final String SIGNAL_CALL_INVITE = "call_invite";
|
||||||
|
private static final String SIGNAL_CALL_ACCEPT = "call_accept";
|
||||||
|
private static final String SIGNAL_CALL_DECLINE_BUSY = "call_decline_busy";
|
||||||
|
private static final String SIGNAL_CALL_TIMEOUT = "call_timeout";
|
||||||
|
private static final String SIGNAL_CALL_HANGUP = "call_hangup";
|
||||||
|
private static final String SIGNAL_CALL_CONNECT_START = "call_connect_start";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
@@ -99,6 +108,10 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_SIGNATURE", "Некорректная подпись session key");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_SIGNATURE", "Некорректная подпись session key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCallSignalType(signalType) && clientSignatureB64.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "CLIENT_SIGNATURE_REQUIRED", "Для call_* сигналов обязательна подпись client key");
|
||||||
|
}
|
||||||
|
|
||||||
if (!clientSignatureB64.isBlank()) {
|
if (!clientSignatureB64.isBlank()) {
|
||||||
String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
|
String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
|
||||||
if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) {
|
if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) {
|
||||||
@@ -107,7 +120,13 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<ConnectionContext> targets = resolveTargets(targetMode, toLogin, targetSessionId);
|
List<ConnectionContext> targets = resolveTargets(targetMode, toLogin, targetSessionId);
|
||||||
if (targets.isEmpty()) {
|
boolean isCallInviteAllSessions = TARGET_MODE_ALL.equals(targetMode) && SIGNAL_CALL_INVITE.equals(signalType);
|
||||||
|
boolean isCallAcceptSingleSession = TARGET_MODE_SINGLE.equals(targetMode) && SIGNAL_CALL_ACCEPT.equals(signalType);
|
||||||
|
boolean isCallTerminalSingleSession = TARGET_MODE_SINGLE.equals(targetMode)
|
||||||
|
&& (SIGNAL_CALL_DECLINE_BUSY.equals(signalType)
|
||||||
|
|| SIGNAL_CALL_TIMEOUT.equals(signalType)
|
||||||
|
|| SIGNAL_CALL_HANGUP.equals(signalType));
|
||||||
|
if (targets.isEmpty() && !isCallInviteAllSessions) {
|
||||||
String code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS";
|
String code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS";
|
||||||
String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала";
|
String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала";
|
||||||
return NetExceptionResponseFactory.error(req, 404, code, msg);
|
return NetExceptionResponseFactory.error(req, 404, code, msg);
|
||||||
@@ -135,16 +154,62 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deliveredSessionIds.isEmpty()) {
|
int webPushDelivered = 0;
|
||||||
|
if (isCallInviteAllSessions) {
|
||||||
|
webPushDelivered = sendIncomingCallPushToOfflineSessions(
|
||||||
|
toLogin,
|
||||||
|
fromLogin,
|
||||||
|
fromSessionId,
|
||||||
|
signalRequestId,
|
||||||
|
deliveredSessionIds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveredSessionIds.isEmpty() && webPushDelivered <= 0) {
|
||||||
return NetExceptionResponseFactory.error(req, 404, "DELIVERY_FAILED", "Не удалось доставить сигнал ни в одну целевую сессию");
|
return NetExceptionResponseFactory.error(req, 404, "DELIVERY_FAILED", "Не удалось доставить сигнал ни в одну целевую сессию");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCallAcceptSingleSession) {
|
||||||
|
notifyStopOnOtherSessions(
|
||||||
|
fromLogin,
|
||||||
|
fromSessionId,
|
||||||
|
fromLogin,
|
||||||
|
fromSessionId,
|
||||||
|
signalRequestId,
|
||||||
|
"accepted_on_other_device"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCallTerminalSingleSession) {
|
||||||
|
String deliveredTargetSessionId = deliveredSessionIds.isEmpty() ? targetSessionId : deliveredSessionIds.get(0);
|
||||||
|
String reason = "terminal_call_signal_" + signalType;
|
||||||
|
notifyStopOnOtherSessions(
|
||||||
|
fromLogin,
|
||||||
|
fromSessionId,
|
||||||
|
fromLogin,
|
||||||
|
fromSessionId,
|
||||||
|
signalRequestId,
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
notifyStopOnOtherSessions(
|
||||||
|
toLogin,
|
||||||
|
deliveredTargetSessionId,
|
||||||
|
fromLogin,
|
||||||
|
fromSessionId,
|
||||||
|
signalRequestId,
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Net_SendSignal_Response resp = new Net_SendSignal_Response();
|
Net_SendSignal_Response resp = new Net_SendSignal_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
resp.setRequestId(req.getRequestId());
|
resp.setRequestId(req.getRequestId());
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
resp.setDeliveredCount(deliveredSessionIds.size());
|
resp.setDeliveredCount(deliveredSessionIds.size() + webPushDelivered);
|
||||||
resp.setDeliveredSessionIds(deliveredSessionIds);
|
resp.setDeliveredSessionIds(deliveredSessionIds);
|
||||||
|
resp.setDeliveredWsSessions(deliveredSessionIds.size());
|
||||||
|
resp.setDeliveredFcmSessions(webPushDelivered);
|
||||||
|
resp.setDeliveredWebPushSessions(webPushDelivered);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +255,19 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
|||||||
+ dataSha256B64;
|
+ dataSha256B64;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isCallSignalType(String signalType) {
|
||||||
|
return SIGNAL_CALL_INVITE.equals(signalType)
|
||||||
|
|| "call_ringing".equals(signalType)
|
||||||
|
|| SIGNAL_CALL_ACCEPT.equals(signalType)
|
||||||
|
|| SIGNAL_CALL_DECLINE_BUSY.equals(signalType)
|
||||||
|
|| SIGNAL_CALL_TIMEOUT.equals(signalType)
|
||||||
|
|| SIGNAL_CALL_HANGUP.equals(signalType)
|
||||||
|
|| SIGNAL_CALL_CONNECT_START.equals(signalType)
|
||||||
|
|| "call_offer".equals(signalType)
|
||||||
|
|| "call_answer".equals(signalType)
|
||||||
|
|| "call_ice".equals(signalType);
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception {
|
private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception {
|
||||||
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName);
|
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName);
|
||||||
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
|
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
|
||||||
@@ -214,7 +292,130 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int sendIncomingCallPushToOfflineSessions(
|
||||||
|
String toLogin,
|
||||||
|
String fromLogin,
|
||||||
|
String fromSessionId,
|
||||||
|
String callId,
|
||||||
|
List<String> deliveredOnlineSessionIds
|
||||||
|
) throws Exception {
|
||||||
|
List<ActiveSessionEntry> persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(toLogin);
|
||||||
|
long sentAtMs = System.currentTimeMillis();
|
||||||
|
long expiresAtMs = sentAtMs + PUSH_CALL_TTL_MS;
|
||||||
|
int delivered = 0;
|
||||||
|
for (ActiveSessionEntry session : persistedSessions) {
|
||||||
|
String sessionId = safe(session.getSessionId());
|
||||||
|
if (!sessionId.isBlank() && deliveredOnlineSessionIds.contains(sessionId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String payload = "{\"kind\":\"incoming_call\""
|
||||||
|
+ ",\"title\":\"SHiNE: входящий звонок\""
|
||||||
|
+ ",\"text\":\"Вам звонит " + jsonEscape(fromLogin) + "\""
|
||||||
|
+ ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\""
|
||||||
|
+ ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\""
|
||||||
|
+ ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\""
|
||||||
|
+ ",\"toLogin\":\"" + jsonEscape(toLogin) + "\""
|
||||||
|
+ ",\"callId\":\"" + jsonEscape(callId) + "\""
|
||||||
|
+ ",\"sentAtMs\":" + sentAtMs
|
||||||
|
+ ",\"expiresAtMs\":" + expiresAtMs
|
||||||
|
+ "}";
|
||||||
|
boolean pushed = WebPushSender.sendBase64Payload(
|
||||||
|
session.getPushEndpoint(),
|
||||||
|
session.getPushP256dhKey(),
|
||||||
|
session.getPushAuthKey(),
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
if (pushed) {
|
||||||
|
delivered++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyStopOnOtherSessions(
|
||||||
|
String targetLogin,
|
||||||
|
String excludeSessionId,
|
||||||
|
String fromLogin,
|
||||||
|
String fromSessionId,
|
||||||
|
String callId,
|
||||||
|
String reason
|
||||||
|
) throws Exception {
|
||||||
|
if (isBlank(targetLogin) || isBlank(callId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> onlineSessionIds = new java.util.HashSet<>();
|
||||||
|
Set<ConnectionContext> sameUserSessions = ActiveConnectionsRegistry.getInstance().getByLogin(targetLogin);
|
||||||
|
for (ConnectionContext siblingCtx : sameUserSessions) {
|
||||||
|
if (siblingCtx == null || siblingCtx.getWsSession() == null || !siblingCtx.getWsSession().isOpen()) continue;
|
||||||
|
onlineSessionIds.add(safe(siblingCtx.getSessionId()));
|
||||||
|
if (!isBlank(excludeSessionId) && excludeSessionId.equals(siblingCtx.getSessionId())) continue;
|
||||||
|
|
||||||
|
String siblingEventId = server.logic.ws_protocol.JSON.utils.NetIdGenerator.eventId("evt");
|
||||||
|
ObjectNode siblingPayload = MAPPER.createObjectNode();
|
||||||
|
siblingPayload.put("eventId", siblingEventId);
|
||||||
|
siblingPayload.put("fromLogin", fromLogin);
|
||||||
|
siblingPayload.put("fromSessionId", fromSessionId);
|
||||||
|
siblingPayload.put("toLogin", targetLogin);
|
||||||
|
siblingPayload.put("targetMode", TARGET_MODE_SINGLE);
|
||||||
|
siblingPayload.put("targetSessionId", safe(siblingCtx.getSessionId()));
|
||||||
|
siblingPayload.put("signalType", SIGNAL_CALL_HANGUP);
|
||||||
|
siblingPayload.put("signalRequestId", callId);
|
||||||
|
siblingPayload.put("data", "{\"callId\":\"" + jsonEscape(callId) + "\",\"type\":150,\"data\":\"" + jsonEscape(reason) + "\"}");
|
||||||
|
siblingPayload.put("timeMs", System.currentTimeMillis());
|
||||||
|
WsEventSender.sendEvent(siblingCtx, "IncomingSignal", siblingEventId, siblingPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ActiveSessionEntry> persistedSessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
|
||||||
|
long sentAtMs = System.currentTimeMillis();
|
||||||
|
for (ActiveSessionEntry session : persistedSessions) {
|
||||||
|
String sessionId = safe(session.getSessionId());
|
||||||
|
if (!isBlank(excludeSessionId) && excludeSessionId.equals(sessionId)) continue;
|
||||||
|
if (!sessionId.isBlank() && onlineSessionIds.contains(sessionId)) continue;
|
||||||
|
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String pushPayload = "{\"kind\":\"stop_call\""
|
||||||
|
+ ",\"callId\":\"" + jsonEscape(callId) + "\""
|
||||||
|
+ ",\"reason\":\"" + jsonEscape(reason) + "\""
|
||||||
|
+ ",\"fromLogin\":\"" + jsonEscape(fromLogin) + "\""
|
||||||
|
+ ",\"fromSessionId\":\"" + jsonEscape(fromSessionId) + "\""
|
||||||
|
+ ",\"targetSessionId\":\"" + jsonEscape(sessionId) + "\""
|
||||||
|
+ ",\"toLogin\":\"" + jsonEscape(targetLogin) + "\""
|
||||||
|
+ ",\"sentAtMs\":" + sentAtMs
|
||||||
|
+ "}";
|
||||||
|
WebPushSender.sendBase64Payload(
|
||||||
|
session.getPushEndpoint(),
|
||||||
|
session.getPushP256dhKey(),
|
||||||
|
session.getPushAuthKey(),
|
||||||
|
pushPayload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String safe(String value) {
|
private static String safe(String value) {
|
||||||
return value == null ? "" : value.trim();
|
return value == null ? "" : value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String jsonEscape(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
if (c == '\\') out.append("\\\\");
|
||||||
|
else if (c == '"') out.append("\\\"");
|
||||||
|
else if (c == '\n') out.append("\\n");
|
||||||
|
else if (c == '\r') out.append("\\r");
|
||||||
|
else if (c == '\t') out.append("\\t");
|
||||||
|
else out.append(c);
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -6,6 +6,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request {
|
|||||||
private String toLogin;
|
private String toLogin;
|
||||||
private String callId;
|
private String callId;
|
||||||
private Integer type;
|
private Integer type;
|
||||||
|
private String data;
|
||||||
|
|
||||||
public String getToLogin() { return toLogin; }
|
public String getToLogin() { return toLogin; }
|
||||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||||
@@ -15,4 +16,7 @@ public class Net_CallInviteBroadcast_Request extends Net_Request {
|
|||||||
|
|
||||||
public Integer getType() { return type; }
|
public Integer getType() { return type; }
|
||||||
public void setType(Integer type) { this.type = type; }
|
public void setType(Integer type) { this.type = type; }
|
||||||
|
|
||||||
|
public String getData() { return data; }
|
||||||
|
public void setData(String data) { this.data = data; }
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_DmSyncBatch_Request extends Net_Request {
|
||||||
|
private String ownerLogin;
|
||||||
|
private Long afterStoredAtMs;
|
||||||
|
private String afterMessageKey;
|
||||||
|
private Integer limit;
|
||||||
|
private Integer maxBytes;
|
||||||
|
|
||||||
|
public String getOwnerLogin() { return ownerLogin; }
|
||||||
|
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||||
|
public Long getAfterStoredAtMs() { return afterStoredAtMs; }
|
||||||
|
public void setAfterStoredAtMs(Long afterStoredAtMs) { this.afterStoredAtMs = afterStoredAtMs; }
|
||||||
|
public String getAfterMessageKey() { return afterMessageKey; }
|
||||||
|
public void setAfterMessageKey(String afterMessageKey) { this.afterMessageKey = afterMessageKey; }
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
public Integer getMaxBytes() { return maxBytes; }
|
||||||
|
public void setMaxBytes(Integer maxBytes) { this.maxBytes = maxBytes; }
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_DmSyncBatch_Response extends Net_Response {
|
||||||
|
private String ownerLogin;
|
||||||
|
private int limit;
|
||||||
|
private int rawBytes;
|
||||||
|
private boolean hasMore;
|
||||||
|
private long nextStoredAtMs;
|
||||||
|
private String nextMessageKey;
|
||||||
|
private List<Item> items = new ArrayList<>();
|
||||||
|
|
||||||
|
public String getOwnerLogin() { return ownerLogin; }
|
||||||
|
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||||
|
public int getLimit() { return limit; }
|
||||||
|
public void setLimit(int limit) { this.limit = limit; }
|
||||||
|
public int getRawBytes() { return rawBytes; }
|
||||||
|
public void setRawBytes(int rawBytes) { this.rawBytes = rawBytes; }
|
||||||
|
public boolean isHasMore() { return hasMore; }
|
||||||
|
public void setHasMore(boolean hasMore) { this.hasMore = hasMore; }
|
||||||
|
public long getNextStoredAtMs() { return nextStoredAtMs; }
|
||||||
|
public void setNextStoredAtMs(long nextStoredAtMs) { this.nextStoredAtMs = nextStoredAtMs; }
|
||||||
|
public String getNextMessageKey() { return nextMessageKey; }
|
||||||
|
public void setNextMessageKey(String nextMessageKey) { this.nextMessageKey = nextMessageKey; }
|
||||||
|
public List<Item> getItems() { return items; }
|
||||||
|
public void setItems(List<Item> items) { this.items = items; }
|
||||||
|
|
||||||
|
public static class Item {
|
||||||
|
private String messageKey;
|
||||||
|
private String baseKey;
|
||||||
|
private String targetLogin;
|
||||||
|
private String fromLogin;
|
||||||
|
private String toLogin;
|
||||||
|
private int messageType;
|
||||||
|
private long timeMs;
|
||||||
|
private long storedAtMs;
|
||||||
|
private String blobB64;
|
||||||
|
|
||||||
|
public String getMessageKey() { return messageKey; }
|
||||||
|
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
|
||||||
|
public String getBaseKey() { return baseKey; }
|
||||||
|
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
|
||||||
|
public String getTargetLogin() { return targetLogin; }
|
||||||
|
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||||
|
public String getFromLogin() { return fromLogin; }
|
||||||
|
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||||
|
public String getToLogin() { return toLogin; }
|
||||||
|
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||||
|
public int getMessageType() { return messageType; }
|
||||||
|
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||||
|
public long getTimeMs() { return timeMs; }
|
||||||
|
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||||
|
public long getStoredAtMs() { return storedAtMs; }
|
||||||
|
public void setStoredAtMs(long storedAtMs) { this.storedAtMs = storedAtMs; }
|
||||||
|
public String getBlobB64() { return blobB64; }
|
||||||
|
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -4,7 +4,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
|||||||
|
|
||||||
public class Net_ReceiveIncomingMessage_Request extends Net_Request {
|
public class Net_ReceiveIncomingMessage_Request extends Net_Request {
|
||||||
private String incomingBlobB64;
|
private String incomingBlobB64;
|
||||||
|
private String sourceServerLogin;
|
||||||
|
|
||||||
public String getIncomingBlobB64() { return incomingBlobB64; }
|
public String getIncomingBlobB64() { return incomingBlobB64; }
|
||||||
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
||||||
|
public String getSourceServerLogin() { return sourceServerLogin; }
|
||||||
|
public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; }
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -5,9 +5,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
|||||||
public class Net_SendMessagePair_Request extends Net_Request {
|
public class Net_SendMessagePair_Request extends Net_Request {
|
||||||
private String incomingBlobB64;
|
private String incomingBlobB64;
|
||||||
private String outgoingBlobB64;
|
private String outgoingBlobB64;
|
||||||
|
private String sourceServerLogin;
|
||||||
|
|
||||||
public String getIncomingBlobB64() { return incomingBlobB64; }
|
public String getIncomingBlobB64() { return incomingBlobB64; }
|
||||||
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
||||||
public String getOutgoingBlobB64() { return outgoingBlobB64; }
|
public String getOutgoingBlobB64() { return outgoingBlobB64; }
|
||||||
public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; }
|
public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; }
|
||||||
|
public String getSourceServerLogin() { return sourceServerLogin; }
|
||||||
|
public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; }
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -8,6 +8,9 @@ import java.util.List;
|
|||||||
public class Net_SendSignal_Response extends Net_Response {
|
public class Net_SendSignal_Response extends Net_Response {
|
||||||
private int deliveredCount;
|
private int deliveredCount;
|
||||||
private List<String> deliveredSessionIds = new ArrayList<>();
|
private List<String> deliveredSessionIds = new ArrayList<>();
|
||||||
|
private int deliveredWsSessions;
|
||||||
|
private int deliveredFcmSessions;
|
||||||
|
private int deliveredWebPushSessions;
|
||||||
|
|
||||||
public int getDeliveredCount() {
|
public int getDeliveredCount() {
|
||||||
return deliveredCount;
|
return deliveredCount;
|
||||||
@@ -24,4 +27,28 @@ public class Net_SendSignal_Response extends Net_Response {
|
|||||||
public void setDeliveredSessionIds(List<String> deliveredSessionIds) {
|
public void setDeliveredSessionIds(List<String> deliveredSessionIds) {
|
||||||
this.deliveredSessionIds = deliveredSessionIds;
|
this.deliveredSessionIds = deliveredSessionIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getDeliveredWsSessions() {
|
||||||
|
return deliveredWsSessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeliveredWsSessions(int deliveredWsSessions) {
|
||||||
|
this.deliveredWsSessions = deliveredWsSessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getDeliveredFcmSessions() {
|
||||||
|
return deliveredFcmSessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeliveredFcmSessions(int deliveredFcmSessions) {
|
||||||
|
this.deliveredFcmSessions = deliveredFcmSessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getDeliveredWebPushSessions() {
|
||||||
|
return deliveredWebPushSessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) {
|
||||||
|
this.deliveredWebPushSessions = deliveredWebPushSessions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-25
@@ -2,7 +2,8 @@ package server.sync;
|
|||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService;
|
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||||
|
import shine.db.entities.UserAccessServerRouteEntry;
|
||||||
import utils.config.AppConfig;
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -18,26 +19,48 @@ public final class DmFederationService {
|
|||||||
|
|
||||||
public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) {
|
public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) {
|
||||||
try {
|
try {
|
||||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> senderRoutes =
|
Map<String, UserAccessServerRouteEntry> senderRoutes =
|
||||||
routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin));
|
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin));
|
||||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> recipientRoutes =
|
Map<String, UserAccessServerRouteEntry> recipientRoutes =
|
||||||
routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin));
|
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin));
|
||||||
|
|
||||||
String ownServerLogin = ownServerLogin();
|
String ownServerLogin = ownServerLogin();
|
||||||
for (SolanaUserPdaImportService.ParsedServerRoute route : senderRoutes.values()) {
|
for (UserAccessServerRouteEntry route : senderRoutes.values()) {
|
||||||
if (isOwnServer(route, ownServerLogin)) continue;
|
if (isOwnServer(route, ownServerLogin)) continue;
|
||||||
REMOTE.sendMessagePair(route.serverAddress(), incomingBlobB64, outgoingBlobB64);
|
REMOTE.sendMessagePair(route.getServerUrl(), incomingBlobB64, outgoingBlobB64, ownServerLogin);
|
||||||
}
|
}
|
||||||
for (SolanaUserPdaImportService.ParsedServerRoute route : recipientRoutes.values()) {
|
for (UserAccessServerRouteEntry route : recipientRoutes.values()) {
|
||||||
if (isOwnServer(route, ownServerLogin)) continue;
|
if (isOwnServer(route, ownServerLogin)) continue;
|
||||||
if (senderRoutes.containsKey(route.login())) continue;
|
if (senderRoutes.containsKey(normalize(route.getServerLogin()))) continue;
|
||||||
REMOTE.receiveIncomingMessage(route.serverAddress(), incomingBlobB64);
|
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e);
|
log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void fanOutIncomingToRecipientAccessServers(
|
||||||
|
String toLogin,
|
||||||
|
String incomingBlobB64,
|
||||||
|
String sourceServerLogin
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
Map<String, UserAccessServerRouteEntry> recipientRoutes =
|
||||||
|
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin));
|
||||||
|
String ownServerLogin = ownServerLogin();
|
||||||
|
String normalizedSource = normalize(sourceServerLogin);
|
||||||
|
for (UserAccessServerRouteEntry route : recipientRoutes.values()) {
|
||||||
|
String routeLogin = normalize(route.getServerLogin());
|
||||||
|
if (routeLogin == null) continue;
|
||||||
|
if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue;
|
||||||
|
if (normalizedSource != null && normalizedSource.equalsIgnoreCase(routeLogin)) continue;
|
||||||
|
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("DM federation incoming relay failed: to={}", toLogin, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) {
|
public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) {
|
||||||
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true);
|
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true);
|
||||||
}
|
}
|
||||||
@@ -48,17 +71,17 @@ public final class DmFederationService {
|
|||||||
|
|
||||||
private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) {
|
private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) {
|
||||||
try {
|
try {
|
||||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> routes = routesByLogin(
|
Map<String, UserAccessServerRouteEntry> routes = routesByLogin(
|
||||||
SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin),
|
UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin),
|
||||||
SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin)
|
UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)
|
||||||
);
|
);
|
||||||
String ownServerLogin = ownServerLogin();
|
String ownServerLogin = ownServerLogin();
|
||||||
for (SolanaUserPdaImportService.ParsedServerRoute route : routes.values()) {
|
for (UserAccessServerRouteEntry route : routes.values()) {
|
||||||
if (isOwnServer(route, ownServerLogin)) continue;
|
if (isOwnServer(route, ownServerLogin)) continue;
|
||||||
if (oneMessageDelete) {
|
if (oneMessageDelete) {
|
||||||
REMOTE.deleteMessage(route.serverAddress(), blobB64);
|
REMOTE.deleteMessage(route.getServerUrl(), blobB64);
|
||||||
} else {
|
} else {
|
||||||
REMOTE.deleteConversation(route.serverAddress(), blobB64);
|
REMOTE.deleteConversation(route.getServerUrl(), blobB64);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -68,15 +91,15 @@ public final class DmFederationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SafeVarargs
|
@SafeVarargs
|
||||||
private static Map<String, SolanaUserPdaImportService.ParsedServerRoute> routesByLogin(
|
private static Map<String, UserAccessServerRouteEntry> routesByLogin(
|
||||||
List<SolanaUserPdaImportService.ParsedServerRoute>... routeLists
|
List<UserAccessServerRouteEntry>... routeLists
|
||||||
) {
|
) {
|
||||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> out = new LinkedHashMap<>();
|
Map<String, UserAccessServerRouteEntry> out = new LinkedHashMap<>();
|
||||||
for (List<SolanaUserPdaImportService.ParsedServerRoute> routeList : routeLists) {
|
for (List<UserAccessServerRouteEntry> routeList : routeLists) {
|
||||||
for (SolanaUserPdaImportService.ParsedServerRoute route : routeList) {
|
for (UserAccessServerRouteEntry route : routeList) {
|
||||||
if (route == null) continue;
|
if (route == null) continue;
|
||||||
String login = normalize(route.login());
|
String login = normalize(route.getServerLogin());
|
||||||
String address = route.serverAddress() == null ? "" : route.serverAddress().trim();
|
String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim();
|
||||||
if (login == null || address.isBlank()) continue;
|
if (login == null || address.isBlank()) continue;
|
||||||
out.putIfAbsent(login, route);
|
out.putIfAbsent(login, route);
|
||||||
}
|
}
|
||||||
@@ -84,8 +107,11 @@ public final class DmFederationService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isOwnServer(SolanaUserPdaImportService.ParsedServerRoute route, String ownServerLogin) {
|
private static boolean isOwnServer(UserAccessServerRouteEntry route, String ownServerLogin) {
|
||||||
return ownServerLogin != null && ownServerLogin.equalsIgnoreCase(route.login());
|
return ownServerLogin != null
|
||||||
|
&& route != null
|
||||||
|
&& route.getServerLogin() != null
|
||||||
|
&& ownServerLogin.equalsIgnoreCase(route.getServerLogin());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String ownServerLogin() {
|
private static String ownServerLogin() {
|
||||||
|
|||||||
+75
-6
@@ -8,6 +8,8 @@ import java.net.http.HttpClient;
|
|||||||
import java.net.http.WebSocket;
|
import java.net.http.WebSocket;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.CompletionStage;
|
import java.util.concurrent.CompletionStage;
|
||||||
@@ -21,36 +23,83 @@ public final class RemoteDmSyncClient {
|
|||||||
.connectTimeout(Duration.ofSeconds(6))
|
.connectTimeout(Duration.ofSeconds(6))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64) throws Exception {
|
public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64, String sourceServerLogin) throws Exception {
|
||||||
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||||
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
|
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
|
||||||
|
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(serverAddressRaw, """
|
||||||
{
|
{
|
||||||
"op":"ReceiveOutcomingMessage",
|
"op":"ReceiveOutcomingMessage",
|
||||||
"requestId":%s,
|
"requestId":%s,
|
||||||
"payload":{
|
"payload":{
|
||||||
"incomingBlobB64":%s,
|
"incomingBlobB64":%s,
|
||||||
"outgoingBlobB64":%s
|
"outgoingBlobB64":%s%s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""".formatted("%s", incomingJson, outgoingJson));
|
""".formatted("%s", incomingJson, outgoingJson, sourceServerLoginJson));
|
||||||
ensureOk("ReceiveOutcomingMessage", response);
|
ensureOk("ReceiveOutcomingMessage", response);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64) throws Exception {
|
public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64, String sourceServerLogin) throws Exception {
|
||||||
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||||
|
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(serverAddressRaw, """
|
||||||
{
|
{
|
||||||
"op":"ReceiveIncomingMessage",
|
"op":"ReceiveIncomingMessage",
|
||||||
"requestId":%s,
|
"requestId":%s,
|
||||||
"payload":{
|
"payload":{
|
||||||
"incomingBlobB64":%s
|
"incomingBlobB64":%s%s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""".formatted("%s", incomingJson));
|
""".formatted("%s", incomingJson, sourceServerLoginJson));
|
||||||
ensureOk("ReceiveIncomingMessage", response);
|
ensureOk("ReceiveIncomingMessage", response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RemoteDmBatch dmSyncBatch(
|
||||||
|
String serverAddressRaw,
|
||||||
|
String ownerLogin,
|
||||||
|
long afterStoredAtMs,
|
||||||
|
String afterMessageKey,
|
||||||
|
int limit,
|
||||||
|
int maxBytes
|
||||||
|
) throws Exception {
|
||||||
|
String ownerLoginJson = MAPPER.writeValueAsString(ownerLogin);
|
||||||
|
String afterMessageKeyJson = MAPPER.writeValueAsString(afterMessageKey == null ? "" : afterMessageKey);
|
||||||
|
JsonNode response = send(serverAddressRaw, """
|
||||||
|
{
|
||||||
|
"op":"DmSyncBatch",
|
||||||
|
"requestId":%s,
|
||||||
|
"payload":{
|
||||||
|
"ownerLogin":%s,
|
||||||
|
"afterStoredAtMs":%d,
|
||||||
|
"afterMessageKey":%s,
|
||||||
|
"limit":%d,
|
||||||
|
"maxBytes":%d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson, limit, maxBytes));
|
||||||
|
ensureOk("DmSyncBatch", response);
|
||||||
|
|
||||||
|
JsonNode payload = response.path("payload");
|
||||||
|
List<RemoteDmItem> items = new ArrayList<>();
|
||||||
|
JsonNode arr = payload.path("items");
|
||||||
|
if (arr.isArray()) {
|
||||||
|
for (JsonNode item : arr) {
|
||||||
|
items.add(new RemoteDmItem(
|
||||||
|
item.path("messageKey").asText(""),
|
||||||
|
item.path("storedAtMs").asLong(0L),
|
||||||
|
item.path("blobB64").asText("")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RemoteDmBatch(
|
||||||
|
payload.path("nextStoredAtMs").asLong(afterStoredAtMs),
|
||||||
|
payload.path("nextMessageKey").asText(afterMessageKey == null ? "" : afterMessageKey),
|
||||||
|
payload.path("hasMore").asBoolean(false),
|
||||||
|
items
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception {
|
public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception {
|
||||||
String blobJson = MAPPER.writeValueAsString(blobB64);
|
String blobJson = MAPPER.writeValueAsString(blobB64);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(serverAddressRaw, """
|
||||||
@@ -107,6 +156,13 @@ public final class RemoteDmSyncClient {
|
|||||||
return MAPPER.readTree(responseJson);
|
return MAPPER.readTree(responseJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String toOptionalJsonField(String fieldName, String value) throws Exception {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return ",\n \"" + fieldName + "\":" + MAPPER.writeValueAsString(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
private void ensureOk(String op, JsonNode response) {
|
private void ensureOk(String op, JsonNode response) {
|
||||||
int status = response.path("status").asInt(500);
|
int status = response.path("status").asInt(500);
|
||||||
if (status >= 200 && status < 300) return;
|
if (status >= 200 && status < 300) return;
|
||||||
@@ -126,6 +182,19 @@ public final class RemoteDmSyncClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record RemoteDmBatch(
|
||||||
|
long nextStoredAtMs,
|
||||||
|
String nextMessageKey,
|
||||||
|
boolean hasMore,
|
||||||
|
List<RemoteDmItem> items
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record RemoteDmItem(
|
||||||
|
String messageKey,
|
||||||
|
long storedAtMs,
|
||||||
|
String blobB64
|
||||||
|
) {}
|
||||||
|
|
||||||
private static final class SyncWsListener implements WebSocket.Listener {
|
private static final class SyncWsListener implements WebSocket.Listener {
|
||||||
private final CompletableFuture<String> responseFuture;
|
private final CompletableFuture<String> responseFuture;
|
||||||
private final CountDownLatch openLatch;
|
private final CountDownLatch openLatch;
|
||||||
|
|||||||
+20
-12
@@ -1057,12 +1057,16 @@ public final class PostgresStorageRepository
|
|||||||
s.updated_at_ms,
|
s.updated_at_ms,
|
||||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
FROM solana_user_pda_current u
|
FROM solana_user_pda_current u
|
||||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
CROSS JOIN LATERAL (
|
||||||
CASE
|
SELECT login_value
|
||||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
FROM jsonb_array_elements_text(
|
||||||
ELSE u.access_servers_json::jsonb
|
CASE
|
||||||
END
|
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||||
) AS access_server(login_value)
|
ELSE u.access_servers_json::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
|
WHERE ord <= 2
|
||||||
|
) AS access_server
|
||||||
JOIN solana_user_pda_current s
|
JOIN solana_user_pda_current s
|
||||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||||
AND s.is_server = TRUE
|
AND s.is_server = TRUE
|
||||||
@@ -1100,12 +1104,16 @@ public final class PostgresStorageRepository
|
|||||||
FOR affected_user IN
|
FOR affected_user IN
|
||||||
SELECT u.login
|
SELECT u.login
|
||||||
FROM solana_user_pda_current u
|
FROM solana_user_pda_current u
|
||||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
CROSS JOIN LATERAL (
|
||||||
CASE
|
SELECT login_value
|
||||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
FROM jsonb_array_elements_text(
|
||||||
ELSE u.access_servers_json::jsonb
|
CASE
|
||||||
END
|
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||||
) AS access_server(login_value)
|
ELSE u.access_servers_json::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
|
WHERE ord <= 2
|
||||||
|
) AS access_server
|
||||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||||
LOOP
|
LOOP
|
||||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package server.sync;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport;
|
||||||
|
import shine.db.dao.DmSyncPeerStateDAO;
|
||||||
|
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||||
|
import shine.db.entities.DmSyncPeerStateEntry;
|
||||||
|
import shine.db.entities.UserAccessServerRouteEntry;
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Периодическая догоняющая синхронизация личной переписки между access-серверами пользователя.
|
||||||
|
*/
|
||||||
|
public final class PeriodicDmSyncService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PeriodicDmSyncService.class);
|
||||||
|
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
|
||||||
|
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient();
|
||||||
|
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance();
|
||||||
|
private static final DmSyncPeerStateDAO STATE_DAO = DmSyncPeerStateDAO.getInstance();
|
||||||
|
|
||||||
|
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||||
|
@Override
|
||||||
|
public Thread newThread(Runnable r) {
|
||||||
|
Thread t = new Thread(r, "periodic-dm-sync");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private PeriodicDmSyncService() {}
|
||||||
|
|
||||||
|
public static void startOrLog() {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
log.info("Periodic DM sync disabled by dm.sync.enabled=false");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!STARTED.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long initialDelaySec = configLong("dm.sync.initialDelaySeconds", 60L, 0L, 3600L);
|
||||||
|
long periodHours = configLong("dm.sync.periodHours", 6L, 1L, 168L);
|
||||||
|
EXECUTOR.scheduleWithFixedDelay(
|
||||||
|
PeriodicDmSyncService::runCycleSafe,
|
||||||
|
initialDelaySec,
|
||||||
|
TimeUnit.HOURS.toSeconds(periodHours),
|
||||||
|
TimeUnit.SECONDS
|
||||||
|
);
|
||||||
|
log.info("Periodic DM sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runCycleSafe() {
|
||||||
|
try {
|
||||||
|
runCycle();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Periodic DM sync failed unexpectedly", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runCycle() throws Exception {
|
||||||
|
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
|
||||||
|
if (ownServerLogin == null) {
|
||||||
|
log.warn("Periodic DM sync skipped: {} is empty", SERVER_LOGIN_CONFIG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin);
|
||||||
|
Set<String> owners = new LinkedHashSet<>(ownersRaw);
|
||||||
|
if (owners.isEmpty()) {
|
||||||
|
log.info("Periodic DM sync skipped: no local access-server users for {}", ownServerLogin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int syncedPeers = 0;
|
||||||
|
int appliedEvents = 0;
|
||||||
|
for (String ownerLogin : owners) {
|
||||||
|
List<UserAccessServerRouteEntry> routes = ACCESS_DAO.listByUserLogin(ownerLogin);
|
||||||
|
for (UserAccessServerRouteEntry route : routes) {
|
||||||
|
if (route == null) continue;
|
||||||
|
String remoteLogin = normalize(route.getServerLogin());
|
||||||
|
String remoteUrl = route.getServerUrl();
|
||||||
|
if (remoteLogin == null || remoteUrl == null || remoteUrl.isBlank()) continue;
|
||||||
|
if (remoteLogin.equals(ownServerLogin)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
appliedEvents += syncOwnerFromRemote(ownerLogin, route);
|
||||||
|
syncedPeers++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e));
|
||||||
|
log.warn("Periodic DM sync peer failed: owner={} remoteServer={} reason={}",
|
||||||
|
ownerLogin, route.getServerLogin(), String.valueOf(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("Periodic DM sync cycle finished: owners={} syncedPeers={} appliedEvents={}",
|
||||||
|
owners.size(), syncedPeers, appliedEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int syncOwnerFromRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception {
|
||||||
|
int limit = (int) configLong("dm.sync.batchLimit", 500L, 1L, 500L);
|
||||||
|
int maxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L);
|
||||||
|
int maxPages = (int) configLong("dm.sync.maxPagesPerPeer", 50L, 1L, 500L);
|
||||||
|
|
||||||
|
DmSyncPeerStateEntry state = STATE_DAO.getOrCreate(ownerLogin, route.getServerLogin(), route.getServerUrl());
|
||||||
|
long cursorStoredAtMs = state.getCursorStoredAtMs();
|
||||||
|
String cursorMessageKey = state.getCursorMessageKey() == null ? "" : state.getCursorMessageKey();
|
||||||
|
int applied = 0;
|
||||||
|
boolean bootstrapCompleted = false;
|
||||||
|
|
||||||
|
for (int page = 0; page < maxPages; page++) {
|
||||||
|
RemoteDmSyncClient.RemoteDmBatch batch = REMOTE.dmSyncBatch(
|
||||||
|
route.getServerUrl(),
|
||||||
|
ownerLogin,
|
||||||
|
cursorStoredAtMs,
|
||||||
|
cursorMessageKey,
|
||||||
|
limit,
|
||||||
|
maxBytes
|
||||||
|
);
|
||||||
|
|
||||||
|
for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) {
|
||||||
|
if (item == null || item.blobB64() == null || item.blobB64().isBlank()) continue;
|
||||||
|
DmSyncApplySupport.ApplyResult result = DmSyncApplySupport.applySyncedBlob(ownerLogin, item.blobB64());
|
||||||
|
if (result.status().applied()) {
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorStoredAtMs = Math.max(cursorStoredAtMs, batch.nextStoredAtMs());
|
||||||
|
cursorMessageKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey();
|
||||||
|
bootstrapCompleted = !batch.hasMore();
|
||||||
|
STATE_DAO.updateSuccess(
|
||||||
|
ownerLogin,
|
||||||
|
route.getServerLogin(),
|
||||||
|
route.getServerUrl(),
|
||||||
|
cursorStoredAtMs,
|
||||||
|
cursorMessageKey,
|
||||||
|
bootstrapCompleted
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!batch.hasMore() || batch.items().isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bootstrapCompleted) {
|
||||||
|
log.info("Periodic DM sync peer paused by page limit: owner={} remoteServer={} maxPages={}",
|
||||||
|
ownerLogin, route.getServerLogin(), maxPages);
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isEnabled() {
|
||||||
|
String raw = AppConfig.getInstance().getParam("dm.sync.enabled");
|
||||||
|
return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long configLong(String key, long defaultValue, long min, long max) {
|
||||||
|
String raw = AppConfig.getInstance().getParam(key);
|
||||||
|
if (raw == null || raw.isBlank()) return defaultValue;
|
||||||
|
try {
|
||||||
|
long parsed = Long.parseLong(raw.trim());
|
||||||
|
return Math.max(min, Math.min(max, parsed));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import server.debug.DebugApiConfigurator;
|
import server.debug.DebugApiConfigurator;
|
||||||
import server.sync.BlockchainResyncRecoveryOnStartup;
|
import server.sync.BlockchainResyncRecoveryOnStartup;
|
||||||
import server.sync.PeriodicBlockchainSyncService;
|
import server.sync.PeriodicBlockchainSyncService;
|
||||||
|
import server.sync.PeriodicDmSyncService;
|
||||||
import server.sync.SolanaUsersSyncStartupService;
|
import server.sync.SolanaUsersSyncStartupService;
|
||||||
import server.sync.SyncServersBootstrapService;
|
import server.sync.SyncServersBootstrapService;
|
||||||
import utils.config.AppConfig;
|
import utils.config.AppConfig;
|
||||||
@@ -102,6 +103,7 @@ public final class WsServer {
|
|||||||
|
|
||||||
server.start();
|
server.start();
|
||||||
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
||||||
|
PeriodicDmSyncService.startOrLog();
|
||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ sync.importUserProfileFromPartner.enabled=false
|
|||||||
# Если какое-то значение не задано, сервер вернёт пустую строку.
|
# Если какое-то значение не задано, сервер вернёт пустую строку.
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
server.version=${projectVersion}
|
server.version=${projectVersion}
|
||||||
|
|
||||||
|
# Межсерверная догоняющая синхронизация личных сообщений.
|
||||||
|
dm.sync.enabled=true
|
||||||
|
dm.sync.initialDelaySeconds=60
|
||||||
|
dm.sync.periodHours=6
|
||||||
|
dm.sync.batchLimit=500
|
||||||
|
dm.sync.batchMaxBytes=3000000
|
||||||
|
dm.sync.maxPagesPerPeer=50
|
||||||
server.info.url=
|
server.info.url=
|
||||||
server.info.physicalRegion=
|
server.info.physicalRegion=
|
||||||
server.info.description=
|
server.info.description=
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# Подключение других устройств по QR и типизированные сессии
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
QR-подключение других устройств сейчас есть как заготовка, но сценарий нужно довести до устойчивого состояния. Параллельно надо аккуратно оформить типизированные сессии homeserver-ов в PDA.
|
|
||||||
|
|
||||||
## Что сделать
|
|
||||||
|
|
||||||
1. Довести QR-сценарий до стабильного подключения нового устройства.
|
|
||||||
2. Нормально описать и хранить устройство как отдельную типизированную сессию.
|
|
||||||
3. Согласовать это с серверной и UI-логикой.
|
|
||||||
4. Проверить, что подключение работает одинаково на новом и повторном устройстве.
|
|
||||||
|
|
||||||
## Что уже есть
|
|
||||||
|
|
||||||
- в планах есть `сессионные homeserver-ы в PDA`;
|
|
||||||
- в планах есть `подключение других устройств через QR`;
|
|
||||||
- базовая заготовка уже существует, но сценарий считается нестабильным.
|
|
||||||
|
|
||||||
## Откуда продолжать
|
|
||||||
|
|
||||||
- от текущих документов в `TODO/medium/`;
|
|
||||||
- отдельно проверить, какие поля уже есть в PDA и UI.
|
|
||||||
|
|
||||||
## Какие документы потом обновить
|
|
||||||
|
|
||||||
- `docs/Solana_Architecture/README.md`;
|
|
||||||
- `TODO/medium/2026-06-03_подключение_других_устройств_через_qr.md`;
|
|
||||||
- `TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md`.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# ESP32 как личное файловое хранилище
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
Планируется использовать ESP32 как личное файловое хранилище SHiNE для переписок и вложений.
|
|
||||||
|
|
||||||
## Что сделать
|
|
||||||
|
|
||||||
1. Продумать формат хранения файлов на устройстве.
|
|
||||||
2. Согласовать загрузку и чтение файлов между UI, сервером и устройством.
|
|
||||||
3. Проверить, как устройство показывает статусы и ошибки.
|
|
||||||
4. Свести это с существующим homeserver/UI-прототипом.
|
|
||||||
|
|
||||||
## Что уже есть
|
|
||||||
|
|
||||||
- в списке будущих фич уже есть отдельная задача по ESP32S3 file storage;
|
|
||||||
- для UI homeserver уже есть отдельная документация и скетч должны держаться синхронно.
|
|
||||||
|
|
||||||
## Откуда продолжать
|
|
||||||
|
|
||||||
- от `TODO/medium/2026-05-26_0029_esp32s3_file_storage.md`;
|
|
||||||
- от документации по ESP32 UI homeserver.
|
|
||||||
|
|
||||||
## Какие документы потом обновить
|
|
||||||
|
|
||||||
- `TODO/medium/2026-05-26_0029_esp32s3_file_storage.md`;
|
|
||||||
- `TODO/README.md`;
|
|
||||||
- документацию по ESP32 UI homeserver, если добавятся экраны или статусы.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
Папка для короткого списка ближайших и среднесрочных задач, которые уже обсуждались и пока отложены.
|
|
||||||
|
|
||||||
## Как использовать
|
|
||||||
|
|
||||||
- Один markdown-файл = одна задача.
|
|
||||||
- В файле коротко фиксируем:
|
|
||||||
- зачем это нужно;
|
|
||||||
- что именно сделать;
|
|
||||||
- что уже есть в коде;
|
|
||||||
- откуда продолжать;
|
|
||||||
- какие документы потом надо обновить.
|
|
||||||
- Это не активная разработка. Тут только план и контекст.
|
|
||||||
- Старую папку `docs/Future_Features/` считать архивной и больше не использовать как источник новых задач.
|
|
||||||
|
|
||||||
## Текущие задачи
|
|
||||||
|
|
||||||
- `2026-06-26_1800_корректное_завершение_за_30с.md` - дать сервису до 30 секунд на корректное завершение опасных операций перед рестартом.
|
|
||||||
- `2026-06-26_1810_подключение_устройств_по_qr.md` - довести подключение других устройств по QR и перевести это в нормальные типизированные сессии.
|
|
||||||
- `2026-06-26_1815_esp32_файловое_хранилище.md` - использовать ESP32 как личное файловое хранилище для переписок и вложений.
|
|
||||||
|
|
||||||
## Децентрализация
|
|
||||||
|
|
||||||
Текущий production-режим SHiNE считается односерверным. Задачи по нескольким серверам, Arweave и realtime PDA/Solana sync вынесены в `Децентрализация/` и не блокируют выкладку текущей версии на GitHub.
|
|
||||||
|
|
||||||
- `Децентрализация/односерверный_production_режим.md` - границы текущей production-версии с одним сервером.
|
|
||||||
- `Децентрализация/запись_блокчейнов_в_arweave.md` - будущая запись/архивация блокчейнов в Arweave.
|
|
||||||
- `Децентрализация/realtime_pda_solana_sync.md` - будущая онлайн-синхронизация PDA и Solana.
|
|
||||||
- `Децентрализация/межсерверная_передача_сообщений.md` - будущая доставка сообщений между серверами.
|
|
||||||
- `Децентрализация/межсерверные_звонки.md` - будущая маршрутизация звонков между серверами.
|
|
||||||
- `Децентрализация/2026-06-26_1805_межсерверный_ws_и_dm_sync.md` - перенесённый старый план постоянного server-to-server WS и DM sync.
|
|
||||||
|
|
||||||
## Новые фишки которые надо доделать
|
|
||||||
|
|
||||||
- `Новые фишки которые надо доделать/Новая_контентная_модель_блокчейна/` - отложенная новая контентная модель блокчейна, не входящая в текущий односерверный production-релиз.
|
|
||||||
|
|
||||||
## Перенесённые планы из `docs/Future_Features/`
|
|
||||||
|
|
||||||
### near
|
|
||||||
|
|
||||||
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
|
|
||||||
- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
|
|
||||||
|
|
||||||
### medium
|
|
||||||
|
|
||||||
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
|
|
||||||
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
|
|
||||||
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
|
||||||
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
|
||||||
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
|
||||||
- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - завершить зачистку хвостов после перевода серверной БД с `SQLite` на `PostgreSQL`.
|
|
||||||
|
|
||||||
### dao_запуск
|
|
||||||
|
|
||||||
- `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона.
|
|
||||||
|
|
||||||
### far
|
|
||||||
|
|
||||||
- `far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через SHiNE/WebRTC DataChannel и обмен файлами по чанкам с адресацией по `SHA-256`.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# Homeserver: технические команды и передача файлов через SHiNE/WebRTC
|
|
||||||
|
|
||||||
## Зачем нужна фича
|
|
||||||
|
|
||||||
Идея на дальнее будущее: дать возможность обращаться к homeserver не только как к участнику сети SHiNE, но и как к удалённой технической точке управления.
|
|
||||||
|
|
||||||
Цели:
|
|
||||||
- отправлять на homeserver технические команды в текстовом виде;
|
|
||||||
- получать текстовый ответ на команду;
|
|
||||||
- при наличии WebRTC DataChannel передавать части файлов в обе стороны;
|
|
||||||
- хранить полученные файлы на SD-карте homeserver;
|
|
||||||
- использовать единый механизм доставки как через сервер SHiNE, так и напрямую через DataChannel.
|
|
||||||
|
|
||||||
## Горизонт
|
|
||||||
|
|
||||||
`far` - идея без ближайшего срока реализации. Сейчас приоритет ниже, чем запуск и стабилизация основного проекта.
|
|
||||||
|
|
||||||
## Что именно имеется в виду
|
|
||||||
|
|
||||||
### 1. Единая модель технической команды
|
|
||||||
|
|
||||||
Техническая команда должна иметь единый смысл независимо от транспорта доставки:
|
|
||||||
- через любой доступный сервер SHiNE;
|
|
||||||
- через уже установленный WebRTC DataChannel.
|
|
||||||
|
|
||||||
Если конкретный транспорт недоступен, ответ по нему может не прийти. Это считается нормальным поведением протокола.
|
|
||||||
|
|
||||||
### 2. Команда как короткоживущий подписанный сигнал
|
|
||||||
|
|
||||||
У команды должны быть:
|
|
||||||
- `commandId`;
|
|
||||||
- временная метка;
|
|
||||||
- TTL около 10 секунд;
|
|
||||||
- криптографическая подпись.
|
|
||||||
|
|
||||||
Смысл такой:
|
|
||||||
- если команда быстро дошла, homeserver подтверждает принятие;
|
|
||||||
- если не дошла вовремя, команда считается протухшей;
|
|
||||||
- отправитель может безопасно послать повтор;
|
|
||||||
- при повторе homeserver отвечает либо `команда принята`, либо `уже выполнено ранее`.
|
|
||||||
|
|
||||||
Это даёт дедупликацию и безопасный resend без повторного выполнения действия.
|
|
||||||
|
|
||||||
### 3. Текстовые технические команды
|
|
||||||
|
|
||||||
Базовый сценарий похож на короткий удалённый shell-протокол, но на уровне строго ограниченных команд:
|
|
||||||
- отправил строку-команду;
|
|
||||||
- получил строку-ответ.
|
|
||||||
|
|
||||||
Команды не обязаны исполнять произвольный shell. Предпочтительная модель - белый список операций с контролируемым форматом аргументов и ответа.
|
|
||||||
|
|
||||||
### 4. Передача файлов только при наличии DataChannel
|
|
||||||
|
|
||||||
Если между устройствами есть WebRTC DataChannel, через него можно передавать технические сообщения для файлового обмена.
|
|
||||||
|
|
||||||
Предварительная модель:
|
|
||||||
- имя файла = `SHA-256` содержимого;
|
|
||||||
- можно запросить диапазон байт `from..to`;
|
|
||||||
- можно отправить диапазон байт `from..to`;
|
|
||||||
- homeserver хранит полученные данные на SD-карте;
|
|
||||||
- если DataChannel нет, на запрос файловой передачи возвращается ответ в духе `не могу передать, нет data channel`.
|
|
||||||
|
|
||||||
Фактически файл-обмен должен быть частным случаем общего протокола технических команд.
|
|
||||||
|
|
||||||
### 5. Установка data-соединения по явной команде
|
|
||||||
|
|
||||||
Нужна техническая команда уровня:
|
|
||||||
- `установить data-соединение`.
|
|
||||||
|
|
||||||
Ответ:
|
|
||||||
- либо `да`, после чего запускается обычная процедура `offer/answer/ICE`;
|
|
||||||
- либо `нет` и причина отказа.
|
|
||||||
|
|
||||||
### 6. Доставка на пользовательские сессии
|
|
||||||
|
|
||||||
Логика должна быть совместима с общей моделью SHiNE, где технические сигналы можно отправлять на конкретные активные сессии пользователя.
|
|
||||||
|
|
||||||
Идея:
|
|
||||||
- на любую активную сессию пользователя можно посылать техническую команду;
|
|
||||||
- контакт пользователя может инициировать такую техническую коммуникацию так же, как он уже инициирует звонок или другой служебный сигнал.
|
|
||||||
|
|
||||||
## Что нужно будет сделать при возврате к задаче
|
|
||||||
|
|
||||||
- Спроектировать отдельный формат технических команд и ack-ответов.
|
|
||||||
- Решить, будет ли это новый тип служебных сообщений в существующем протоколе блокчейн/сигналинга или отдельная ветка поверх уже имеющихся transport-операций.
|
|
||||||
- Отдельно продумать авторизацию: кто именно из контактов и какие команды имеет право слать.
|
|
||||||
- Ограничить набор допустимых команд, чтобы не превратить механизм в небезопасный удалённый shell.
|
|
||||||
- Спроектировать протокол чанков файлов: размер чанка, нумерация, повторная отправка, контроль целостности, дозагрузка, завершение файла.
|
|
||||||
- Продумать хранение на SD-карте: временные файлы, сборка чанков, проверка итогового `SHA-256`, очистка мусора.
|
|
||||||
- Продумать поведение при отсутствии DataChannel, таймаутах и дублирующихся командах.
|
|
||||||
- Проверить, как это лучше встраивать в текущие клиентские сессии, звонки и homeserver-логику.
|
|
||||||
|
|
||||||
## Вопросы для будущего уточнения
|
|
||||||
|
|
||||||
- Это должен быть строго служебный протокол или пользователь сможет вызывать его и вручную из UI.
|
|
||||||
- Нужен ли доступ только к заранее разрешённым каталогам/файлам.
|
|
||||||
- Нужна ли двусторонняя синхронизация файлов или достаточно ручных команд `запросить кусок` / `отправить кусок`.
|
|
||||||
- Нужно ли разрешать передачу файлов через сервер SHiNE как fallback, или файл-обмен должен идти только через DataChannel.
|
|
||||||
- Какой максимальный размер файлов и допустимый объём хранения на SD-карте.
|
|
||||||
|
|
||||||
## Что уже сделано
|
|
||||||
|
|
||||||
Пока только зафиксирована идея и базовая концепция. Реализация не начиналась.
|
|
||||||
|
|
||||||
## Какие документы нужно будет обновить при реализации
|
|
||||||
|
|
||||||
- `docs/Blockchain/README.md` и связанные файлы, если изменятся типы служебных сообщений или форматы блокчейн-команд.
|
|
||||||
- `docs/API/` если изменится публичный серверный API или появятся новые операции.
|
|
||||||
- `docs/Personal_Messages/Протокол_DM_v1.md` если часть маршрутизации или подтверждений будет встроена в существующую логику доставки/сессий.
|
|
||||||
- Документацию по homeserver/ESP32, если появится пользовательская или сервисная файловая логика на устройстве.
|
|
||||||
|
|
||||||
## С какого места продолжать позже
|
|
||||||
|
|
||||||
Возвращаться к задаче только после стабилизации запуска проекта и базовых текущих функций. Начинать с проектирования протокола команд и матрицы прав доступа, а уже потом переходить к DataChannel-файлообмену.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Кошелёк и пополнение баланса сияния
|
|
||||||
|
|
||||||
- Горизонт:
|
|
||||||
`medium`
|
|
||||||
- Ориентир:
|
|
||||||
среднесрочно
|
|
||||||
- Статус:
|
|
||||||
`proposal`
|
|
||||||
|
|
||||||
## Кратко
|
|
||||||
|
|
||||||
Нужно добавить кошелёк для внутреннего баланса сияния и пополнение этого баланса через блокчейн-логику проекта. Задача связана с регистрацией пользователя и будущим учётом баланса.
|
|
||||||
|
|
||||||
## Предполагаемый сценарий
|
|
||||||
|
|
||||||
1. Пользователь регистрируется и получает/подключает нужные кошельки.
|
|
||||||
2. В интерфейсе появляется баланс сияния.
|
|
||||||
3. Пользователь открывает пополнение баланса сияния.
|
|
||||||
4. Система создаёт или принимает блокчейн-операцию пополнения.
|
|
||||||
5. После подтверждения баланса UI обновляет значение.
|
|
||||||
|
|
||||||
## Что нужно продумать
|
|
||||||
|
|
||||||
1. Что именно является единицей баланса сияния.
|
|
||||||
2. Где хранится состояние баланса: в существующем блокчейне SHiNE, Solana-модуле или комбинированно.
|
|
||||||
3. Какая операция отвечает за пополнение.
|
|
||||||
4. Нужно ли делать отдельную регистрацию кошелька сияния или использовать существующую регистрацию пользователя.
|
|
||||||
5. Как баланс восстанавливается после перезагрузки клиента.
|
|
||||||
6. Какие права нужны для пополнения и списания.
|
|
||||||
7. Нужна ли история операций баланса.
|
|
||||||
|
|
||||||
## Вопросы перед реализацией
|
|
||||||
|
|
||||||
1. Пополнение баланса сияния должно идти через основной блокчейн SHiNE или через Solana-программу.
|
|
||||||
2. Нужна ли конвертация из SOL/AR в сияние.
|
|
||||||
3. Кто может выпускать или начислять сияние.
|
|
||||||
4. Нужно ли поддерживать перевод сияния между пользователями.
|
|
||||||
5. Нужны ли лимиты, комиссии или статусы подтверждения.
|
|
||||||
6. Какой экран должен показывать баланс: регистрация, профиль, кошелёк или отдельная страница.
|
|
||||||
7. Нужно ли отображать неподтверждённый баланс отдельно от подтверждённого.
|
|
||||||
|
|
||||||
## Важное ограничение
|
|
||||||
|
|
||||||
Если для баланса сияния потребуется новый формат блокчейн-блока или изменение существующего формата, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение формата блокчейна.
|
|
||||||
|
|
||||||
Если потребуется новый серверный API или изменение существующих `op`, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение API.
|
|
||||||
|
|
||||||
## Документы, которые обновить при реализации
|
|
||||||
|
|
||||||
- `docs/Blockchain/`, если появятся или изменятся блоки баланса.
|
|
||||||
- `docs/Blockchain/CHANGELOG.md`, если меняется блокчейн-формат.
|
|
||||||
- `docs/API/`, если меняется серверный API.
|
|
||||||
- после реализации отдельно согласовать ручную проверку.
|
|
||||||
- Документацию Solana-регистрации, если баланс будет связан с Solana-модулем.
|
|
||||||
|
|
||||||
## Минимальная проверка в будущем
|
|
||||||
|
|
||||||
1. Новый пользователь видит корректный начальный баланс.
|
|
||||||
2. Пополнение создаёт правильную операцию.
|
|
||||||
3. Баланс обновляется после подтверждения.
|
|
||||||
4. После перезагрузки UI баланс остаётся корректным.
|
|
||||||
5. Ошибочные или повторные операции не начисляют баланс дважды.
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# ESP32S3 как личное файловое хранилище SHiNE
|
|
||||||
|
|
||||||
## Горизонт
|
|
||||||
|
|
||||||
Среднесрочный: ближайшие недели или 1-2 месяца.
|
|
||||||
|
|
||||||
## Зачем нужна фича
|
|
||||||
|
|
||||||
Нужно проработать маленький физический сервер на ESP32S3 как персональное или доверенное файловое хранилище SHiNE.
|
|
||||||
|
|
||||||
Идея: при обмене сообщениями пользователи смогут использовать такой сервер для хранения своих файлов, вложений, файлов общих переписок и связанных данных.
|
|
||||||
|
|
||||||
## Что нужно сделать
|
|
||||||
|
|
||||||
- Описать роль ESP32S3-сервера в общей архитектуре ключей и сессий.
|
|
||||||
- Определить, какие ключи может хранить такое устройство.
|
|
||||||
- Решить, хранит ли устройство только файлы или также подписывает пользовательские операции.
|
|
||||||
- Описать протокол загрузки, скачивания и удаления файлов.
|
|
||||||
- Определить правила шифрования файлов до отправки на устройство.
|
|
||||||
- Продумать индексацию файлов для личных и общих переписок.
|
|
||||||
- Решить, как устройство авторизуется на основном сервере SHiNE.
|
|
||||||
|
|
||||||
## Вопросы перед реализацией
|
|
||||||
|
|
||||||
- ESP32S3 должен работать как полностью локальное устройство или как публично доступный мини-сервер?
|
|
||||||
- Нужен ли внешний relay, если устройство находится за NAT?
|
|
||||||
- Какие ограничения по размеру файла считаем допустимыми?
|
|
||||||
- Хранит ли устройство метаданные переписок или только зашифрованные blob-файлы?
|
|
||||||
- Как восстанавливать доступ, если устройство потеряно или заменено?
|
|
||||||
|
|
||||||
## Что уже сделано
|
|
||||||
|
|
||||||
Код не реализован. Идея зафиксирована как будущая задача после описания модели ключей.
|
|
||||||
|
|
||||||
## Документы, которые нужно обновить при возврате
|
|
||||||
|
|
||||||
- `docs/Keys/README.md`
|
|
||||||
- `docs/Personal_Messages/Протокол_DM_v1.md`
|
|
||||||
- `docs/API/`
|
|
||||||
- `docs/Blockchain/`, если появятся новые блоки или команды для файлов.
|
|
||||||
|
|
||||||
## С какого места продолжать
|
|
||||||
|
|
||||||
Начать с короткого протокольного документа: роли устройства, авторизация, шифрование файлов, минимальные API-операции и сценарии восстановления.
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# Сессионные homeserver-ы в PDA пользователя
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
`future`
|
|
||||||
|
|
||||||
- Горизонт:
|
|
||||||
`medium`
|
|
||||||
|
|
||||||
- Ориентир:
|
|
||||||
после завершения первого этапа по пользовательским сессиям
|
|
||||||
|
|
||||||
- Основание:
|
|
||||||
Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних homeserver-ов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли.
|
|
||||||
|
|
||||||
## Зачем нужна фича
|
|
||||||
|
|
||||||
У одного пользователя может быть несколько доверенных внутренних homeserver-ов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели.
|
|
||||||
|
|
||||||
Это нужно, чтобы:
|
|
||||||
|
|
||||||
- хранить несколько homeserver-ов у одного пользователя одновременно;
|
|
||||||
- различать обычные клиентские сессии и серверные сессии по явному типу;
|
|
||||||
- дать расширяемый формат записи с версией;
|
|
||||||
- использовать единый подход для DM, звонков и внутренних команд между сессиями.
|
|
||||||
|
|
||||||
## Целевая идея
|
|
||||||
|
|
||||||
В пользовательском PDA должен появиться список записей сессий, где каждая запись содержит как минимум:
|
|
||||||
|
|
||||||
- `sessionType` (`u8`);
|
|
||||||
- `sessionVersion` (`u8`);
|
|
||||||
- `sessionName`;
|
|
||||||
- `sessionPubKey`.
|
|
||||||
|
|
||||||
Предварительные значения:
|
|
||||||
|
|
||||||
- тип `1` - обычная пользовательская сессия;
|
|
||||||
- тип `100` - homeserver пользователя;
|
|
||||||
- версия `1` - первая рабочая версия формата записи сессии.
|
|
||||||
|
|
||||||
На текущем этапе под это уже зарезервирован отдельный блок `SessionsBlock` с `block_type = 55`, а `TrustedStateBlock` остаётся на `50`.
|
|
||||||
|
|
||||||
Важно: homeserver-ов у одного пользователя может быть несколько.
|
|
||||||
|
|
||||||
## Архитектурный принцип
|
|
||||||
|
|
||||||
Внутренний протокол взаимодействия должен оставаться транспортным.
|
|
||||||
|
|
||||||
То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки homeserver-а, а должен:
|
|
||||||
|
|
||||||
- доставлять сообщения между сессиями;
|
|
||||||
- доставлять сигналы звонков между сессиями;
|
|
||||||
- хранить и маршрутизировать адресацию;
|
|
||||||
- не принимать на себя бизнес-логику содержимого внутренних команд.
|
|
||||||
|
|
||||||
## Что уже подтверждается текущим кодом
|
|
||||||
|
|
||||||
- Личные сообщения уже доставляются по всем сессиям целевого пользователя с отдельным учётом доставки на каждую сессию.
|
|
||||||
- Подтверждение доставки DM уже идёт отдельно по каждой сессии.
|
|
||||||
- Вызов звонка уже рассылается по нескольким активным сессиям пользователя.
|
|
||||||
- Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя.
|
|
||||||
|
|
||||||
Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол homeserver-а".
|
|
||||||
|
|
||||||
## Что нужно сделать при возврате к задаче
|
|
||||||
|
|
||||||
1. Согласовать финальный бинарный формат записи сессии в PDA пользователя.
|
|
||||||
2. Проверить, не меняет ли это уже опубликованный формат пользовательской PDA-записи.
|
|
||||||
3. Если формат PDA меняется, заранее предупредить пользователя и получить отдельное подтверждение.
|
|
||||||
4. Решить, где именно хранится массив сессий:
|
|
||||||
- в основной записи пользователя;
|
|
||||||
- в отдельной PDA-структуре расширения;
|
|
||||||
- или в смешанной схеме с базовой записью и внешними индексами.
|
|
||||||
5. Зафиксировать ограничения:
|
|
||||||
- максимальное число сессий;
|
|
||||||
- максимальную длину `sessionName`;
|
|
||||||
- правила удаления и обновления записи;
|
|
||||||
- правила ротации `sessionPubKey`.
|
|
||||||
6. Продумать, как UI и сервер будут отличать тип `1` и тип `100`.
|
|
||||||
7. Определить, какие внутренние сообщения homeserver-а останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации.
|
|
||||||
8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов.
|
|
||||||
9. После реализации обязательно обновить документацию.
|
|
||||||
|
|
||||||
## Что нужно обновить при реализации
|
|
||||||
|
|
||||||
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
|
||||||
- `docs/Solana_Architecture/README.md`
|
|
||||||
- `docs/Инициализация_Solana_регистрации/README.md`
|
|
||||||
- `docs/Keys/README.md`
|
|
||||||
- `docs/Personal_Messages/Протокол_DM_v1.md`, если изменится адресация DM по типам сессий
|
|
||||||
- `docs/API/`, если появятся новые серверные операции или изменятся ответы
|
|
||||||
|
|
||||||
## Что пока не делать
|
|
||||||
|
|
||||||
- Не включать это автоматически в основной deploy сервера.
|
|
||||||
- Не менять сейчас Solana PDA-формат без отдельного подтверждения.
|
|
||||||
- Не добавлять временные поля в публичный API "на всякий случай".
|
|
||||||
|
|
||||||
## С какого места продолжать
|
|
||||||
|
|
||||||
Продолжать после завершения первой части:
|
|
||||||
|
|
||||||
1. описать минимальный формат записи пользовательской сессии;
|
|
||||||
2. отдельно решить, живут ли homeserver-ы в том же списке, что и обычные сессии;
|
|
||||||
3. затем уже проектировать операции регистрации, обновления и отключения таких сессий.
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Подключение других устройств через QR
|
|
||||||
|
|
||||||
- Горизонт:
|
|
||||||
`medium`
|
|
||||||
- Ориентир:
|
|
||||||
позже, не сейчас
|
|
||||||
- Статус:
|
|
||||||
`future`
|
|
||||||
|
|
||||||
## Зачем нужна фича
|
|
||||||
|
|
||||||
Нужно нормально довести подключение другого устройства через QR-код. Сейчас есть полуготовая заготовка, но сценарий работает нестабильно и требует отдельной доработки.
|
|
||||||
|
|
||||||
## Что уже есть
|
|
||||||
|
|
||||||
- В UI уже есть экраны:
|
|
||||||
- `shine-UI/js/pages/connect-device-view.js`
|
|
||||||
- `shine-UI/js/pages/device-qr-view.js`
|
|
||||||
- Есть сервис переноса ключей через QR:
|
|
||||||
- `shine-UI/js/services/qr-key-transfer-service.js`
|
|
||||||
- Логика частично собрана, но её нельзя считать завершённой или надёжной.
|
|
||||||
|
|
||||||
## Что нужно будет сделать потом
|
|
||||||
|
|
||||||
1. Проверить и довести формат QR-передачи.
|
|
||||||
2. Проверить сканирование и ручной ввод QR-текста.
|
|
||||||
3. Проверить перенос `device`, `blockchain`, `root` ключей только по реальному наличию на исходном устройстве.
|
|
||||||
4. Проверить, что после переноса очищается старая история нужного логина и не ломается вход.
|
|
||||||
5. Отдельно проверить сценарий без `BarcodeDetector`.
|
|
||||||
6. Довести экран подтверждения на втором устройстве.
|
|
||||||
|
|
||||||
## Что сейчас важно
|
|
||||||
|
|
||||||
- Не считать эту часть готовой.
|
|
||||||
- Не возвращать её в активную разработку без отдельной команды пользователя.
|
|
||||||
- Если вернёмся к задаче, сначала нужно понять, что именно уже работает, а что нет, и потом починить целиком.
|
|
||||||
|
|
||||||
## Что обновить при возврате
|
|
||||||
|
|
||||||
- после реализации отдельно согласовать ручную проверку
|
|
||||||
- `shine-UI/js/pages/connect-device-view.js`
|
|
||||||
- `shine-UI/js/pages/device-qr-view.js`
|
|
||||||
- `shine-UI/js/services/qr-key-transfer-service.js`
|
|
||||||
- документацию по ключам, если формат переноса меняется
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Перенести старые сессионные сигналы на `SendSignal`
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
В проект добавлен новый общий межсессионный transport `SendSignal`.
|
|
||||||
|
|
||||||
Первое текущее применение:
|
|
||||||
|
|
||||||
- `remote AddBlock via homeserver session`
|
|
||||||
|
|
||||||
Старые сценарии пока оставлены на прежнем транспорте, чтобы не ломать уже работающий код.
|
|
||||||
|
|
||||||
## Что перенести позже
|
|
||||||
|
|
||||||
1. Звонковые сигналы, которые сейчас идут через `CallSignalToSession`.
|
|
||||||
2. Старый wallet/ESP32 обмен, где технические команды всё ещё привязаны к call-like транспорту.
|
|
||||||
3. Остальные доверенные межсессионные команды одного пользователя.
|
|
||||||
|
|
||||||
## Что важно учесть при переносе
|
|
||||||
|
|
||||||
- не ломать обратную совместимость работающих звонков;
|
|
||||||
- сохранить текущую маршрутизацию по `sessionId`;
|
|
||||||
- договориться о едином `signalType`;
|
|
||||||
- отдельно описать миграцию клиентских обработчиков событий:
|
|
||||||
- `IncomingCallSignal` -> `IncomingSignal`
|
|
||||||
|
|
||||||
## С какого сценария продолжать
|
|
||||||
|
|
||||||
Начинать перенос со звонков, но только после отдельной ручной проверки того, что `SendSignal` стабильно отработал на `remote AddBlock`.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Переход с SQLite на PostgreSQL
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
Переход runtime-сервера на `PostgreSQL` уже выполнен, но после него остались хвосты в документации, именах, комментариях и части прямых SQL-запросов.
|
|
||||||
|
|
||||||
Этот TODO теперь нужен не для самого перехода, а для доведения проекта до полностью консистентного состояния после ухода от `SQLite`.
|
|
||||||
|
|
||||||
## Что сделать
|
|
||||||
|
|
||||||
- Дочистить документацию, где ещё описан `SQLite` как текущий runtime.
|
|
||||||
- Убрать или переименовать legacy-названия и комментарии, которые уже не соответствуют PostgreSQL runtime.
|
|
||||||
- Постепенно перенести оставшиеся прямые SQL-запросы из хэндлеров в DAO/service.
|
|
||||||
- Проверить case-insensitive сравнения, уникальные ограничения и индексы уже в чисто PostgreSQL модели.
|
|
||||||
- Отдельно пройтись по TODO/служебным документам и убрать ссылки на удалённые SQLite-классы как на актуальный код.
|
|
||||||
|
|
||||||
## Что уже есть в коде
|
|
||||||
|
|
||||||
- Доступ к БД в основном проходит через DAO-слой, а не полностью размазан по проекту.
|
|
||||||
- Основная серверная логика уже разделена по модулям.
|
|
||||||
- Runtime-сервер уже работает только с `PostgreSQL`.
|
|
||||||
- Пустая БД инициализируется автоматически через `schema_v1`.
|
|
||||||
|
|
||||||
## Откуда продолжать
|
|
||||||
|
|
||||||
- Продолжать с зачистки legacy-документации и комментариев.
|
|
||||||
- Затем добрать оставшиеся прямые SQL-запросы вне DAO.
|
|
||||||
- После этого можно отдельно решать вопрос косметического переименования `*V2`, `DbController` и других переходных сущностей.
|
|
||||||
|
|
||||||
## Что потом обновить
|
|
||||||
|
|
||||||
- Серверную документацию по БД и миграциям.
|
|
||||||
- Инструкции по локальному запуску сервера.
|
|
||||||
- Скрипты деплоя и настройки окружения.
|
|
||||||
|
|
||||||
## Что временно отключено и что вернуть потом
|
|
||||||
|
|
||||||
- В серверном runtime временно снята проверка `channelName must not contain only digits`
|
|
||||||
в `SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java`.
|
|
||||||
- Причина: на боевой истории уже есть блоки с числовыми именами каналов, и сервер
|
|
||||||
должен уметь с нуля восстановить `blockchain_state` и `.bch`, подтягивая старые
|
|
||||||
блоки от других sync-серверов.
|
|
||||||
- Что осталось как текущее поведение:
|
|
||||||
- UI по-прежнему не даёт создать новый канал только из цифр;
|
|
||||||
- сервер принимает такие имена, чтобы не ломать replay старых блоков.
|
|
||||||
- Что нужно сделать отдельным следующим шагом:
|
|
||||||
- вернуть серверное продуктовое правило для новых каналов;
|
|
||||||
- сделать это совместимо со старой историей, чтобы импорт/реплей существующих
|
|
||||||
блоков не падал на старых числовых channel name.
|
|
||||||
- Какие документы обновить при возврате:
|
|
||||||
- `docs/libs/shine-server-bd/POSTGRES_RUNTIME_SCHEMA_V1.md`;
|
|
||||||
- UI/серверные документы по правилам имён каналов, если появится отдельная спецификация.
|
|
||||||
- С какого сценария продолжать:
|
|
||||||
- повторить cold start тест на `t2`: пустая PostgreSQL schema, удалённые `.bch`,
|
|
||||||
новый запуск, ожидание полной синхронизации от `t1`/`t3`.
|
|
||||||
- Последняя полная рабочая точка с этим временным компромиссом:
|
|
||||||
- ветка `migration-postgres`, коммит будет создан после этой записи.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Пополнение Solana и Arweave через внешний сервис покупки
|
|
||||||
|
|
||||||
- Горизонт:
|
|
||||||
`near`
|
|
||||||
- Ориентир:
|
|
||||||
сегодня/завтра
|
|
||||||
- Статус:
|
|
||||||
`proposal`
|
|
||||||
|
|
||||||
## Кратко
|
|
||||||
|
|
||||||
Нужно добавить удобное пополнение кошельков на экране регистрации/кошелька: для Solana и Arweave дать отдельные действия `Пополнить`, которые ведут на международный сервис покупки криптовалюты с карты и помогают пользователю скопировать адрес кошелька.
|
|
||||||
|
|
||||||
## Пользовательский сценарий
|
|
||||||
|
|
||||||
1. Пользователь видит адрес кошелька Solana или Arweave.
|
|
||||||
2. Нажимает `Пополнить`.
|
|
||||||
3. Открывается промежуточное окно с инструкцией:
|
|
||||||
- сейчас пользователь перейдёт на страницу покупки/пополнения;
|
|
||||||
- нужно указать или проверить адрес кошелька;
|
|
||||||
- после оплаты нужно закрыть внешнюю страницу и вернуться назад;
|
|
||||||
- Solana обычно приходит быстро, ориентир 10-15 секунд после подтверждения сети;
|
|
||||||
- Arweave может идти дольше, точное время нужно уточнить по выбранному сервису.
|
|
||||||
4. В окне есть кнопки:
|
|
||||||
- `Скопировать адрес и перейти`;
|
|
||||||
- `Перейти без копирования`.
|
|
||||||
5. Для Solana и Arweave используются разные окна/инструкции и, возможно, разные внешние ссылки.
|
|
||||||
|
|
||||||
## Что нужно сделать
|
|
||||||
|
|
||||||
1. Найти текущий экран, где показываются кошельки при регистрации и пополнении.
|
|
||||||
2. Найти текущую ссылку покупки Arweave, если она уже есть в UI.
|
|
||||||
3. Выбрать международный сервис покупки Solana с карты, не российский.
|
|
||||||
4. Проверить, поддерживает ли сервис deep link с предзаполненным адресом кошелька.
|
|
||||||
5. Если deep link невозможен, реализовать промежуточное окно с копированием адреса.
|
|
||||||
6. Добавить отдельные действия для Solana и Arweave.
|
|
||||||
7. Сделать текст инструкции коротким и понятным.
|
|
||||||
8. Проверить, что адрес копируется в буфер обмена в браузере.
|
|
||||||
9. Проверить мобильный сценарий и desktop-сценарий.
|
|
||||||
|
|
||||||
## Вопросы перед реализацией
|
|
||||||
|
|
||||||
1. Какой сервис покупки Solana использовать: тот же провайдер, что для Arweave, или другой международный on-ramp.
|
|
||||||
2. Нужно ли разрешать покупку только SOL или также USDC/SPL-токены на Solana.
|
|
||||||
3. Где именно показывать кнопку `Пополнить`: только регистрация, настройки кошелька или оба места.
|
|
||||||
4. Нужно ли показывать предупреждение о комиссиях и стороннем сервисе.
|
|
||||||
5. Нужно ли открывать внешнюю страницу в новой вкладке или в текущем окне.
|
|
||||||
6. Нужно ли логировать факт нажатия `Пополнить` на сервере.
|
|
||||||
7. Какой точный текст использовать для времени прихода Arweave.
|
|
||||||
|
|
||||||
## Риски и ограничения
|
|
||||||
|
|
||||||
- On-ramp-сервисы меняют ссылки и параметры, поэтому deep link нужно проверять перед реализацией.
|
|
||||||
- Clipboard API может требовать HTTPS и пользовательский жест.
|
|
||||||
- Нельзя обещать точное время поступления средств: лучше писать ориентир и зависимость от сети/провайдера.
|
|
||||||
- Внешний сервис может быть недоступен в отдельных странах или для отдельных карт.
|
|
||||||
|
|
||||||
## Документы, которые обновить при реализации
|
|
||||||
|
|
||||||
- Документацию UI/кошельков, если такая есть.
|
|
||||||
- после реализации отдельно согласовать ручную проверку.
|
|
||||||
- `docs/API/`, только если появится новый серверный API или логирование.
|
|
||||||
|
|
||||||
## Минимальная проверка
|
|
||||||
|
|
||||||
1. На Solana-кошельке открывается правильное окно пополнения.
|
|
||||||
2. Кнопка `Скопировать адрес и перейти` копирует Solana-адрес и открывает внешний сервис.
|
|
||||||
3. Кнопка `Перейти без копирования` открывает внешний сервис без копирования.
|
|
||||||
4. Аналогичный сценарий работает для Arweave.
|
|
||||||
5. На мобильном экране текст и кнопки не перекрываются.
|
|
||||||
6. Возврат назад в приложение не ломает состояние регистрации/кошелька.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Убрать временную очистку `signed_messages_v2` в миграции БД v11
|
|
||||||
|
|
||||||
## Зачем это нужно
|
|
||||||
|
|
||||||
При переходе на новый DM-протокол `SHiNE_DM v1` была добавлена временная миграция БД `v11`, которая при первом старте на старой базе полностью очищает:
|
|
||||||
|
|
||||||
- `signed_messages_v2`
|
|
||||||
- `signed_message_session_delivery`
|
|
||||||
|
|
||||||
Это сделано как защитный reset, потому что старые DM-строки и backlog могли быть несовместимы с новым форматом, новой логикой tombstone и новым клиентским E2EE-разбором.
|
|
||||||
|
|
||||||
## Что именно потом сделать
|
|
||||||
|
|
||||||
- найти историческое место, где была добавлена временная миграция `migrateToV11()`, и убрать её остатки из runtime-логики/документации;
|
|
||||||
- удалить helper `clearLegacySignedMessagesForDmV11(...)`;
|
|
||||||
- поднять версию схемы дальше обычным образом уже без destructive-cleanup;
|
|
||||||
- при необходимости заменить это на нормальную точечную миграцию старых DM-записей или совсем убрать поддержку старой истории.
|
|
||||||
|
|
||||||
## Что уже есть в коде
|
|
||||||
|
|
||||||
- `LATEST_SCHEMA_VERSION = 11`;
|
|
||||||
- при миграции в `v11` выполняется полная очистка DM-таблиц;
|
|
||||||
- в коде прямо оставлен комментарий, что это временная мера.
|
|
||||||
|
|
||||||
## Откуда продолжать
|
|
||||||
|
|
||||||
Продолжать от коммита, в котором была добавлена миграция `v11` для очистки DM-таблиц после перехода на `SHiNE_DM v1`.
|
|
||||||
|
|
||||||
## Какие документы потом обновить
|
|
||||||
|
|
||||||
- `docs/Personal_Messages/Протокол_DM_v1.md`, если изменится стратегия миграции старой истории;
|
|
||||||
- `docs/Personal_Messages/Формат_DM_v1.md`, если появится отдельное правило совместимости/конвертации;
|
|
||||||
- при необходимости `docs/API/12_Direct_Messages_Push_Calls_API.md`, если затронется поведение backlog/доставки.
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# ESP32 и wallet-extension: перейти на единый `SendSignal` и обязательную подпись `client key`
|
||||||
|
|
||||||
|
## Зачем
|
||||||
|
|
||||||
|
Сейчас в проекте уже есть универсальный transport `SendSignal`, который умеет работать и в режим `all_sessions`, и в режим `single_session`.
|
||||||
|
|
||||||
|
При этом в коде всё ещё живут старые call-like транспорты:
|
||||||
|
|
||||||
|
- `CallInviteBroadcast`
|
||||||
|
- `CallSignalToSession`
|
||||||
|
|
||||||
|
Для web-call логики их можно постепенно убрать в пользу одного `SendSignal`.
|
||||||
|
|
||||||
|
Отдельный хвост остался в ESP32 homeserver/wallet-сценариях:
|
||||||
|
|
||||||
|
- в ESP32-скетче технический ответ wallet RPC всё ещё уходит через `CallSignalToSession`;
|
||||||
|
- в ESP32 send-signal сценарии сейчас допускается режим только с `sessionSignatureB64`, без обязательной подписи `client key`;
|
||||||
|
- такое поведение выбивается из общей модели безопасности и создаёт лишнюю специальную ветку.
|
||||||
|
|
||||||
|
## Что уже есть в коде
|
||||||
|
|
||||||
|
- универсальный серверный transport `SendSignal` уже существует и поддерживает:
|
||||||
|
- `single_session`;
|
||||||
|
- `all_sessions`.
|
||||||
|
- web UI уже использует `SendSignal` в части межсессионных сценариев.
|
||||||
|
- звонки пока ещё используют старые call-specific методы.
|
||||||
|
- ESP32 homeserver main использует:
|
||||||
|
- `CallSignalToSession` для wallet RPC response;
|
||||||
|
- `SendSignal` для части технических ответов.
|
||||||
|
- в ESP32 есть код генерации:
|
||||||
|
- `sessionSignatureB64`;
|
||||||
|
- `clientSignatureB64`;
|
||||||
|
но для некоторых send-signal вызовов `client key` сейчас не обязателен.
|
||||||
|
|
||||||
|
## Что сделать
|
||||||
|
|
||||||
|
1. Перевести ESP32 сценарии со старого `CallSignalToSession` на `SendSignal(single_session)`.
|
||||||
|
2. Убрать из ESP32-special flow странное исключение, где можно жить без `client key`.
|
||||||
|
3. Переделать wallet-extension / связанный ESP32-клиент так, чтобы он тоже хранил `client key` и подписывал им `SendSignal`, как обычные клиенты SHiNE.
|
||||||
|
4. Сделать `SendSignal` контрактно обязательным по подписи `client key`, а не только `session key`.
|
||||||
|
5. После этого зачистить legacy call-like транспорт там, где он больше не нужен.
|
||||||
|
6. Отдельно проверить, не осталось ли старых обработчиков, завязанных на:
|
||||||
|
- `IncomingCallSignal`;
|
||||||
|
- `IncomingCallInvite`;
|
||||||
|
там, где должен работать общий `IncomingSignal`.
|
||||||
|
|
||||||
|
## Отдельно про шифрование
|
||||||
|
|
||||||
|
Нужно продумать будущее расширение: чтобы через `SendSignal` можно было передавать не только подписанные, но и зашифрованные payload.
|
||||||
|
|
||||||
|
Предварительный вывод по текущей архитектуре:
|
||||||
|
|
||||||
|
- для сервера это почти не должно требовать специальной логики;
|
||||||
|
- сервер в `SendSignal` в основном:
|
||||||
|
- валидирует подписи;
|
||||||
|
- маршрутизирует событие в нужные сессии;
|
||||||
|
- пересылает `data` дальше.
|
||||||
|
|
||||||
|
Если UI/ESP32 начнут передавать уже клиентски зашифрованный `data`, сервер должен это пережить без серьёзных изменений, пока:
|
||||||
|
|
||||||
|
- формат `data` остаётся строковым/JSON-совместимым;
|
||||||
|
- сервер продолжает считать `SHA-256` от фактической передаваемой строки;
|
||||||
|
- подписи строятся по уже зашифрованному payload.
|
||||||
|
|
||||||
|
То есть возможное E2E-шифрование `SendSignal` в будущем скорее относится к клиентским участникам протокола, а не к серверной маршрутизации.
|
||||||
|
|
||||||
|
## Что важно учесть при миграции
|
||||||
|
|
||||||
|
- не ломать текущие звонки и текущий wallet RPC сценарий одним большим рывком;
|
||||||
|
- сначала перевести ESP32 и wallet-extension на единый `SendSignal`;
|
||||||
|
- только потом убирать старые call-specific методы;
|
||||||
|
- не оставлять “особый ESP32-режим” без `client key`;
|
||||||
|
- проверить, что у расширения/ESP32 есть безопасное хранение `client key` и понятный сценарий восстановления/перепривязки.
|
||||||
|
|
||||||
|
## Откуда продолжать
|
||||||
|
|
||||||
|
- от `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`;
|
||||||
|
- от текущего ESP32 homeserver-скетча:
|
||||||
|
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
|
||||||
|
- от web UI auth/call transport логики:
|
||||||
|
- `shine-UI/js/services/auth-service.js`
|
||||||
|
- `shine-UI/js/services/call-service.js`
|
||||||
|
|
||||||
|
## Какие документы потом обновить
|
||||||
|
|
||||||
|
- `TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md`;
|
||||||
|
- `TODO/README.md`;
|
||||||
|
- документацию по ESP32 homeserver/wallet flow, если появится отдельный утверждённый transport contract;
|
||||||
|
- документацию по межсессионным сигналам, если будет зафиксирован единый список `signalType`.
|
||||||
+8
@@ -1,5 +1,13 @@
|
|||||||
# Восстановить полную логику `shine_login_guard`
|
# Восстановить полную логику `shine_login_guard`
|
||||||
|
|
||||||
|
|
||||||
|
Тоесть сделать что бы нормально проверялись логины пользователей
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Статус: отложено.
|
Статус: отложено.
|
||||||
|
|
||||||
## Зачем это нужно
|
## Зачем это нужно
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Передачу билетов владельцами со счёта на счёт
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
Баланс в салане
|
||||||
|
|
||||||
|
баланс в Арвив / и турбо
|
||||||
|
|
||||||
|
Балан лимит МБ / оно же сияния токены SHN
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Сделать поддержку нескольких залогиненных аккаунтов враз тоесть что бы можно было менять акаунт под которым заходить
|
||||||
|
- подумать о деталях, а так хорошая тема - и тестировать удобнее станет
|
||||||
|
-
|
||||||
-10
@@ -27,13 +27,3 @@
|
|||||||
|
|
||||||
- `BlockchainTmpRecoveryOnStartup` и `BlockchainResyncRecoveryOnStartup` уже умеют добирать незавершённые хвосты после старта.
|
- `BlockchainTmpRecoveryOnStartup` и `BlockchainResyncRecoveryOnStartup` уже умеют добирать незавершённые хвосты после старта.
|
||||||
- `AddBlock` уже стал crash-safe через `tmp_bch` / `write_check` / `write_pending`.
|
- `AddBlock` уже стал crash-safe через `tmp_bch` / `write_check` / `write_pending`.
|
||||||
|
|
||||||
## Откуда продолжать
|
|
||||||
|
|
||||||
- начать с `systemd`-юнита и базового shutdown-hook в сервере;
|
|
||||||
- затем проверить, что текущие операции реально завершаются в отведённые 30 секунд.
|
|
||||||
|
|
||||||
## Какие документы потом обновить
|
|
||||||
|
|
||||||
- `deploy/`;
|
|
||||||
- `docs/Blockchain/sync-between-servers.md`, если изменится поведение остановки/восстановления.
|
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
Там были какието разные апи для ошибок звонка
|
||||||
|
и для старта тестовых соединений
|
||||||
|
|
||||||
|
убрать короче лишнее
|
||||||
|
|
||||||
|
|
||||||
|
и сделать номальное апи что бы клиент мог
|
||||||
|
высылать уведомления об ошибках на сервер
|
||||||
|
предлогал отправить уведомление
|
||||||
|
|
||||||
|
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
Сделать что бы если сервера с исходящими сообщениями не могут доставить на входящие то
|
||||||
|
- делать повторные попытки через время
|
||||||
|
- если так и не получилось и никужа не доставлено уведомлять пользователя
|
||||||
|
|
||||||
|
- Так же как вариант собирать подписи что полученно входящее сообщение с серверов пользователя полчателя
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
Как_вариант_можно_сделать_hameserver_как_хранилище_файлов_пользователя
|
||||||
|
И всё это можно сделать внутри ESP32
|
||||||
|
|
||||||
|
Хотя не понятно надо ли так делать - потому что вроде удобно,
|
||||||
|
но тем не менее и сложно как то объяснить такой функционал людям
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Постоянный server-to-server WS и DM sync
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
Текущий production-режим SHiNE рассчитан на один основной сервер. Межсерверная синхронизация относится к будущей децентрализации и не должна блокировать выкладку односерверной production-версии.
|
|
||||||
|
|
||||||
Сейчас синхронизация между серверами работает в основном как periodic sync и one-shot push. Для нормальной репликации в будущем ещё нужен постоянный межсерверный канал:
|
|
||||||
|
|
||||||
- живое подключение к партнёру;
|
|
||||||
- push новых блоков;
|
|
||||||
- push DM;
|
|
||||||
- ACK на доставку;
|
|
||||||
- backoff/reconnect;
|
|
||||||
- стартовый backfill.
|
|
||||||
|
|
||||||
## Что сделать
|
|
||||||
|
|
||||||
1. Поднять постоянное WebSocket-соединение между партнёрскими серверами.
|
|
||||||
2. Сделать push новых блоков сразу после `AddBlock`.
|
|
||||||
3. Сделать push DM-блоков между серверами.
|
|
||||||
4. Добавить ACK и повторную отправку при сбое.
|
|
||||||
5. Ввести стартовый обмен курсорами и добор хвоста.
|
|
||||||
|
|
||||||
## Что уже есть
|
|
||||||
|
|
||||||
- `ListBlockchainHeads`;
|
|
||||||
- `GetBlockchainBlock`;
|
|
||||||
- `GetSyncUserProfile`;
|
|
||||||
- базовый periodic sync;
|
|
||||||
- базовый backfill хвоста;
|
|
||||||
- базовый full resync при divergence.
|
|
||||||
|
|
||||||
## Откуда продолжать
|
|
||||||
|
|
||||||
- от текущего `sync_servers` bootstrap и `PeriodicBlockchainSyncService`;
|
|
||||||
- дальше выделить отдельный межсерверный transport layer.
|
|
||||||
|
|
||||||
## Какие документы потом обновить
|
|
||||||
|
|
||||||
- `docs/Blockchain/sync-between-servers.md`;
|
|
||||||
- `docs/Personal_Messages/Протокол_DM_v1.md`;
|
|
||||||
- `docs/Personal_Messages/Формат_DM_v1.md`;
|
|
||||||
- `docs/API/`.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Децентрализация
|
|
||||||
|
|
||||||
Папка для задач, которые нужны для будущего режима с несколькими серверами, Solana/PDA-синхронизацией и внешним хранением данных.
|
|
||||||
|
|
||||||
## Текущий статус
|
|
||||||
|
|
||||||
Сейчас production-режим SHiNE считается односерверным: один сервер обслуживает пользователей, сообщения, звонки и запись данных. Задачи из этой папки не являются блокерами для выкладки текущего репозитория на GitHub и запуска одного production-сервера.
|
|
||||||
|
|
||||||
## Задачи
|
|
||||||
|
|
||||||
- `односерверный_production_режим.md` - зафиксировать границы текущей production-версии.
|
|
||||||
- `запись_блокчейнов_в_arweave.md` - вынести долговременную запись блокчейнов в Arweave.
|
|
||||||
- `realtime_pda_solana_sync.md` - сделать онлайн-синхронизацию PDA/Solana в реальном времени.
|
|
||||||
- `межсерверная_передача_сообщений.md` - реализовать доставку сообщений между серверами.
|
|
||||||
- `межсерверные_звонки.md` - реализовать маршрутизацию звонков между серверами.
|
|
||||||
- `2026-06-26_1805_межсерверный_ws_и_dm_sync.md` - старый план постоянного server-to-server WS и DM sync, перенесённый в контекст децентрализации.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Realtime-синхронизация PDA и Solana
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
В будущем PDA-записи и Solana-состояние должны автоматически и быстро синхронизироваться с серверным состоянием, чтобы данные пользователей, homeserver-сессии и связанные записи не расходились.
|
|
||||||
|
|
||||||
## Что сделать
|
|
||||||
|
|
||||||
1. Определить, какие серверные события должны обновлять PDA.
|
|
||||||
2. Добавить очередь/воркер для надёжной отправки изменений в Solana.
|
|
||||||
3. Добавить периодическую сверку серверного состояния с PDA.
|
|
||||||
4. Добавить обработку ошибок, повторов и конфликтов версий.
|
|
||||||
5. Добавить мониторинг задержек и неуспешных Solana-транзакций.
|
|
||||||
|
|
||||||
## Что учесть
|
|
||||||
|
|
||||||
- Solana/Anchor-модуль находится в `shine-solana/shine/` и ведётся отдельно от основного server/UI deploy.
|
|
||||||
- Перед изменениями внутри Solana-модуля нужно читать `shine-solana/shine/AGENTS.md`.
|
|
||||||
- Основная инструкция по Solana-регистрации находится в `docs/Инициализация_Solana_регистрации/README.md`.
|
|
||||||
- Формат пользовательской PDA-записи описан в `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`.
|
|
||||||
|
|
||||||
## Документы, которые потом нужно обновить
|
|
||||||
|
|
||||||
- `docs/Инициализация_Solana_регистрации/README.md`;
|
|
||||||
- `docs/Solana_Architecture/README.md`;
|
|
||||||
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`, если меняется формат PDA.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
Отложено до этапа децентрализации.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Запись блокчейнов в Arweave
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
Для будущей децентрализации нужно долговременное внешнее хранение блокчейнов, чтобы данные не зависели только от одного серверного диска.
|
|
||||||
|
|
||||||
## Что сделать
|
|
||||||
|
|
||||||
1. Определить, какие блокчейны и какие диапазоны блоков записываются в Arweave.
|
|
||||||
2. Зафиксировать формат пачки блоков, метаданных, ссылок и контрольных хэшей.
|
|
||||||
3. Добавить безопасный механизм публикации без хранения приватного JWK в git.
|
|
||||||
4. Добавить проверку уже загруженных диапазонов, чтобы не плодить дубли.
|
|
||||||
5. Описать восстановление блокчейна из Arweave при потере локальных данных.
|
|
||||||
|
|
||||||
## Важные ограничения
|
|
||||||
|
|
||||||
- Любое изменение формата блокчейна требует отдельного предупреждения и явного подтверждения пользователя.
|
|
||||||
- Добавление данных в блокчейн должно выполняться только через `AddBlock`.
|
|
||||||
- Секреты Arweave нельзя хранить в репозитории.
|
|
||||||
|
|
||||||
## Документы, которые потом нужно обновить
|
|
||||||
|
|
||||||
- `docs/Blockchain/README.md`;
|
|
||||||
- `docs/Blockchain/CHANGELOG.md`;
|
|
||||||
- документы deploy/секретов в `deploy/`, если появятся новые параметры.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
Отложено до этапа децентрализации.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Межсерверная передача сообщений
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
Когда у SHiNE появится несколько серверов, пользователи на разных серверах должны получать личные сообщения без ручной синхронизации и без привязки к одному центральному узлу.
|
|
||||||
|
|
||||||
## Что сделать
|
|
||||||
|
|
||||||
1. Определить протокол server-to-server доставки DM.
|
|
||||||
2. Добавить маршрутизацию получателя по серверу, user id, публичному ключу или PDA.
|
|
||||||
3. Добавить ACK, повторы, дедупликацию и backfill пропущенных сообщений.
|
|
||||||
4. Разделить realtime-доставку и восстановление истории.
|
|
||||||
5. Описать поведение при недоступности удалённого сервера.
|
|
||||||
|
|
||||||
## Что учесть
|
|
||||||
|
|
||||||
- Логика DM должна соответствовать документам в `docs/Personal_Messages/`.
|
|
||||||
- При изменении формата signed DM-блока или правил доставки нужно обновлять протокол и байтовый формат DM.
|
|
||||||
- Если появятся новые server API/WebSocket операции, нужно обновить `docs/API/`.
|
|
||||||
|
|
||||||
## Документы, которые потом нужно обновить
|
|
||||||
|
|
||||||
- `docs/Personal_Messages/Протокол_DM_v1.md`;
|
|
||||||
- `docs/Personal_Messages/Формат_DM_v1.md`;
|
|
||||||
- `docs/API/`;
|
|
||||||
- `docs/API/09_Operations_Index.md`, если добавляются новые `op`.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
Отложено до этапа децентрализации.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Межсерверные звонки
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
В будущем пользователи на разных серверах должны иметь возможность устанавливать звонки так же, как пользователи одного сервера.
|
|
||||||
|
|
||||||
## Что сделать
|
|
||||||
|
|
||||||
1. Определить протокол межсерверной сигнализации звонков.
|
|
||||||
2. Добавить маршрутизацию offer/answer/ICE-кандидатов между серверами.
|
|
||||||
3. Добавить обработку статусов занятости, отказа, таймаута и ошибок маршрута.
|
|
||||||
4. Добавить диагностику доставки сигналов между серверами.
|
|
||||||
5. Проверить совместимость с текущими логами `CallDeliveryReport`.
|
|
||||||
|
|
||||||
## Что учесть
|
|
||||||
|
|
||||||
- Специальная диагностика установки звонков идёт через `CallDeliveryReport`.
|
|
||||||
- На production важно сохранять поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`.
|
|
||||||
- Межсерверные звонки не должны ломать текущий односерверный сценарий.
|
|
||||||
|
|
||||||
## Документы, которые потом нужно обновить
|
|
||||||
|
|
||||||
- `docs/API/`, если добавляются или меняются операции сигнализации;
|
|
||||||
- документы по звонкам/диагностике, если они будут выделены отдельно;
|
|
||||||
- deploy-документы, если появятся новые параметры TURN/server-to-server маршрутизации.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
Отложено до этапа децентрализации.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Односерверный production-режим
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
Перед выкладкой репозитория на GitHub и запуском production нужно явно зафиксировать, что текущая стабильная версия работает как один основной сервер.
|
|
||||||
|
|
||||||
## Что считаем текущей нормой
|
|
||||||
|
|
||||||
- Один production-сервер обслуживает пользователей, сообщения, звонки и серверные данные.
|
|
||||||
- Децентрализованные сценарии не считаются обязательными для первого production-релиза.
|
|
||||||
- Межсерверная доставка сообщений, межсерверные звонки, realtime PDA/Solana sync и запись блокчейнов в Arweave вынесены в отдельные будущие задачи.
|
|
||||||
- Код и документация текущего production не должны создавать ожидание, что несколько серверов уже работают как единая realtime-сеть.
|
|
||||||
|
|
||||||
## Что сделать перед возвратом к децентрализации
|
|
||||||
|
|
||||||
1. Проверить актуальные документы по API, blockchain, DM и deploy.
|
|
||||||
2. Выделить минимальный протокол server-to-server взаимодействия.
|
|
||||||
3. Решить, какие данные остаются локальными, какие реплицируются между серверами, а какие записываются во внешнее долговременное хранилище.
|
|
||||||
4. После изменения API, blockchain-форматов или DM-протокола обновить соответствующие документы по правилам проекта.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
Отложено. Текущий production работает как один сервер.
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Сделать все эти: друг, близкий друг, и статусы типо сияет, точно сияет, реальный человек и т.д.
|
||||||
|
|
||||||
|
и как вариант не реальный человек тк
|
||||||
|
- украли акаунт
|
||||||
|
- сразу был нереальным мошенником
|
||||||
|
- умер
|
||||||
-275
@@ -1,275 +0,0 @@
|
|||||||
# Новая логика контента в блокчейне SHiNE
|
|
||||||
|
|
||||||
## Зачем это нужно
|
|
||||||
|
|
||||||
Сейчас блокчейн SHiNE хорошо умеет хранить обычные сообщения, ответы, лайки и связи между людьми.
|
|
||||||
|
|
||||||
Новая модель добавляет поверх этого более понятный смысл контента:
|
|
||||||
|
|
||||||
- обычный текст;
|
|
||||||
- упражнение;
|
|
||||||
- услуга / процедура;
|
|
||||||
- курс;
|
|
||||||
- стартовая страница канала (`entrypoint`).
|
|
||||||
|
|
||||||
Это нужно для того, чтобы канал стал не просто лентой постов, а полноценным пространством знаний, практик, услуг и сообществ.
|
|
||||||
|
|
||||||
## Что меняется для людей
|
|
||||||
|
|
||||||
### 1. В канале появятся понятные виды материалов
|
|
||||||
|
|
||||||
Сообщение можно будет создать не только как обычный текст, но и как:
|
|
||||||
|
|
||||||
- упражнение;
|
|
||||||
- услугу / процедуру;
|
|
||||||
- курс;
|
|
||||||
- стартовую страницу канала.
|
|
||||||
|
|
||||||
Смысл в том, что приложение и сервер будут понимать, что это за материал, а не просто показывать любой текст одинаково.
|
|
||||||
|
|
||||||
### 2. У канала будет стартовая страница
|
|
||||||
|
|
||||||
У канала появится отдельное стартовое сообщение `entrypoint`.
|
|
||||||
|
|
||||||
Это не курс и не оглавление, а именно главная точка входа в канал:
|
|
||||||
|
|
||||||
- короткое объяснение, о чём канал;
|
|
||||||
- описание структуры;
|
|
||||||
- ссылки на нужные материалы;
|
|
||||||
- удобное начало для новых людей.
|
|
||||||
|
|
||||||
У канала в каждый момент времени будет только одна актуальная стартовая страница.
|
|
||||||
Если её исправляют, то сохраняется история версий.
|
|
||||||
Если её удаляют, для интерфейса считается, что стартовой страницы у канала сейчас нет.
|
|
||||||
|
|
||||||
### 3. Курс, упражнение и услуга / процедура будут отличаться по смыслу
|
|
||||||
|
|
||||||
Это важно для логики и статистики.
|
|
||||||
|
|
||||||
- `Упражнение` — то, что человек может делать много раз.
|
|
||||||
- `Услуга / процедура` — то, что тоже можно проходить много раз, но обычно с участием другого человека.
|
|
||||||
- `Курс` — то, что можно начать, закончить или бросить.
|
|
||||||
|
|
||||||
За счёт этого сервер сможет честно считать активность, а интерфейс сможет показывать человеку именно те действия, которые подходят к данному типу материала.
|
|
||||||
|
|
||||||
### 4. Появятся статусные действия
|
|
||||||
|
|
||||||
На контент можно будет не только ответить или поставить лайк, но и отметить свой путь:
|
|
||||||
|
|
||||||
- сделал один раз;
|
|
||||||
- заинтересовался и рассматривает;
|
|
||||||
- начал;
|
|
||||||
- закончил / освоил / знаю;
|
|
||||||
- бросил.
|
|
||||||
|
|
||||||
При этом:
|
|
||||||
|
|
||||||
- для упражнений и услуг / процедур будет отдельно считаться, сколько раз человек сделал / прошёл;
|
|
||||||
- для упражнений и курсов будет храниться текущий статус.
|
|
||||||
|
|
||||||
Текущий статус определяется просто:
|
|
||||||
|
|
||||||
- последнее статусное действие и считается актуальным.
|
|
||||||
|
|
||||||
Например:
|
|
||||||
|
|
||||||
- если последнее действие “заинтересовался и рассматривает”, значит человек присматривается, но ещё не начал;
|
|
||||||
- если последнее действие `started`, значит материал сейчас в процессе;
|
|
||||||
- если последнее действие `abandoned`, значит человек бросил;
|
|
||||||
- если последнее действие `completed`, значит для системы он завершил / освоил материал.
|
|
||||||
|
|
||||||
### 5. К действиям можно добавлять живой текст
|
|
||||||
|
|
||||||
Практически любое статусное действие можно будет сопровождать коротким комментарием.
|
|
||||||
|
|
||||||
Например:
|
|
||||||
|
|
||||||
- “Начал изучать, потому что давно хотел разобраться”;
|
|
||||||
- “Бросил, пока нет времени”;
|
|
||||||
- “Прошёл процедуру, стало заметно легче”.
|
|
||||||
|
|
||||||
Это важно, потому что сам блокчейн будет хранить не только формальный статус, но и живую человеческую причину или заметку.
|
|
||||||
|
|
||||||
### 6. Появится подтверждение статуса другими людьми
|
|
||||||
|
|
||||||
Отдельный человек сможет подтвердить чей-то статус.
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
- подтвердить, что человек действительно занимался;
|
|
||||||
- подтвердить, что он реально прошёл услугу;
|
|
||||||
- подтвердить, что он освоил материал.
|
|
||||||
|
|
||||||
Подтверждение — это не замена статуса, а отдельное мнение / свидетельство со стороны.
|
|
||||||
|
|
||||||
### 7. Появится отдельный тип «мнение»
|
|
||||||
|
|
||||||
На любое сообщение можно будет ответить не только обычным ответом, но и специальным типом ответа: `мнение`.
|
|
||||||
|
|
||||||
Это по сути тоже текстовый ответ, но с отдельным смыслом:
|
|
||||||
|
|
||||||
- это отзыв;
|
|
||||||
- это оценка;
|
|
||||||
- это мнение о материале;
|
|
||||||
- это явная метка для будущего анализа нейронками.
|
|
||||||
|
|
||||||
То есть:
|
|
||||||
|
|
||||||
- обычный ответ нужен для разговора;
|
|
||||||
- `мнение` нужно для отзыва, оценки и анализа реакции людей.
|
|
||||||
|
|
||||||
## Что остаётся как раньше
|
|
||||||
|
|
||||||
### Комментарии
|
|
||||||
|
|
||||||
Обычные ответы на сообщения остаются.
|
|
||||||
То есть обсуждение материалов не ломается и не меняется концептуально.
|
|
||||||
|
|
||||||
### Лайки контента
|
|
||||||
|
|
||||||
Лайк на сообщение, курс, упражнение или услугу остаётся обычной реакцией на конкретный блок.
|
|
||||||
|
|
||||||
### Лайк пользователю
|
|
||||||
|
|
||||||
Лайк пользователю не будет считаться реакцией на сообщение.
|
|
||||||
Он относится к графу связей между людьми.
|
|
||||||
|
|
||||||
Это удобно, потому что:
|
|
||||||
|
|
||||||
- лайк человека — это отношение к человеку;
|
|
||||||
- лайк материала — это отношение к контенту.
|
|
||||||
|
|
||||||
## Сообщество вокруг канала
|
|
||||||
|
|
||||||
Канал сможет работать не только как лента, но и как сообщество.
|
|
||||||
|
|
||||||
Для этого появятся простые действия:
|
|
||||||
|
|
||||||
- заявка на вступление;
|
|
||||||
- самостоятельный выход;
|
|
||||||
- принятие;
|
|
||||||
- исключение.
|
|
||||||
|
|
||||||
Сервер сможет понимать:
|
|
||||||
|
|
||||||
- кто только подал заявку;
|
|
||||||
- кто уже принят;
|
|
||||||
- кто вышел;
|
|
||||||
- кто был исключён.
|
|
||||||
|
|
||||||
## Личный канал и лента достижений
|
|
||||||
|
|
||||||
У каждого человека по смыслу появляется два важных пространства:
|
|
||||||
|
|
||||||
- канал его обычных постов;
|
|
||||||
- отдельная лента его тренировок и достижений.
|
|
||||||
|
|
||||||
В обычном канале человек сможет:
|
|
||||||
|
|
||||||
- писать посты;
|
|
||||||
- делиться мыслями;
|
|
||||||
- публиковать материалы;
|
|
||||||
- обсуждать темы как раньше.
|
|
||||||
|
|
||||||
А в ленте достижений будут видны его реальные действия:
|
|
||||||
|
|
||||||
- какие упражнения он делал;
|
|
||||||
- какие услуги / процедуры проходил;
|
|
||||||
- какие курсы его заинтересовали;
|
|
||||||
- какие курсы он начал;
|
|
||||||
- какие курсы он закончил;
|
|
||||||
- что он бросил.
|
|
||||||
|
|
||||||
То есть блокчейн SHiNE сможет хранить не только слова человека, но и его путь, активность и историю практики.
|
|
||||||
|
|
||||||
## Что смогут делать авторы контента
|
|
||||||
|
|
||||||
Создатели контента в своих каналах смогут публиковать не только обычные посты, но и:
|
|
||||||
|
|
||||||
- упражнения;
|
|
||||||
- курсы;
|
|
||||||
- стартовую страницу канала;
|
|
||||||
- услуги / процедуры, которые они оказывают.
|
|
||||||
|
|
||||||
Это превращает канал в сочетание:
|
|
||||||
|
|
||||||
- блога;
|
|
||||||
- базы знаний;
|
|
||||||
- пространства обучения;
|
|
||||||
- каталога услуг и практик.
|
|
||||||
|
|
||||||
## Что увидит человек в интерфейсе
|
|
||||||
|
|
||||||
На специальных сообщениях в UI можно будет показывать отдельные кнопки действий.
|
|
||||||
|
|
||||||
Например:
|
|
||||||
|
|
||||||
- `Выполнил упражнение`
|
|
||||||
- `Прошёл процедуру`
|
|
||||||
- `Заинтересовало`
|
|
||||||
- `Начал курс`
|
|
||||||
- `Закончил курс`
|
|
||||||
|
|
||||||
То есть материал можно будет не просто прочитать, а сразу отметить реальное действие.
|
|
||||||
|
|
||||||
Также при ответе на любое сообщение можно будет выбрать:
|
|
||||||
|
|
||||||
- обычный ответ;
|
|
||||||
- `мнение / отзыв`.
|
|
||||||
|
|
||||||
## Как будет работать лента достижений
|
|
||||||
|
|
||||||
Если кто-то зайдёт в твою ленту достижений, он сможет:
|
|
||||||
|
|
||||||
- прочитать, что ты делал;
|
|
||||||
- оставить мнение / отзыв;
|
|
||||||
- подтвердить, что это действительно было.
|
|
||||||
|
|
||||||
Это даёт основу для мягкой “сертификации” внутри SHiNE.
|
|
||||||
|
|
||||||
Например:
|
|
||||||
|
|
||||||
- человек прошёл курс и получил подтверждения;
|
|
||||||
- человек прошёл процедуру и получил отзыв;
|
|
||||||
- человек регулярно делает упражнения, и это видно в его истории.
|
|
||||||
|
|
||||||
Так постепенно у пользователя появляется не только лента постов, но и лента достижений, подтверждений и репутации.
|
|
||||||
|
|
||||||
## Ссылки внутри SHiNE
|
|
||||||
|
|
||||||
Для переходов между материалами вводятся простые внутренние адреса:
|
|
||||||
|
|
||||||
- обычная ссылка: `SHiNE/alice-001/157`
|
|
||||||
- особополная ссылка: `SHiNE/alice-001/157/ХЭШ`
|
|
||||||
|
|
||||||
Первая форма — основная и каноническая.
|
|
||||||
Вторая нужна там, где хочется добавить ещё и точную проверку по хэшу.
|
|
||||||
|
|
||||||
## Что это даёт в итоге
|
|
||||||
|
|
||||||
После внедрения новая блокчейн-логика позволит:
|
|
||||||
|
|
||||||
- строить каналы как структурированные пространства, а не просто как поток постов;
|
|
||||||
- выделять упражнения, услуги и курсы как отдельные сущности;
|
|
||||||
- показывать стартовую страницу канала;
|
|
||||||
- хранить путь человека по материалу;
|
|
||||||
- хранить отдельную ленту его действий и достижений;
|
|
||||||
- считать активность и статусы;
|
|
||||||
- подтверждать результаты другими людьми;
|
|
||||||
- развивать сообщество вокруг канала.
|
|
||||||
|
|
||||||
И самое важное: всё это можно добавить как расширение уже существующего блокчейна SHiNE, не разрушая старую модель сообщений.
|
|
||||||
|
|
||||||
## Отдельный вопрос для будущего
|
|
||||||
|
|
||||||
Отзывы о людях как о людях — полезная идея, но её стоит дополнительно обдумать.
|
|
||||||
|
|
||||||
Например, на вкладке связей в будущем можно:
|
|
||||||
|
|
||||||
- писать человеку отзыв;
|
|
||||||
- смотреть все отзывы о человеке;
|
|
||||||
- выводить сначала отзывы близких друзей, родственников, друзей и контактов, а уже потом остальные.
|
|
||||||
|
|
||||||
Но этот слой нужно делать осторожно, чтобы он не стал слишком жёстким или неприятным для людей.
|
|
||||||
|
|
||||||
Поэтому отзывы о людях как отдельная социальная механика требуют дополнительного обсуждения и проектирования.
|
|
||||||
-670
@@ -1,670 +0,0 @@
|
|||||||
# ТЗ: новая контентная модель блокчейна SHiNE
|
|
||||||
|
|
||||||
## Статус документа
|
|
||||||
|
|
||||||
Этот документ описывает предлагаемые новые типы блоков и правила их обработки.
|
|
||||||
|
|
||||||
Цель:
|
|
||||||
|
|
||||||
- добавить новую семантику контента;
|
|
||||||
- не ломать существующие блоки `type=0..4`;
|
|
||||||
- внедрить всё как расширение блокчейна за счёт новых форматов.
|
|
||||||
|
|
||||||
Документ является проектным ТЗ на реализацию в сервере, БД, API чтения и UI.
|
|
||||||
|
|
||||||
## 1. Базовые принципы
|
|
||||||
|
|
||||||
### 1.1. Совместимость
|
|
||||||
|
|
||||||
Старые типы не меняются:
|
|
||||||
|
|
||||||
- `type=0` — TECH
|
|
||||||
- `type=1` — TEXT
|
|
||||||
- `type=2` — REACTION
|
|
||||||
- `type=3` — CONNECTION
|
|
||||||
- `type=4` — USER_PARAM
|
|
||||||
|
|
||||||
Новые сущности и действия добавляются только как новые `type` и новые `body`.
|
|
||||||
|
|
||||||
Это означает:
|
|
||||||
|
|
||||||
- старые блоки продолжают читаться как раньше;
|
|
||||||
- старые `TEXT_POST`, `TEXT_REPLY`, `REACTION_LIKE` и остальные форматы не ломаются;
|
|
||||||
- существующий блокчейн остаётся валидным;
|
|
||||||
- новый функционал появляется только там, где клиент и сервер умеют его понимать.
|
|
||||||
|
|
||||||
### 1.2. Общая стратегия
|
|
||||||
|
|
||||||
Новая модель делится на четыре слоя:
|
|
||||||
|
|
||||||
1. контентные сущности;
|
|
||||||
2. текстовые отзывы и мнения;
|
|
||||||
3. статусные действия пользователей;
|
|
||||||
4. community-события вокруг канала.
|
|
||||||
|
|
||||||
### 1.3. Редактирование и удаление
|
|
||||||
|
|
||||||
Для новых контентных сущностей сохраняется действующий принцип SHiNE:
|
|
||||||
|
|
||||||
- редактирование всегда ссылается на оригинальный блок;
|
|
||||||
- тип сущности edit не меняет;
|
|
||||||
- удаление выполняется через `edit` с пустым текстом;
|
|
||||||
- отдельный `DELETE`-подтип не вводится.
|
|
||||||
|
|
||||||
Это правило особенно важно для:
|
|
||||||
|
|
||||||
- `plain_text`
|
|
||||||
- `exercise`
|
|
||||||
- `service`
|
|
||||||
- `course`
|
|
||||||
- `entrypoint`
|
|
||||||
|
|
||||||
В пользовательских текстах и UI желательно использовать русские названия:
|
|
||||||
|
|
||||||
- обычный текст;
|
|
||||||
- упражнение;
|
|
||||||
- услуга / процедура;
|
|
||||||
- курс;
|
|
||||||
- стартовое сообщение канала.
|
|
||||||
|
|
||||||
## 2. Канонические внутренние ссылки
|
|
||||||
|
|
||||||
В новой модели поддерживаются только две формы внутренней ссылки:
|
|
||||||
|
|
||||||
- каноническая: `SHiNE/<blockchainName>/<blockNumber>`
|
|
||||||
- особополная: `SHiNE/<blockchainName>/<blockNumber>/<blockHash>`
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
- `SHiNE/alice-001/157`
|
|
||||||
- `SHiNE/alice-001/157/abcd1234...`
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
|
|
||||||
- канонической считается именно короткая форма без хэша;
|
|
||||||
- форма с хэшем используется как усиленный вариант для точной проверки;
|
|
||||||
- внутри UI и серверной логики ссылка должна приводиться как минимум к паре:
|
|
||||||
- `blockchainName`
|
|
||||||
- `blockNumber`
|
|
||||||
- если хэш присутствует, он участвует в дополнительной валидации ссылки.
|
|
||||||
|
|
||||||
## 3. Новые контентные сущности
|
|
||||||
|
|
||||||
## 3.1. Новый `type=5` — `CONTENT`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- хранение новых смысловых материалов канала;
|
|
||||||
- сохранение линии канала;
|
|
||||||
- поддержка edit-версий и логического удаления.
|
|
||||||
|
|
||||||
### 3.1.1. Подтипы `CONTENT`
|
|
||||||
|
|
||||||
- `subType=10` — `CONTENT_PLAIN`
|
|
||||||
- `subType=11` — `CONTENT_EDIT_PLAIN`
|
|
||||||
- `subType=20` — `CONTENT_EXERCISE`
|
|
||||||
- `subType=21` — `CONTENT_EDIT_EXERCISE`
|
|
||||||
- `subType=30` — `CONTENT_SERVICE`
|
|
||||||
- `subType=31` — `CONTENT_EDIT_SERVICE`
|
|
||||||
- `subType=40` — `CONTENT_COURSE`
|
|
||||||
- `subType=41` — `CONTENT_EDIT_COURSE`
|
|
||||||
- `subType=50` — `CONTENT_ENTRYPOINT`
|
|
||||||
- `subType=51` — `CONTENT_EDIT_ENTRYPOINT`
|
|
||||||
|
|
||||||
### 3.1.2. Семантика подтипов
|
|
||||||
|
|
||||||
- `CONTENT_PLAIN` — обычный текст нового поколения.
|
|
||||||
- `CONTENT_EXERCISE` — упражнение, которое можно выполнять многократно.
|
|
||||||
- `CONTENT_SERVICE` — услуга / процедура, которую можно проходить многократно.
|
|
||||||
- `CONTENT_COURSE` — курс / оглавление.
|
|
||||||
- `CONTENT_ENTRYPOINT` — стартовое сообщение канала.
|
|
||||||
|
|
||||||
### 3.1.3. Почему `entrypoint` отдельный тип
|
|
||||||
|
|
||||||
`entrypoint` не считается курсом.
|
|
||||||
|
|
||||||
Это отдельная сущность, потому что:
|
|
||||||
|
|
||||||
- она описывает вход в канал;
|
|
||||||
- по ней нельзя делать `started / completed / abandoned`;
|
|
||||||
- у канала в каждый момент времени должна быть только одна актуальная стартовая страница.
|
|
||||||
|
|
||||||
### 3.1.4. Ограничение на `entrypoint`
|
|
||||||
|
|
||||||
Для одного канала допускается только один исходный блок `CONTENT_ENTRYPOINT`.
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
|
|
||||||
- если entrypoint уже существует, создать второй нельзя;
|
|
||||||
- изменять можно только через `CONTENT_EDIT_ENTRYPOINT`;
|
|
||||||
- если entrypoint логически удалён, UI должен считать, что стартовой страницы больше нет;
|
|
||||||
- исторический блок при этом остаётся в цепочке.
|
|
||||||
|
|
||||||
### 3.1.5. Формат body для `CONTENT_*`
|
|
||||||
|
|
||||||
Для `version=1` рекомендуется использовать формат, максимально совместимый по логике с текущими `TEXT_POST` / `TEXT_EDIT_POST`.
|
|
||||||
|
|
||||||
#### Создающие блоки
|
|
||||||
|
|
||||||
Для:
|
|
||||||
|
|
||||||
- `CONTENT_PLAIN`
|
|
||||||
- `CONTENT_EXERCISE`
|
|
||||||
- `CONTENT_SERVICE`
|
|
||||||
- `CONTENT_COURSE`
|
|
||||||
- `CONTENT_ENTRYPOINT`
|
|
||||||
|
|
||||||
body:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ContentLineBody_v1
|
|
||||||
- lineCode: int32
|
|
||||||
- prevLineNumber: int32
|
|
||||||
- prevLineHash32: [32]
|
|
||||||
- thisLineNumber: int32
|
|
||||||
- textLenBytes: uint16
|
|
||||||
- text UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Edit-блоки
|
|
||||||
|
|
||||||
Для:
|
|
||||||
|
|
||||||
- `CONTENT_EDIT_PLAIN`
|
|
||||||
- `CONTENT_EDIT_EXERCISE`
|
|
||||||
- `CONTENT_EDIT_SERVICE`
|
|
||||||
- `CONTENT_EDIT_COURSE`
|
|
||||||
- `CONTENT_EDIT_ENTRYPOINT`
|
|
||||||
|
|
||||||
body:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ContentEditBody_v1
|
|
||||||
- lineCode: int32
|
|
||||||
- prevLineNumber: int32
|
|
||||||
- prevLineHash32: [32]
|
|
||||||
- thisLineNumber: int32
|
|
||||||
- toBlockGlobalNumber: int32
|
|
||||||
- toBlockHash32: [32]
|
|
||||||
- textLenBytes: uint16
|
|
||||||
- text UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
|
|
||||||
- edit всегда ссылается на оригинальный блок соответствующего типа;
|
|
||||||
- `toBlockchainName` в edit не хранится;
|
|
||||||
- `textLen=0` означает логическое удаление содержимого;
|
|
||||||
- тип исходной сущности edit не меняет.
|
|
||||||
|
|
||||||
### 3.1.6. Что считается комментарием
|
|
||||||
|
|
||||||
Комментарии не требуют нового формата.
|
|
||||||
|
|
||||||
Для обсуждения новых контентных сущностей продолжают использоваться уже существующие:
|
|
||||||
|
|
||||||
- `TEXT_REPLY`
|
|
||||||
- `TEXT_EDIT_REPLY`
|
|
||||||
|
|
||||||
Это позволяет не ломать старую reply-механику и reuse текущую модель тредов.
|
|
||||||
|
|
||||||
## 4. Текстовые отзывы
|
|
||||||
|
|
||||||
## 4.1. Новый `type=6` — `TEXT_RATING`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- текстовая оценка / отзыв на объект;
|
|
||||||
- без числовой шкалы;
|
|
||||||
- с возможностью редактирования и логического удаления.
|
|
||||||
|
|
||||||
Смысл `TEXT_RATING`:
|
|
||||||
|
|
||||||
- это текст;
|
|
||||||
- это специальный отзыв / мнение / оценка;
|
|
||||||
- это явный сигнал, что перед нами не просто комментарий, а осмысленный отзыв;
|
|
||||||
- в будущем это поле можно отдельно анализировать нейронками.
|
|
||||||
|
|
||||||
### 4.1.1. Подтипы
|
|
||||||
|
|
||||||
- `subType=10` — `TEXT_RATING_POST`
|
|
||||||
- `subType=11` — `TEXT_RATING_EDIT`
|
|
||||||
|
|
||||||
### 4.1.2. Где разрешён `TEXT_RATING_POST`
|
|
||||||
|
|
||||||
Разрешён на target:
|
|
||||||
|
|
||||||
- `HEADER` пользователя;
|
|
||||||
- контентный блок `type=5`;
|
|
||||||
- при необходимости в будущем — на другие target-блоки по отдельному решению.
|
|
||||||
|
|
||||||
Сейчас в данном ТЗ:
|
|
||||||
|
|
||||||
- отзыв / оценка на пользователя — да;
|
|
||||||
- отзыв / оценка на контент — да;
|
|
||||||
- отзыв / лайк на канал целиком — не вводится, только оставляется как будущая возможность.
|
|
||||||
|
|
||||||
### 4.1.3. Где и как используется `TEXT_RATING_POST`
|
|
||||||
|
|
||||||
`TEXT_RATING_POST` можно создавать:
|
|
||||||
|
|
||||||
- как отзыв на контентный блок;
|
|
||||||
- как отзыв на пользователя через target на `HEADER`;
|
|
||||||
- как специальный ответ вместо обычного комментария.
|
|
||||||
|
|
||||||
Практическое правило для UI:
|
|
||||||
|
|
||||||
- при ответе на любое сообщение пользователь может выбрать:
|
|
||||||
- обычный ответ;
|
|
||||||
- `мнение / отзыв`.
|
|
||||||
|
|
||||||
### 4.1.4. Формат body
|
|
||||||
|
|
||||||
#### Создание
|
|
||||||
|
|
||||||
```text
|
|
||||||
TextRatingBody_v1
|
|
||||||
- toBlockchainNameLen: uint8
|
|
||||||
- toBlockchainName UTF-8
|
|
||||||
- toBlockGlobalNumber: int32
|
|
||||||
- toBlockHash32: [32]
|
|
||||||
- textLenBytes: uint16
|
|
||||||
- text UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Редактирование
|
|
||||||
|
|
||||||
```text
|
|
||||||
TextRatingEditBody_v1
|
|
||||||
- toBlockGlobalNumber: int32
|
|
||||||
- toBlockHash32: [32]
|
|
||||||
- textLenBytes: uint16
|
|
||||||
- text UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
|
|
||||||
- edit ссылается на оригинальный `TEXT_RATING_POST`;
|
|
||||||
- пустой текст в edit означает логическое удаление отзыва.
|
|
||||||
|
|
||||||
## 5. Статусные действия и накопительные события
|
|
||||||
|
|
||||||
## 5.1. Новый `type=7` — `STATUS_ACTION`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- хранение действий пользователя по отношению к контенту;
|
|
||||||
- вычисление текущего статуса;
|
|
||||||
- накопительный учёт повторных прохождений;
|
|
||||||
- подтверждение статусов другими людьми.
|
|
||||||
|
|
||||||
### 5.1.1. Подтипы
|
|
||||||
|
|
||||||
- `subType=10` — `STATUS_DONE_ONCE`
|
|
||||||
- `subType=20` — `STATUS_INTERESTED`
|
|
||||||
- `subType=30` — `STATUS_STARTED`
|
|
||||||
- `subType=40` — `STATUS_COMPLETED`
|
|
||||||
- `subType=50` — `STATUS_ABANDONED`
|
|
||||||
- `subType=60` — `STATUS_CONFIRMED`
|
|
||||||
|
|
||||||
### 5.1.2. Матрица допустимости по контенту
|
|
||||||
|
|
||||||
`STATUS_DONE_ONCE` разрешён только для:
|
|
||||||
|
|
||||||
- `CONTENT_EXERCISE`
|
|
||||||
- `CONTENT_SERVICE`
|
|
||||||
|
|
||||||
`STATUS_INTERESTED`, `STATUS_STARTED`, `STATUS_COMPLETED`, `STATUS_ABANDONED` разрешены только для:
|
|
||||||
|
|
||||||
- `CONTENT_EXERCISE`
|
|
||||||
- `CONTENT_COURSE`
|
|
||||||
|
|
||||||
`CONTENT_ENTRYPOINT` не поддерживает:
|
|
||||||
|
|
||||||
- `interested`
|
|
||||||
- `started`
|
|
||||||
- `completed`
|
|
||||||
- `abandoned`
|
|
||||||
|
|
||||||
### 5.1.3. Как считать текущее состояние
|
|
||||||
|
|
||||||
Для пары:
|
|
||||||
|
|
||||||
- `actorLogin`
|
|
||||||
- `targetBlock`
|
|
||||||
|
|
||||||
актуальным статусом считается последнее по времени статусное событие из набора:
|
|
||||||
|
|
||||||
- `STATUS_INTERESTED`
|
|
||||||
- `STATUS_STARTED`
|
|
||||||
- `STATUS_COMPLETED`
|
|
||||||
- `STATUS_ABANDONED`
|
|
||||||
|
|
||||||
Следствия:
|
|
||||||
|
|
||||||
- у одного пользователя по одному объекту в каждый момент времени только один актуальный статус;
|
|
||||||
- если последним пришёл `interested`, статус считается “заинтересовался / рассматривает, но ещё не начал”;
|
|
||||||
- если последним пришёл `started`, статус считается “в процессе”;
|
|
||||||
- если последним пришёл `completed`, статус считается “завершён / освоен / знаю”;
|
|
||||||
- если последним пришёл `abandoned`, статус считается “брошен”.
|
|
||||||
|
|
||||||
### 5.1.4. Как считать количество прохождений
|
|
||||||
|
|
||||||
`STATUS_DONE_ONCE` не меняет текущий статус.
|
|
||||||
|
|
||||||
Он считается отдельно как накопительное событие.
|
|
||||||
|
|
||||||
Сервер должен уметь считать:
|
|
||||||
|
|
||||||
- сколько раз пользователь сделал упражнение;
|
|
||||||
- сколько раз пользователь прошёл услугу / процедуру.
|
|
||||||
|
|
||||||
### 5.1.5. Дополнительный текст действия
|
|
||||||
|
|
||||||
Каждое действие `STATUS_*` может содержать дополнительный текст-комментарий.
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
- как именно делал упражнение;
|
|
||||||
- чем заинтересовал курс;
|
|
||||||
- с какими мыслями начал курс;
|
|
||||||
- почему бросил;
|
|
||||||
- что именно подтверждает подтверждающий человек.
|
|
||||||
|
|
||||||
### 5.1.6. Подтверждение статуса
|
|
||||||
|
|
||||||
`STATUS_CONFIRMED` разрешён только на target-статусы:
|
|
||||||
|
|
||||||
- `STATUS_DONE_ONCE`
|
|
||||||
- `STATUS_INTERESTED`
|
|
||||||
- `STATUS_STARTED`
|
|
||||||
- `STATUS_COMPLETED`
|
|
||||||
- `STATUS_ABANDONED`
|
|
||||||
|
|
||||||
Это значит:
|
|
||||||
|
|
||||||
- подтверждение не ставится прямо на курс или упражнение;
|
|
||||||
- подтверждение ставится на конкретный статусный блок другого человека.
|
|
||||||
|
|
||||||
Подтверждение:
|
|
||||||
|
|
||||||
- не меняет основной статус автора;
|
|
||||||
- не меняет счётчик `done_once`;
|
|
||||||
- хранится как отдельное мнение / свидетельство.
|
|
||||||
|
|
||||||
### 5.1.7. Формат body
|
|
||||||
|
|
||||||
Для `STATUS_DONE_ONCE`, `STATUS_INTERESTED`, `STATUS_STARTED`, `STATUS_COMPLETED`, `STATUS_ABANDONED`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
StatusActionBody_v1
|
|
||||||
- toBlockchainNameLen: uint8
|
|
||||||
- toBlockchainName UTF-8
|
|
||||||
- toBlockGlobalNumber: int32
|
|
||||||
- toBlockHash32: [32]
|
|
||||||
- noteLenBytes: uint16
|
|
||||||
- note UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
Для `STATUS_CONFIRMED`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
StatusConfirmBody_v1
|
|
||||||
- toBlockchainNameLen: uint8
|
|
||||||
- toBlockchainName UTF-8
|
|
||||||
- toBlockGlobalNumber: int32
|
|
||||||
- toBlockHash32: [32]
|
|
||||||
- noteLenBytes: uint16
|
|
||||||
- note UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
На уровне бинарного формата тело можно оставить одинаковым.
|
|
||||||
Различие задаётся `subType` и правилами валидации target.
|
|
||||||
|
|
||||||
## 6. Community-события
|
|
||||||
|
|
||||||
## 6.1. Новый `type=8` — `COMMUNITY_EVENT`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- заявки в сообщество;
|
|
||||||
- выход из сообщества;
|
|
||||||
- принятие;
|
|
||||||
- исключение.
|
|
||||||
|
|
||||||
### 6.1.1. Подтипы
|
|
||||||
|
|
||||||
- `subType=10` — `COMMUNITY_JOIN_REQUEST`
|
|
||||||
- `subType=20` — `COMMUNITY_LEAVE`
|
|
||||||
- `subType=30` — `COMMUNITY_ACCEPT`
|
|
||||||
- `subType=40` — `COMMUNITY_REMOVE`
|
|
||||||
|
|
||||||
### 6.1.2. Базовая логика
|
|
||||||
|
|
||||||
`COMMUNITY_JOIN_REQUEST`
|
|
||||||
|
|
||||||
- создаёт пользователь;
|
|
||||||
- target — `CONTENT_ENTRYPOINT` канала;
|
|
||||||
- может содержать текст заявки.
|
|
||||||
|
|
||||||
`COMMUNITY_LEAVE`
|
|
||||||
|
|
||||||
- создаёт сам участник;
|
|
||||||
- target — `CONTENT_ENTRYPOINT` канала;
|
|
||||||
- подтверждение не требуется;
|
|
||||||
- может содержать текст.
|
|
||||||
|
|
||||||
`COMMUNITY_ACCEPT`
|
|
||||||
|
|
||||||
- создаёт владелец канала;
|
|
||||||
- target — конкретный блок `COMMUNITY_JOIN_REQUEST`;
|
|
||||||
- может содержать текст.
|
|
||||||
|
|
||||||
`COMMUNITY_REMOVE`
|
|
||||||
|
|
||||||
- создаёт владелец канала;
|
|
||||||
- target — `CONTENT_ENTRYPOINT` канала;
|
|
||||||
- body дополнительно хранит `subjectLogin`, кого исключили;
|
|
||||||
- может содержать текст.
|
|
||||||
|
|
||||||
### 6.1.3. Текущее членство
|
|
||||||
|
|
||||||
Пользователь считается текущим участником сообщества, если:
|
|
||||||
|
|
||||||
- у него есть хотя бы одно принятие в это сообщество;
|
|
||||||
- после этого принятия нет более позднего:
|
|
||||||
- `COMMUNITY_LEAVE`
|
|
||||||
- `COMMUNITY_REMOVE`
|
|
||||||
|
|
||||||
Заявка сама по себе членство не создаёт.
|
|
||||||
|
|
||||||
### 6.1.4. Формат body
|
|
||||||
|
|
||||||
Для `JOIN_REQUEST` и `LEAVE`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
CommunityActionBody_v1
|
|
||||||
- toBlockchainNameLen: uint8
|
|
||||||
- toBlockchainName UTF-8
|
|
||||||
- toBlockGlobalNumber: int32
|
|
||||||
- toBlockHash32: [32]
|
|
||||||
- noteLenBytes: uint16
|
|
||||||
- note UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
Для `ACCEPT`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
CommunityAcceptBody_v1
|
|
||||||
- toBlockchainNameLen: uint8
|
|
||||||
- toBlockchainName UTF-8
|
|
||||||
- toBlockGlobalNumber: int32
|
|
||||||
- toBlockHash32: [32]
|
|
||||||
- noteLenBytes: uint16
|
|
||||||
- note UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
Для `REMOVE`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
CommunityRemoveBody_v1
|
|
||||||
- toBlockchainNameLen: uint8
|
|
||||||
- toBlockchainName UTF-8
|
|
||||||
- toBlockGlobalNumber: int32
|
|
||||||
- toBlockHash32: [32]
|
|
||||||
- subjectLoginLen: uint8
|
|
||||||
- subjectLogin ASCII
|
|
||||||
- noteLenBytes: uint16
|
|
||||||
- note UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Что остаётся на старых типах
|
|
||||||
|
|
||||||
### 7.0. Обычный канал и лента достижений
|
|
||||||
|
|
||||||
На уровне продукта рекомендуется различать:
|
|
||||||
|
|
||||||
- обычный канал постов пользователя;
|
|
||||||
- отдельную ленту его действий и достижений.
|
|
||||||
|
|
||||||
В обычном канале пользователь:
|
|
||||||
|
|
||||||
- пишет посты;
|
|
||||||
- публикует материалы;
|
|
||||||
- общается и обсуждает.
|
|
||||||
|
|
||||||
В ленте достижений видны события:
|
|
||||||
|
|
||||||
- какие упражнения он делал;
|
|
||||||
- какие услуги / процедуры проходил;
|
|
||||||
- какие курсы его заинтересовали;
|
|
||||||
- какие курсы он начал;
|
|
||||||
- какие курсы он завершил;
|
|
||||||
- что он бросил.
|
|
||||||
|
|
||||||
В данном ТЗ эта модель фиксируется как продуктовая логика.
|
|
||||||
Конкретный способ хранения можно реализовать:
|
|
||||||
|
|
||||||
- либо отдельным специальным каналом;
|
|
||||||
- либо отдельным режимом чтения по статусным блокам.
|
|
||||||
|
|
||||||
### 7.1. Лайк пользователю
|
|
||||||
|
|
||||||
Лайк пользователю не вводится как `REACTION`.
|
|
||||||
|
|
||||||
Он остаётся в слое социальных связей:
|
|
||||||
|
|
||||||
- через `CONNECTION`
|
|
||||||
- как будущий отдельный подтип связи
|
|
||||||
|
|
||||||
В этом ТЗ сам новый подтип связи не описывается детально.
|
|
||||||
Нужно только зафиксировать правило:
|
|
||||||
|
|
||||||
- лайк человека относится к графу связей, а не к реакции на блок.
|
|
||||||
|
|
||||||
### 7.2. Лайк контента
|
|
||||||
|
|
||||||
Лайк на:
|
|
||||||
|
|
||||||
- `CONTENT_PLAIN`
|
|
||||||
- `CONTENT_EXERCISE`
|
|
||||||
- `CONTENT_SERVICE`
|
|
||||||
- `CONTENT_COURSE`
|
|
||||||
- `CONTENT_ENTRYPOINT`
|
|
||||||
|
|
||||||
может использовать уже существующий:
|
|
||||||
|
|
||||||
- `REACTION_LIKE`
|
|
||||||
- `REACTION_UNLIKE`
|
|
||||||
|
|
||||||
Отдельный новый формат для лайка контента не нужен.
|
|
||||||
|
|
||||||
### 7.3. Канал целиком
|
|
||||||
|
|
||||||
В текущем ТЗ не вводятся:
|
|
||||||
|
|
||||||
- отзыв на канал целиком;
|
|
||||||
- лайк канала целиком.
|
|
||||||
|
|
||||||
Это оставляется как будущая возможность.
|
|
||||||
|
|
||||||
### 7.4. Отзывы о людях
|
|
||||||
|
|
||||||
Отзывы о человеке как о человеке в текущем ТЗ допустимы через `TEXT_RATING` на `HEADER`.
|
|
||||||
|
|
||||||
Но продуктовую модель их показа нужно отдельно продумать.
|
|
||||||
|
|
||||||
Направление для будущего:
|
|
||||||
|
|
||||||
- просмотр отзывов о человеке на вкладке связей;
|
|
||||||
- приоритетный вывод отзывов от близких друзей, родственников, друзей и контактов;
|
|
||||||
- затем вывод остальных отзывов.
|
|
||||||
|
|
||||||
Эта тема полезна, но требует дополнительной осторожной проработки с точки зрения UX и социальных рисков.
|
|
||||||
|
|
||||||
## 8. Требования к серверу
|
|
||||||
|
|
||||||
Сервер после внедрения должен уметь:
|
|
||||||
|
|
||||||
1. Валидировать новые `type=5..8`.
|
|
||||||
2. Хранить новые блоки без ломки старого чтения.
|
|
||||||
3. Определять текущий статус пользователя по объекту:
|
|
||||||
- `interested`
|
|
||||||
- `started`
|
|
||||||
- `completed`
|
|
||||||
- `abandoned`
|
|
||||||
4. Считать накопительные события `done_once` для:
|
|
||||||
- `exercise`
|
|
||||||
- `service`
|
|
||||||
5. Считать подтверждения статусов.
|
|
||||||
6. Определять единственный актуальный `entrypoint` канала.
|
|
||||||
7. Определять текущее членство в сообществе канала.
|
|
||||||
8. Поддерживать внутренние ссылки вида:
|
|
||||||
- `SHiNE/<blockchainName>/<blockNumber>`
|
|
||||||
- `SHiNE/<blockchainName>/<blockNumber>/<blockHash>`
|
|
||||||
|
|
||||||
## 9. Требования к UI
|
|
||||||
|
|
||||||
UI после внедрения должен уметь:
|
|
||||||
|
|
||||||
1. Показывать разные карточки для:
|
|
||||||
- текста
|
|
||||||
- упражнения
|
|
||||||
- услуги
|
|
||||||
- курса
|
|
||||||
- entrypoint
|
|
||||||
2. Показывать стартовую страницу канала, если `entrypoint` существует.
|
|
||||||
3. Не показывать entrypoint, если он логически удалён.
|
|
||||||
4. Давать человеку только допустимые действия по типу материала.
|
|
||||||
5. Показывать:
|
|
||||||
- текущий статус;
|
|
||||||
- количество `done_once`;
|
|
||||||
- подтверждения статуса.
|
|
||||||
6. Показывать отдельные действия-кнопки на специальных блоках, например:
|
|
||||||
- `Выполнил упражнение`
|
|
||||||
- `Прошёл процедуру`
|
|
||||||
- `Заинтересовало`
|
|
||||||
- `Начал курс`
|
|
||||||
- `Закончил курс`
|
|
||||||
7. При ответе на сообщение давать выбор:
|
|
||||||
- обычный ответ;
|
|
||||||
- `мнение / отзыв`.
|
|
||||||
8. Открывать внутренние ссылки SHiNE.
|
|
||||||
|
|
||||||
## 10. Вывод по совместимости
|
|
||||||
|
|
||||||
Предлагаемая модель реализуема без слома старого блокчейна.
|
|
||||||
|
|
||||||
Причина:
|
|
||||||
|
|
||||||
- старые `type=0..4` не меняются;
|
|
||||||
- новые сущности вводятся только как новые `type=5..8`;
|
|
||||||
- существующие `reply`, `like`, `edit`, `HEADER`, `CREATE_CHANNEL` и `CONNECTION` продолжают работать как раньше;
|
|
||||||
- старые клиенты смогут игнорировать новые типы как неизвестные;
|
|
||||||
- новые клиенты смогут постепенно включать поддержку нового функционала.
|
|
||||||
|
|
||||||
Итог:
|
|
||||||
|
|
||||||
- это расширение формата блокчейна;
|
|
||||||
- это не миграция со сломом старых блоков;
|
|
||||||
- это можно внедрять поэтапно.
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Доделть мелочи
|
||||||
|
и разместить проект нормально на гитхаб
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Межсерверные звонки
|
||||||
|
|
||||||
|
Доделать Звонки что бы работало как сигнал о том что вызов идёт.
|
||||||
|
|
||||||
|
и
|
||||||
|
Пользователи на разных серверах должны иметь возможность устанавливать звонки так же, как пользователи одного сервера.
|
||||||
|
|
||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.355
|
client.version=1.7.0
|
||||||
server.version=1.2.339
|
server.version=1.5.0
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
backup.schema.version=1
|
backup.schema.version=2
|
||||||
backup.full.version=2
|
backup.full.version=3
|
||||||
last.full.backup.date=2026-07-10
|
last.full.backup.date=2026-08-08
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
cld9-012186
|
p702072.kvmvps
|
||||||
---
|
---
|
||||||
Linux cld9-012186 6.8.0-124-generic #124-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 13:00:45 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
|
Linux p702072.kvmvps 5.15.0-187-generic #197-Ubuntu SMP Fri Jul 17 19:17:01 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
|
||||||
---
|
---
|
||||||
Filesystem Size Used Avail Use% Mounted on
|
Filesystem Size Used Avail Use% Mounted on
|
||||||
tmpfs 392M 1.2M 391M 1% /run
|
tmpfs 197M 1.2M 196M 1% /run
|
||||||
/dev/vda2 32G 14G 17G 46% /
|
/dev/sda1 40G 6.7G 31G 18% /
|
||||||
tmpfs 2.0G 0 2.0G 0% /dev/shm
|
tmpfs 982M 0 982M 0% /dev/shm
|
||||||
tmpfs 5.0M 0 5.0M 0% /run/lock
|
tmpfs 5.0M 0 5.0M 0% /run/lock
|
||||||
tmpfs 392M 12K 392M 1% /run/user/1002
|
tmpfs 197M 0 197M 0% /run/user/1000
|
||||||
---
|
---
|
||||||
NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS
|
NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS
|
||||||
sr0 iso9660 Joliet Extension cidata 2026-06-02-12-22-36-00
|
sda
|
||||||
sr1
|
└─sda1 ext4 1.0 42e8f44f-2dd9-4fc6-8485-6581d6528df5 30.6G 17% /
|
||||||
vda
|
sr0
|
||||||
├─vda1
|
|
||||||
└─vda2 ext4 1.0 c422dce2-e6a3-4ce4-a9c1-17ab14e9193a 16.1G 44% /
|
|
||||||
|
|||||||
@@ -1,13 +1,48 @@
|
|||||||
UNIT FILE STATE PRESET
|
UNIT FILE STATE VENDOR PRESET
|
||||||
agent-memory.service enabled enabled
|
apparmor.service enabled enabled
|
||||||
caddy.service enabled enabled
|
blk-availability.service enabled enabled
|
||||||
containerd.service enabled enabled
|
caddy.service enabled enabled
|
||||||
coturn.service enabled enabled
|
console-setup.service enabled enabled
|
||||||
docker.service enabled enabled
|
containerd.service enabled enabled
|
||||||
elaira-agent.service enabled enabled
|
coturn.service enabled enabled
|
||||||
hermes-dashboard.service enabled enabled
|
cron.service enabled enabled
|
||||||
hermes-gateway.service enabled enabled
|
dmesg.service enabled enabled
|
||||||
shine-server.service enabled enabled
|
docker.service enabled enabled
|
||||||
ubuntu-fan.service enabled enabled
|
e2scrub_reap.service enabled enabled
|
||||||
|
finalrd.service enabled enabled
|
||||||
|
getty@.service enabled enabled
|
||||||
|
gpu-manager.service enabled enabled
|
||||||
|
grub-common.service enabled enabled
|
||||||
|
grub-initrd-fallback.service enabled enabled
|
||||||
|
guestfs-firstboot.service enabled enabled
|
||||||
|
irqbalance.service enabled enabled
|
||||||
|
keyboard-setup.service enabled enabled
|
||||||
|
lvm2-monitor.service enabled enabled
|
||||||
|
lxd-agent.service enabled enabled
|
||||||
|
ModemManager.service enabled enabled
|
||||||
|
multipathd.service enabled enabled
|
||||||
|
networkd-dispatcher.service enabled enabled
|
||||||
|
open-iscsi.service enabled enabled
|
||||||
|
open-vm-tools.service enabled enabled
|
||||||
|
pollinate.service enabled enabled
|
||||||
|
rsyslog.service enabled enabled
|
||||||
|
secureboot-db.service enabled enabled
|
||||||
|
setvtrgb.service enabled enabled
|
||||||
|
shine-server.service enabled enabled
|
||||||
|
snap.lxd.activate.service enabled enabled
|
||||||
|
ssh.service enabled enabled
|
||||||
|
systemd-networkd-wait-online.service enabled disabled
|
||||||
|
systemd-networkd.service enabled enabled
|
||||||
|
systemd-pstore.service enabled enabled
|
||||||
|
systemd-resolved.service enabled enabled
|
||||||
|
systemd-timesyncd.service enabled enabled
|
||||||
|
thermald.service enabled enabled
|
||||||
|
ua-reboot-cmds.service enabled enabled
|
||||||
|
ubuntu-advantage.service enabled enabled
|
||||||
|
ubuntu-fan.service enabled enabled
|
||||||
|
udisks2.service enabled enabled
|
||||||
|
ufw.service enabled enabled
|
||||||
|
unattended-upgrades.service enabled enabled
|
||||||
|
vgauth.service enabled enabled
|
||||||
|
|
||||||
10 unit files listed.
|
45 unit files listed.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user