SHA256
Compare commits
71
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
091291bfa2 | ||
|
|
288fde67e8 | ||
|
|
ce30b77328 | ||
|
|
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 |
+11
-1
@@ -36,7 +36,12 @@ public final class BodyRecordParser {
|
||||
case TextBody.KEY -> {
|
||||
if (st == (MsgSubType.TEXT_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);
|
||||
}
|
||||
|
||||
@@ -45,12 +50,17 @@ public final class BodyRecordParser {
|
||||
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);
|
||||
}
|
||||
|
||||
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
|
||||
case ConnectionBody.KEY -> new ConnectionBody(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(
|
||||
"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;
|
||||
|
||||
/** RATING — target-based отзыв на конкретный блок. */
|
||||
public static final short TEXT_RATING = 30;
|
||||
|
||||
/**
|
||||
* REPOST — репост сообщения в линии канала.
|
||||
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
||||
* Имеет 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) ===================== */
|
||||
|
||||
@@ -138,4 +156,16 @@ public final class MsgSubType {
|
||||
|
||||
/** Параметр профиля key/value (обе строки). */
|
||||
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
|
||||
* [N] channelName UTF-8
|
||||
* [2] channelDescriptionLen
|
||||
* [M] channelDescription UTF-8 (0..200 bytes)
|
||||
* [M] channelDescription UTF-8 (0..2048 bytes)
|
||||
* [2] channelTypeCode (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 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 version;
|
||||
@@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
||||
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) {
|
||||
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);
|
||||
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
||||
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);
|
||||
@@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
private static String normalizeDescription(String value) {
|
||||
if (value == null) return "";
|
||||
return value.trim().replaceAll("\\s+", " ");
|
||||
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
}
|
||||
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
||||
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;
|
||||
@@ -204,4 +204,3 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
@Override
|
||||
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:
|
||||
* - POST (10)
|
||||
* - EDIT_POST (11)
|
||||
* - REPOST (30)
|
||||
* - REPOST (50)
|
||||
* - CHANNEL_META (90)
|
||||
* - ENTRYPOINT (100)
|
||||
* - EXERCISE (110)
|
||||
* - SERVICE (120)
|
||||
* - COURSE (130)
|
||||
*
|
||||
* Формат bodyBytes (BigEndian):
|
||||
*
|
||||
* POST:
|
||||
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
@@ -89,8 +94,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
int st = this.subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
|
||||
&& 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("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
|
||||
}
|
||||
|
||||
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.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");
|
||||
}
|
||||
@@ -155,12 +166,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
|
||||
&& 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("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -206,7 +222,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_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);
|
||||
|
||||
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)
|
||||
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
||||
} 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");
|
||||
} else if (message == null) {
|
||||
throw new IllegalArgumentException("Text message is 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;
|
||||
@@ -246,12 +272,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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;
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// EDIT_POST
|
||||
@@ -310,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
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) {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,19 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_1 = 1;
|
||||
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 int SCHEMA_VERSION_5 = 5;
|
||||
public static final int SCHEMA_VERSION_6 = 6;
|
||||
public static final int SCHEMA_VERSION_7 = 7;
|
||||
public static final int SCHEMA_VERSION_8 = 8;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
|
||||
public static final String POSTGRES_MIGRATION_V5_RESOURCE = "postgres/migration_v5.sql";
|
||||
public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.sql";
|
||||
public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql";
|
||||
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -30,7 +40,13 @@ public final class DatabaseInitializer {
|
||||
public static final short TEXT_EDIT_POST = 11;
|
||||
public static final short TEXT_REPLY = 20;
|
||||
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_UNLIKE = 2;
|
||||
@@ -88,6 +104,26 @@ public final class DatabaseInitializer {
|
||||
}
|
||||
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);
|
||||
currentVersion = SCHEMA_VERSION_4;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_5) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V5_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_5;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_6) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V6_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_6;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_7) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V7_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_7;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_8) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,23 @@ public final class MsgSubType {
|
||||
/** EDIT_REPLY — редактирование исходного ответа. */
|
||||
public static final short TEXT_EDIT_REPLY = 21;
|
||||
|
||||
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
|
||||
public static final short TEXT_REPOST = 30;
|
||||
/** RATING — target-based отзыв на конкретный блок. */
|
||||
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) ===================== */
|
||||
|
||||
@@ -120,6 +135,18 @@ public final class MsgSubType {
|
||||
/** Параметр профиля key/value (обе строки). */
|
||||
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 — лучше добавить новые значения,
|
||||
// не трогая уже занятые коды.
|
||||
|
||||
@@ -65,8 +65,12 @@ public final class ChannelNameStateDAO {
|
||||
channel_type_version,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
created_at_ms,
|
||||
ava_ar,
|
||||
ava_sha256,
|
||||
ava_size,
|
||||
meta_updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getSlug());
|
||||
@@ -79,6 +83,34 @@ public final class ChannelNameStateDAO {
|
||||
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||
ps.setBytes(9, entry.getChannelRootBlockHash());
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public final class CurrentUsersDAO {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM %s
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
WHERE normalized_login = LOWER(BTRIM(?))
|
||||
LIMIT 1
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
|
||||
@@ -104,7 +104,7 @@ public final class CurrentUsersDAO {
|
||||
blockchain_key,
|
||||
client_key
|
||||
FROM %s
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
WHERE normalized_login = LOWER(BTRIM(?))
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
@@ -167,7 +167,7 @@ public final class CurrentUsersDAO {
|
||||
blockchain_key,
|
||||
client_key
|
||||
FROM %s
|
||||
WHERE LOWER(login) LIKE ?
|
||||
WHERE normalized_login LIKE LOWER(BTRIM(?))
|
||||
AND (? IS NULL OR is_server = ?)
|
||||
ORDER BY login
|
||||
LIMIT 5
|
||||
@@ -176,7 +176,7 @@ public final class CurrentUsersDAO {
|
||||
List<CurrentUserEntry> result = new ArrayList<>();
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, prefix.toLowerCase() + "%");
|
||||
ps.setString(1, prefix.trim() + "%");
|
||||
if (isServer == null) {
|
||||
ps.setNull(2, Types.BOOLEAN);
|
||||
ps.setNull(3, Types.BOOLEAN);
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.util.List;
|
||||
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
|
||||
* - login цели (channelLogin)
|
||||
* - blockchainName цели (channelBchName)
|
||||
* - count публикаций (TEXT_POST)
|
||||
* - count публикаций (видимые line-based TEXT-сообщения канала)
|
||||
* - last publication: bytes оригинального блока (для timestamp)
|
||||
* - last publication: bytes актуального блока (edit или orig) — для текста превью
|
||||
*
|
||||
@@ -92,7 +92,7 @@ public final class SubscriptionsDAO {
|
||||
|
||||
/**
|
||||
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
|
||||
* - count публикаций (TEXT_POST)
|
||||
* - count публикаций (видимые line-based TEXT-сообщения канала)
|
||||
* - последнюю публикацию (orig bytes) + её edit (если есть)
|
||||
*
|
||||
* Поведение при 0 публикаций:
|
||||
@@ -131,7 +131,7 @@ public final class SubscriptionsDAO {
|
||||
ON s.channel_login = b.login
|
||||
AND s.channel_bch_name = b.bch_name
|
||||
WHERE b.msg_type = ?
|
||||
AND b.msg_sub_type IN (?, ?)
|
||||
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||
GROUP BY b.login, b.bch_name
|
||||
),
|
||||
last_pub AS (
|
||||
@@ -144,7 +144,7 @@ public final class SubscriptionsDAO {
|
||||
ON s.channel_login = b.login
|
||||
AND s.channel_bch_name = b.bch_name
|
||||
WHERE b.msg_type = ?
|
||||
AND b.msg_sub_type IN (?, ?)
|
||||
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||
GROUP BY b.login, b.bch_name
|
||||
),
|
||||
last_pub_block AS (
|
||||
@@ -209,11 +209,19 @@ public final class SubscriptionsDAO {
|
||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||
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
|
||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||
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()) {
|
||||
while (rs.next()) {
|
||||
|
||||
+36
@@ -13,6 +13,10 @@ public class ChannelNameStateEntry {
|
||||
private int channelRootBlockNumber;
|
||||
private byte[] channelRootBlockHash;
|
||||
private long createdAtMs;
|
||||
private String avaAr;
|
||||
private String avaSha256;
|
||||
private long avaSize;
|
||||
private long metaUpdatedAtMs;
|
||||
|
||||
public String getSlug() {
|
||||
return slug;
|
||||
@@ -93,4 +97,36 @@ public class ChannelNameStateEntry {
|
||||
public void setCreatedAtMs(long 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public final class CurrentUsersSql {
|
||||
(
|
||||
SELECT
|
||||
current_users.login AS login,
|
||||
current_users.normalized_login AS normalized_login,
|
||||
current_users.blockchain_name AS blockchain_name,
|
||||
current_users.client_key AS solana_key,
|
||||
current_users.blockchain_key AS blockchain_key,
|
||||
|
||||
@@ -53,12 +53,16 @@ BEGIN
|
||||
s.updated_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
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
|
||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||
AND s.is_server = TRUE
|
||||
@@ -92,12 +96,16 @@ BEGIN
|
||||
FOR affected_user IN
|
||||
SELECT u.login
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
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)
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,83 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS normalized_login TEXT;
|
||||
|
||||
UPDATE solana_user_pda_current
|
||||
SET normalized_login = LOWER(BTRIM(login))
|
||||
WHERE normalized_login IS NULL
|
||||
OR normalized_login <> LOWER(BTRIM(login));
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ALTER COLUMN normalized_login SET NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login
|
||||
ON solana_user_pda_current(normalized_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login
|
||||
ON solana_user_pda_current(normalized_login);
|
||||
|
||||
WITH bad_signed_messages AS (
|
||||
SELECT DISTINCT sm.message_key
|
||||
FROM signed_messages sm
|
||||
LEFT JOIN solana_user_pda_current u_from
|
||||
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
|
||||
LEFT JOIN solana_user_pda_current u_to
|
||||
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
|
||||
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
|
||||
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
|
||||
)
|
||||
DELETE FROM signed_message_session_delivery d
|
||||
USING bad_signed_messages bad
|
||||
WHERE d.message_key = bad.message_key;
|
||||
|
||||
WITH bad_signed_messages AS (
|
||||
SELECT DISTINCT sm.message_key
|
||||
FROM signed_messages sm
|
||||
LEFT JOIN solana_user_pda_current u_from
|
||||
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
|
||||
LEFT JOIN solana_user_pda_current u_to
|
||||
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
|
||||
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
|
||||
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
|
||||
)
|
||||
DELETE FROM signed_messages sm
|
||||
USING bad_signed_messages bad
|
||||
WHERE sm.message_key = bad.message_key;
|
||||
|
||||
DELETE FROM signed_direct_messages_history h
|
||||
USING solana_user_pda_current u_from,
|
||||
solana_user_pda_current u_to
|
||||
WHERE u_from.normalized_login = LOWER(BTRIM(h.from_login))
|
||||
AND u_to.normalized_login = LOWER(BTRIM(h.to_login))
|
||||
AND (h.from_login <> u_from.login OR h.to_login <> u_to.login);
|
||||
|
||||
DELETE FROM signed_direct_message_replay r
|
||||
USING solana_user_pda_current u_from
|
||||
WHERE u_from.normalized_login = LOWER(BTRIM(r.from_login))
|
||||
AND r.from_login <> u_from.login;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'direct_messages'
|
||||
) THEN
|
||||
DELETE FROM direct_messages d
|
||||
USING solana_user_pda_current u_from,
|
||||
solana_user_pda_current u_to
|
||||
WHERE u_from.normalized_login = LOWER(BTRIM(d.from_login))
|
||||
AND u_to.normalized_login = LOWER(BTRIM(d.to_login))
|
||||
AND (d.from_login <> u_from.login OR d.to_login <> u_to.login);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 5, 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,21 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE signed_messages
|
||||
DROP CONSTRAINT IF EXISTS signed_messages_from_login_fkey;
|
||||
|
||||
ALTER TABLE signed_messages
|
||||
DROP CONSTRAINT IF EXISTS signed_messages_to_login_fkey;
|
||||
|
||||
ALTER TABLE signed_direct_messages_history
|
||||
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_from_login_fkey;
|
||||
|
||||
ALTER TABLE signed_direct_messages_history
|
||||
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_to_login_fkey;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 6, 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,16 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE blocks
|
||||
DROP CONSTRAINT IF EXISTS blocks_login_fkey;
|
||||
|
||||
ALTER TABLE blocks
|
||||
ADD CONSTRAINT blocks_login_fkey
|
||||
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 7, 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,16 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE connections_state
|
||||
DROP CONSTRAINT IF EXISTS connections_state_login_fkey;
|
||||
|
||||
ALTER TABLE connections_state
|
||||
ADD CONSTRAINT connections_state_login_fkey
|
||||
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 8, 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)
|
||||
VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
VALUES (1, 8, 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;
|
||||
@@ -74,6 +74,7 @@ CREATE INDEX IF NOT EXISTS idx_sync_tx_history_login
|
||||
CREATE TABLE IF NOT EXISTS solana_user_pda_current (
|
||||
pda_address TEXT PRIMARY KEY,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
normalized_login TEXT NOT NULL,
|
||||
record_number INTEGER NOT NULL,
|
||||
slot BIGINT NOT NULL,
|
||||
last_tx_signature TEXT NOT NULL,
|
||||
@@ -109,6 +110,12 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current (
|
||||
CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot
|
||||
ON solana_user_pda_current(slot);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login
|
||||
ON solana_user_pda_current(normalized_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login
|
||||
ON solana_user_pda_current(normalized_login);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_access_servers_current (
|
||||
user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
@@ -162,12 +169,16 @@ BEGIN
|
||||
s.updated_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
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
|
||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||
AND s.is_server = TRUE
|
||||
@@ -201,12 +212,16 @@ BEGIN
|
||||
FOR affected_user IN
|
||||
SELECT u.login
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
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)
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||
@@ -432,7 +447,7 @@ CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at
|
||||
ON blockchain_state(updated_at_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
bch_name TEXT NOT NULL REFERENCES blockchain_state(blockchain_name),
|
||||
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||
msg_type INTEGER NOT NULL,
|
||||
@@ -462,7 +477,7 @@ CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
||||
ON blocks (bch_name, line_code, this_line_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections_state (
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
rel_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
@@ -536,7 +551,11 @@ CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||
channel_type_version INTEGER NOT NULL DEFAULT 1,
|
||||
channel_root_block_number INTEGER 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
|
||||
@@ -612,8 +631,8 @@ CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signed_direct_messages_history (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
target_mode INTEGER NOT NULL,
|
||||
target_session_id TEXT,
|
||||
message_type INTEGER NOT NULL,
|
||||
@@ -630,8 +649,8 @@ CREATE TABLE IF NOT EXISTS signed_messages (
|
||||
message_key TEXT PRIMARY KEY,
|
||||
base_key TEXT NOT NULL,
|
||||
target_login TEXT NOT NULL,
|
||||
from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
time_ms BIGINT NOT NULL,
|
||||
nonce BIGINT NOT NULL,
|
||||
message_type INTEGER NOT NULL,
|
||||
|
||||
+4
@@ -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.Net_GetChannelMessages_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_GetChannelsCounters_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_GetGroupDialog_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_ListSubscriptionsFeed_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
@@ -184,6 +186,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
|
||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
||||
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
||||
@@ -265,6 +268,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
|
||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_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("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
||||
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
||||
|
||||
+9
-1
@@ -27,6 +27,7 @@ public final class SolanaUserPdaImportService {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||
private static final String MAGIC = "SHiNE";
|
||||
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 2;
|
||||
|
||||
private SolanaUserPdaImportService() {}
|
||||
|
||||
@@ -87,6 +88,7 @@ public final class SolanaUserPdaImportService {
|
||||
String serverAddress = safe(serverProfile.serverAddress());
|
||||
if (serverAddress.isBlank()) continue;
|
||||
routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress));
|
||||
if (routes.size() >= MAX_EFFECTIVE_ACCESS_SERVERS) break;
|
||||
}
|
||||
return new ArrayList<>(routes.values());
|
||||
}
|
||||
@@ -234,7 +236,13 @@ public final class SolanaUserPdaImportService {
|
||||
int n = u8(raw, c++);
|
||||
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
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) {
|
||||
int sessionsMode = u8(raw, c++);
|
||||
|
||||
+175
-7
@@ -6,6 +6,7 @@ import blockchain.MsgSubType;
|
||||
import blockchain.body.BodyHasLine;
|
||||
import blockchain.body.BodyHasTarget;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.StatusActionBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import blockchain.body.UserParamBody;
|
||||
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.entyties.Net_AddBlock_Request;
|
||||
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.WireCodes;
|
||||
import server.sync.AddBlockSyncService;
|
||||
@@ -166,6 +168,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
|
||||
case "channel_name_already_exists" -> "Такое название канала уже занято";
|
||||
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 "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||
default -> "Ошибка: " + code;
|
||||
@@ -277,6 +282,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
ChannelNameStateEntry channelNameStateEntry = null;
|
||||
ChannelNameStateEntry channelMetaUpdateEntry = null;
|
||||
Chat200CreateSeed chat200CreateSeed = null;
|
||||
if (block.body instanceof CreateChannelBody createChannelBody) {
|
||||
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);
|
||||
}
|
||||
|
||||
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.setSlug(slug);
|
||||
channelNameStateEntry.setDisplayName(normalizedName);
|
||||
channelNameStateEntry.setChannelDescription(
|
||||
createChannelBody.channelDescription == null
|
||||
? ""
|
||||
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription)
|
||||
);
|
||||
channelNameStateEntry.setDisplayName(displayName);
|
||||
channelNameStateEntry.setChannelDescription(channelDescription);
|
||||
channelNameStateEntry.setAvaAr(avaAr);
|
||||
channelNameStateEntry.setAvaSha256(avaSha256);
|
||||
channelNameStateEntry.setAvaSize(avaSize);
|
||||
channelNameStateEntry.setMetaUpdatedAtMs(metaUpdatedAtMs);
|
||||
channelNameStateEntry.setOwnerLogin(login);
|
||||
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
|
||||
channelNameStateEntry.setChannelTypeCode(channelTypeCode);
|
||||
@@ -329,6 +357,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription();
|
||||
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
|
||||
@@ -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) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
@@ -544,6 +632,86 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
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 {
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
|
||||
+6
-1
@@ -58,7 +58,8 @@ public final class BlockchainWriter {
|
||||
BlockchainStateEntry st,
|
||||
BlockEntry be,
|
||||
UserParamEntry userParamEntry,
|
||||
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
||||
ChannelNameStateEntry channelNameStateEntry,
|
||||
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
byte[] blockBytes = block.toBytes();
|
||||
@@ -91,6 +92,10 @@ public final class BlockchainWriter {
|
||||
channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||
}
|
||||
|
||||
if (channelMetaUpdateEntry != null) {
|
||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} 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;
|
||||
|
||||
import blockchain.BchBlockEntry;
|
||||
import blockchain.MsgSubType;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.DbController;
|
||||
@@ -20,6 +22,7 @@ import java.util.Map;
|
||||
public final class ChannelNamesStateBootstrapper {
|
||||
private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class);
|
||||
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 volatile boolean bootstrapped;
|
||||
|
||||
@@ -74,17 +77,30 @@ public final class ChannelNamesStateBootstrapper {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String displayName;
|
||||
String displayName;
|
||||
final String slug;
|
||||
final String channelDescription;
|
||||
String channelDescription;
|
||||
final int channelTypeCode;
|
||||
final int channelTypeVersion;
|
||||
String avaAr = "";
|
||||
String avaSha256 = "";
|
||||
long avaSize = 0L;
|
||||
long metaUpdatedAtMs = 0L;
|
||||
try {
|
||||
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
|
||||
slug = ChannelNameRules.toCanonicalSlug(displayName);
|
||||
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
|
||||
channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
|
||||
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) {
|
||||
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
|
||||
continue;
|
||||
@@ -102,6 +118,10 @@ public final class ChannelNamesStateBootstrapper {
|
||||
entry.setSlug(slug);
|
||||
entry.setDisplayName(displayName);
|
||||
entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
|
||||
entry.setAvaAr(avaAr);
|
||||
entry.setAvaSha256(avaSha256);
|
||||
entry.setAvaSize(avaSize);
|
||||
entry.setMetaUpdatedAtMs(metaUpdatedAtMs);
|
||||
entry.setOwnerLogin(ownerLogin);
|
||||
entry.setOwnerBlockchainName(ownerBch);
|
||||
entry.setChannelTypeCode(channelTypeCode);
|
||||
@@ -116,6 +136,8 @@ public final class ChannelNamesStateBootstrapper {
|
||||
|
||||
dao.clearAll(c);
|
||||
dao.insertAll(c, entries);
|
||||
|
||||
applyMetaUpdates(c, dao, entries, skipped);
|
||||
c.commit();
|
||||
log.info("channel_names_state bootstrapped: {}", entries.size());
|
||||
if (!conflicts.isEmpty()) {
|
||||
@@ -148,4 +170,53 @@ public final class ChannelNamesStateBootstrapper {
|
||||
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_REACTION = 2;
|
||||
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 COMMAND_PREFIX = "/.";
|
||||
static final String COMMAND_DESC = "desc";
|
||||
static final String COMMAND_ADD = "add";
|
||||
static final String COMMAND_REMOVE = "remove";
|
||||
|
||||
@@ -47,11 +46,40 @@ final class ChannelsReadSupport {
|
||||
|
||||
if (rootNumber == 0) {
|
||||
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;
|
||||
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";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
@@ -68,21 +96,29 @@ final class ChannelsReadSupport {
|
||||
BodyRecord body = e.body;
|
||||
if (body instanceof CreateChannelBody ccb) {
|
||||
meta.channelName = ccb.channelName;
|
||||
meta.displayName = ccb.channelName;
|
||||
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
|
||||
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 {
|
||||
meta.channelName = null;
|
||||
meta.displayName = null;
|
||||
meta.channelDescription = "";
|
||||
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
||||
}
|
||||
String updatedDescription = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
||||
if (updatedDescription != null) {
|
||||
meta.channelDescription = updatedDescription;
|
||||
}
|
||||
return meta;
|
||||
} catch (Exception ignored) {
|
||||
meta.channelName = null;
|
||||
meta.displayName = null;
|
||||
meta.channelDescription = "";
|
||||
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
||||
return meta;
|
||||
@@ -91,13 +127,17 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
|
||||
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)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
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()) {
|
||||
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 {
|
||||
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
|
||||
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
|
||||
LIMIT 1
|
||||
""";
|
||||
@@ -117,7 +157,11 @@ final class ChannelsReadSupport {
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
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()) {
|
||||
if (!rs.next()) return null;
|
||||
PostBlock pb = new PostBlock();
|
||||
@@ -133,7 +177,7 @@ final class ChannelsReadSupport {
|
||||
|
||||
static PostBlock loadLastVersion(Connection c, String ownerBch, int originalBlockNumber, byte[] originalHash) throws SQLException {
|
||||
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
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||
@@ -169,6 +213,10 @@ final class ChannelsReadSupport {
|
||||
ti.text = tlb.message;
|
||||
} else if (e.body instanceof TextReplyBody trb) {
|
||||
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) {
|
||||
ti.text = tb.message;
|
||||
}
|
||||
@@ -183,7 +231,7 @@ final class ChannelsReadSupport {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
||||
ORDER BY block_number
|
||||
""" + order + " LIMIT ?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
@@ -191,8 +239,12 @@ final class ChannelsReadSupport {
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
||||
ps.setInt(5, lineCode);
|
||||
ps.setInt(6, limit);
|
||||
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);
|
||||
ps.setInt(10, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostBlock> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
@@ -257,21 +309,47 @@ final class ChannelsReadSupport {
|
||||
|
||||
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";
|
||||
int likesCount = 0;
|
||||
int repliesCount = 0;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bch);
|
||||
ps.setInt(2, blockNumber);
|
||||
ps.setBytes(3, blockHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return new int[] {0, 0};
|
||||
return new int[] {rs.getInt("likes_count"), rs.getInt("replies_count")};
|
||||
if (rs.next()) {
|
||||
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 {
|
||||
if (rootNumber == 0) {
|
||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, 0);
|
||||
return fromCommand == null ? "" : fromCommand;
|
||||
return "";
|
||||
}
|
||||
|
||||
// Preferred source: persisted state (fast path, works for CreateChannelBody v2).
|
||||
@@ -287,8 +365,7 @@ final class ChannelsReadSupport {
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
|
||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
||||
return fromCommand == null ? saved : fromCommand;
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
} catch (SQLException ignored) {
|
||||
@@ -306,8 +383,6 @@ final class ChannelsReadSupport {
|
||||
BchBlockEntry e = new BchBlockEntry(bytes);
|
||||
BodyRecord body = e.body;
|
||||
if (body instanceof CreateChannelBody ccb) {
|
||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
||||
if (fromCommand != null) return fromCommand;
|
||||
return ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||
}
|
||||
return "";
|
||||
@@ -363,45 +438,97 @@ final class ChannelsReadSupport {
|
||||
return meta.channelTypeVersion;
|
||||
}
|
||||
|
||||
static String detectLatestDescriptionCommand(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
String sql = """
|
||||
SELECT block_bytes
|
||||
static List<MetaEvent> channelMetaEvents(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
List<MetaEvent> out = new ArrayList<>();
|
||||
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
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
||||
ORDER BY block_number DESC
|
||||
LIMIT 300
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND block_number=?
|
||||
LIMIT 1
|
||||
""";
|
||||
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)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
||||
ps.setInt(5, lineCode);
|
||||
ps.setInt(3, MsgSubType.TEXT_CHANNEL_META);
|
||||
ps.setInt(4, lineCode);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
TextInfo info = parseTextAndTime(rs.getBytes("block_bytes"));
|
||||
CommandInfo commandInfo = parseCommandText(info.text);
|
||||
if (commandInfo != null && COMMAND_DESC.equals(commandInfo.command)) {
|
||||
return commandInfo.arg;
|
||||
byte[] bytes = rs.getBytes("block_bytes");
|
||||
TextInfo info = parseTextAndTime(bytes);
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
return out;
|
||||
}
|
||||
|
||||
static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException {
|
||||
@@ -506,6 +633,22 @@ final class ChannelsReadSupport {
|
||||
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 {
|
||||
String login;
|
||||
String bchName;
|
||||
@@ -526,19 +669,29 @@ final class ChannelsReadSupport {
|
||||
|
||||
static final class ChannelMeta {
|
||||
String channelName;
|
||||
String displayName;
|
||||
String channelDescription;
|
||||
int channelTypeCode;
|
||||
int channelTypeVersion;
|
||||
String avaAr = "";
|
||||
String avaSha256 = "";
|
||||
long avaSize = 0L;
|
||||
long metaUpdatedAtMs = 0L;
|
||||
}
|
||||
|
||||
static final class CommandInfo {
|
||||
final String command;
|
||||
final String arg;
|
||||
|
||||
CommandInfo(String command, String arg) {
|
||||
this.command = command;
|
||||
this.arg = arg;
|
||||
}
|
||||
static final class MetaEvent {
|
||||
String kind;
|
||||
String login;
|
||||
String bchName;
|
||||
int blockNumber;
|
||||
byte[] blockHash;
|
||||
Integer lineStep;
|
||||
long createdAtMs;
|
||||
String title;
|
||||
String description;
|
||||
String avaAr;
|
||||
String avaSha256;
|
||||
long avaSize;
|
||||
}
|
||||
|
||||
static final class PairChannelSelector {
|
||||
|
||||
+28
-1
@@ -57,7 +57,12 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
|
||||
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode);
|
||||
channel.setChannelName(meta.channelName);
|
||||
channel.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
|
||||
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.setChannelTypeVersion(meta.channelTypeVersion);
|
||||
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||
@@ -66,6 +71,27 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
channel.setChannelRoot(rootRef);
|
||||
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<>(
|
||||
ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc)
|
||||
);
|
||||
@@ -117,7 +143,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
v1.setCreatedAtMs(postText.createdAtMs);
|
||||
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);
|
||||
for (ChannelsReadSupport.PostBlock edit : edits) {
|
||||
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);
|
||||
item.setLikesCount(stats[0]);
|
||||
item.setRepliesCount(stats[1]);
|
||||
item.setRatingsCount(stats[2]);
|
||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||
|
||||
items.add(item);
|
||||
|
||||
+22
-11
@@ -17,6 +17,7 @@ import shine.db.DbController;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.Comparator;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
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 {
|
||||
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<>();
|
||||
for (PostRow row : replies) {
|
||||
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
|
||||
@@ -100,24 +101,29 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
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 = """
|
||||
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
|
||||
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=?
|
||||
ORDER BY block_number ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setInt(1, MsgSubType.TEXT_REPLY);
|
||||
ps.setString(2, toBchName);
|
||||
ps.setInt(3, toBlockNumber);
|
||||
ps.setBytes(4, toBlockHash);
|
||||
ps.setInt(5, limit);
|
||||
ps.setInt(2, MsgSubType.TEXT_RATING);
|
||||
ps.setString(3, toBchName);
|
||||
ps.setInt(4, toBlockNumber);
|
||||
ps.setBytes(5, toBlockHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostRow> out = new ArrayList<>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -208,8 +214,12 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
first.setCreatedAtMs(base.createdAtMs);
|
||||
versions.add(first);
|
||||
|
||||
if (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_POST) {
|
||||
short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST;
|
||||
if (row.msgSubType == MsgSubType.TEXT_REPLY
|
||||
|| 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)) {
|
||||
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||
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);
|
||||
node.setLikesCount(stats[0]);
|
||||
node.setRepliesCount(stats[1]);
|
||||
node.setRatingsCount(stats[2]);
|
||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||
if (row.lineCode != null && row.lineCode >= 0) {
|
||||
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.setOwnerBlockchainName(key.ownerBch);
|
||||
channelRef.setChannelName(meta.channelName);
|
||||
channelRef.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
|
||||
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.setChannelTypeVersion(meta.channelTypeVersion);
|
||||
channelRef.setPersonal(meta.channelTypeCode == 100);
|
||||
|
||||
+7
-2
@@ -58,7 +58,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
||||
AND block_number = ?
|
||||
AND block_hash = ?
|
||||
AND msg_type = ?
|
||||
AND msg_sub_type = ?
|
||||
AND msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||
%s
|
||||
LIMIT 1
|
||||
""".formatted(strictChannelMatch ? "AND line_code = ?" : "");
|
||||
@@ -98,8 +98,13 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
||||
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
|
||||
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
|
||||
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) {
|
||||
existsPs.setInt(6, expectedRoot);
|
||||
existsPs.setInt(11, expectedRoot);
|
||||
}
|
||||
|
||||
boolean exists;
|
||||
|
||||
+95
@@ -8,6 +8,7 @@ import java.util.List;
|
||||
public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
private Channel channel;
|
||||
private List<MessageItem> messages = new ArrayList<>();
|
||||
private List<ChannelMetaEvent> metaEvents = new ArrayList<>();
|
||||
|
||||
public Channel getChannel() { return 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 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 {
|
||||
private String ownerLogin;
|
||||
private String ownerBlockchainName;
|
||||
private String channelName;
|
||||
private String displayName;
|
||||
private String channelDescription;
|
||||
private String avaAr;
|
||||
private String avaSha256;
|
||||
private Long avaSize;
|
||||
private Long metaUpdatedAtMs;
|
||||
private Integer channelTypeCode;
|
||||
private Integer channelTypeVersion;
|
||||
private BlockRef channelRoot;
|
||||
@@ -34,9 +43,24 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
public String getChannelName() { return 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 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 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 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 {
|
||||
private BlockRef messageRef;
|
||||
private Integer msgSubType;
|
||||
@@ -56,11 +127,17 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
private String targetBlockchainName;
|
||||
private Integer targetBlockNumber;
|
||||
private String targetBlockHash;
|
||||
private Integer targetMsgSubType;
|
||||
private String targetText;
|
||||
private String targetAuthorLogin;
|
||||
private String targetAuthorBlockchainName;
|
||||
private Long targetCreatedAtMs;
|
||||
private long createdAtMs;
|
||||
private String text;
|
||||
private int likesCount;
|
||||
private boolean likedByMe;
|
||||
private int repliesCount;
|
||||
private int ratingsCount;
|
||||
private int versionsTotal;
|
||||
private List<VersionItem> versions = new ArrayList<>();
|
||||
|
||||
@@ -87,6 +164,21 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
public String getTargetBlockHash() { return 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 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 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 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 ownerBlockchainName;
|
||||
private String channelName;
|
||||
private String displayName;
|
||||
private String channelDescription;
|
||||
private String avaAr;
|
||||
private String avaSha256;
|
||||
private Long avaSize;
|
||||
private Long metaUpdatedAtMs;
|
||||
private Integer channelTypeCode;
|
||||
private Integer channelTypeVersion;
|
||||
private boolean personal;
|
||||
@@ -61,9 +66,24 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
public String getChannelName() { return 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 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 void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
|
||||
|
||||
|
||||
+22
-3
@@ -26,6 +26,7 @@ import java.util.Set;
|
||||
public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
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;
|
||||
|
||||
@Override
|
||||
@@ -38,8 +39,11 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
||||
String toRequest = req.getToLogin() == null ? "" : req.getToLogin().trim();
|
||||
String callId = req.getCallId() == null ? "" : req.getCallId().trim();
|
||||
int type = req.getType() == null ? TYPE_INVITE : req.getType();
|
||||
if (toRequest.isBlank() || callId.isBlank() || type != TYPE_INVITE) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны");
|
||||
String data = req.getData() == null ? "" : req.getData().trim();
|
||||
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);
|
||||
@@ -69,13 +73,28 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler {
|
||||
payload.put("fromSessionId", ctx.getSessionId());
|
||||
payload.put("toLogin", to);
|
||||
payload.put("callId", callId);
|
||||
payload.put("type", TYPE_INVITE);
|
||||
payload.put("type", type);
|
||||
payload.put("timeMs", timeMs);
|
||||
if (!data.isBlank()) {
|
||||
payload.put("data", data);
|
||||
}
|
||||
|
||||
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingCallInvite", eventId, payload);
|
||||
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) {
|
||||
String sessionId = String.valueOf(session.getSessionId() == null ? "" : session.getSessionId()).trim();
|
||||
if (!sessionId.isBlank() && activeSessionIds.contains(sessionId)) {
|
||||
|
||||
+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.messages.entyties.Net_SendSignal_Request;
|
||||
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.utils.AuthKeyUtils;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.ActiveSessionsDAO;
|
||||
import shine.db.dao.CurrentUsersDAO;
|
||||
import shine.db.entities.ActiveSessionEntry;
|
||||
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_ALL = "all_sessions";
|
||||
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
|
||||
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");
|
||||
}
|
||||
|
||||
if (isCallSignalType(signalType) && clientSignatureB64.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "CLIENT_SIGNATURE_REQUIRED", "Для call_* сигналов обязательна подпись client key");
|
||||
}
|
||||
|
||||
if (!clientSignatureB64.isBlank()) {
|
||||
String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
|
||||
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);
|
||||
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 msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала";
|
||||
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", "Не удалось доставить сигнал ни в одну целевую сессию");
|
||||
}
|
||||
|
||||
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();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setDeliveredCount(deliveredSessionIds.size());
|
||||
resp.setDeliveredCount(deliveredSessionIds.size() + webPushDelivered);
|
||||
resp.setDeliveredSessionIds(deliveredSessionIds);
|
||||
resp.setDeliveredWsSessions(deliveredSessionIds.size());
|
||||
resp.setDeliveredFcmSessions(webPushDelivered);
|
||||
resp.setDeliveredWebPushSessions(webPushDelivered);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@@ -190,6 +255,19 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
||||
+ 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 {
|
||||
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName);
|
||||
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
|
||||
@@ -214,7 +292,130 @@ public class Net_SendSignal_Handler implements JsonMessageHandler {
|
||||
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) {
|
||||
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 callId;
|
||||
private Integer type;
|
||||
private String data;
|
||||
|
||||
public String getToLogin() { return 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 void setType(Integer type) { this.type = type; }
|
||||
|
||||
public String getData() { return data; }
|
||||
public void setData(String data) { this.data = data; }
|
||||
}
|
||||
|
||||
+27
@@ -8,6 +8,9 @@ import java.util.List;
|
||||
public class Net_SendSignal_Response extends Net_Response {
|
||||
private int deliveredCount;
|
||||
private List<String> deliveredSessionIds = new ArrayList<>();
|
||||
private int deliveredWsSessions;
|
||||
private int deliveredFcmSessions;
|
||||
private int deliveredWebPushSessions;
|
||||
|
||||
public int getDeliveredCount() {
|
||||
return deliveredCount;
|
||||
@@ -24,4 +27,28 @@ public class Net_SendSignal_Response extends Net_Response {
|
||||
public void setDeliveredSessionIds(List<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+77
-43
@@ -8,6 +8,7 @@ import sync.codec.ShineUsersCodec;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class PostgresStorageRepository
|
||||
implements AutoCloseable {
|
||||
@@ -465,7 +466,7 @@ public final class PostgresStorageRepository
|
||||
|
||||
String sql =
|
||||
"INSERT INTO solana_user_pda_current (" +
|
||||
"pda_address, login, record_number, slot, last_tx_signature, " +
|
||||
"pda_address, login, normalized_login, record_number, slot, last_tx_signature, " +
|
||||
"recovery_key, root_key, client_key, blockchain_name, " +
|
||||
"blockchain_key, paid_limit_bytes, used_bytes, " +
|
||||
"last_block_number, last_block_hash, last_block_signature, " +
|
||||
@@ -475,9 +476,10 @@ public final class PostgresStorageRepository
|
||||
"trusted_count, created_at_ms, updated_at_ms, " +
|
||||
"prev_record_hash, record_signature, raw_data_base64, " +
|
||||
"first_seen_at_ms, last_synced_at_ms" +
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT (pda_address) DO UPDATE SET " +
|
||||
"login = EXCLUDED.login, " +
|
||||
"normalized_login = EXCLUDED.normalized_login, " +
|
||||
"record_number = EXCLUDED.record_number, " +
|
||||
"slot = EXCLUDED.slot, " +
|
||||
"last_tx_signature = EXCLUDED.last_tx_signature, " +
|
||||
@@ -679,36 +681,41 @@ public final class PostgresStorageRepository
|
||||
|
||||
statement.setString(1, snapshot.pdaAddress());
|
||||
statement.setString(2, snapshot.login());
|
||||
statement.setInt(3, snapshot.recordNumber());
|
||||
statement.setLong(4, snapshot.slot());
|
||||
statement.setString(5, snapshot.lastTxSignature());
|
||||
statement.setString(6, snapshot.recoveryKey());
|
||||
statement.setString(7, snapshot.rootKey());
|
||||
statement.setString(8, snapshot.clientKey());
|
||||
statement.setString(9, snapshot.blockchainName());
|
||||
statement.setString(10, snapshot.blockchainKey());
|
||||
statement.setLong(11, snapshot.paidLimitBytes());
|
||||
statement.setLong(12, snapshot.usedBytes());
|
||||
statement.setInt(13, snapshot.lastBlockNumber());
|
||||
statement.setString(14, snapshot.lastBlockHash());
|
||||
statement.setString(15, snapshot.lastBlockSignature());
|
||||
statement.setString(16, snapshot.arweaveTxId());
|
||||
statement.setBoolean(17, snapshot.isServer());
|
||||
statement.setInt(18, snapshot.addressFormatType());
|
||||
statement.setInt(19, snapshot.addressFormatVersion());
|
||||
statement.setString(20, snapshot.serverAddress());
|
||||
statement.setString(21, writeJson(snapshot.syncServers()));
|
||||
statement.setString(22, writeJson(snapshot.accessServers()));
|
||||
statement.setInt(23, snapshot.sessionsMode());
|
||||
statement.setString(24, writeJson(snapshot.sessions()));
|
||||
statement.setInt(25, snapshot.trustedCount());
|
||||
statement.setLong(26, snapshot.createdAtMs());
|
||||
statement.setLong(27, snapshot.updatedAtMs());
|
||||
statement.setString(28, snapshot.prevRecordHash());
|
||||
statement.setString(29, snapshot.recordSignature());
|
||||
statement.setString(30, snapshot.rawDataBase64());
|
||||
statement.setLong(31, nowMs);
|
||||
statement.setString(3, normalizeLogin(snapshot.login()));
|
||||
statement.setInt(4, snapshot.recordNumber());
|
||||
statement.setLong(5, snapshot.slot());
|
||||
statement.setString(6, snapshot.lastTxSignature());
|
||||
statement.setString(7, snapshot.recoveryKey());
|
||||
statement.setString(8, snapshot.rootKey());
|
||||
statement.setString(9, snapshot.clientKey());
|
||||
statement.setString(10, snapshot.blockchainName());
|
||||
statement.setString(11, snapshot.blockchainKey());
|
||||
statement.setLong(12, snapshot.paidLimitBytes());
|
||||
statement.setLong(13, snapshot.usedBytes());
|
||||
statement.setInt(14, snapshot.lastBlockNumber());
|
||||
statement.setString(15, snapshot.lastBlockHash());
|
||||
statement.setString(16, snapshot.lastBlockSignature());
|
||||
statement.setString(17, snapshot.arweaveTxId());
|
||||
statement.setBoolean(18, snapshot.isServer());
|
||||
statement.setInt(19, snapshot.addressFormatType());
|
||||
statement.setInt(20, snapshot.addressFormatVersion());
|
||||
statement.setString(21, snapshot.serverAddress());
|
||||
statement.setString(22, writeJson(snapshot.syncServers()));
|
||||
statement.setString(23, writeJson(snapshot.accessServers()));
|
||||
statement.setInt(24, snapshot.sessionsMode());
|
||||
statement.setString(25, writeJson(snapshot.sessions()));
|
||||
statement.setInt(26, snapshot.trustedCount());
|
||||
statement.setLong(27, snapshot.createdAtMs());
|
||||
statement.setLong(28, snapshot.updatedAtMs());
|
||||
statement.setString(29, snapshot.prevRecordHash());
|
||||
statement.setString(30, snapshot.recordSignature());
|
||||
statement.setString(31, snapshot.rawDataBase64());
|
||||
statement.setLong(32, nowMs);
|
||||
statement.setLong(33, nowMs);
|
||||
}
|
||||
|
||||
private String normalizeLogin(String login) {
|
||||
return login == null ? "" : login.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private ShineUsersCodec.UserPdaSnapshot mapSnapshot(
|
||||
@@ -911,6 +918,7 @@ public final class PostgresStorageRepository
|
||||
"CREATE TABLE IF NOT EXISTS solana_user_pda_current (" +
|
||||
"pda_address TEXT PRIMARY KEY, " +
|
||||
"login TEXT NOT NULL UNIQUE, " +
|
||||
"normalized_login TEXT NOT NULL, " +
|
||||
"record_number INTEGER NOT NULL, " +
|
||||
"slot BIGINT NOT NULL, " +
|
||||
"last_tx_signature TEXT NOT NULL, " +
|
||||
@@ -943,11 +951,29 @@ public final class PostgresStorageRepository
|
||||
"last_synced_at_ms BIGINT NOT NULL" +
|
||||
")"
|
||||
);
|
||||
statement.executeUpdate(
|
||||
"ALTER TABLE solana_user_pda_current " +
|
||||
"ADD COLUMN IF NOT EXISTS normalized_login TEXT"
|
||||
);
|
||||
statement.executeUpdate(
|
||||
"UPDATE solana_user_pda_current " +
|
||||
"SET normalized_login = LOWER(BTRIM(login)) " +
|
||||
"WHERE normalized_login IS NULL " +
|
||||
" OR normalized_login <> LOWER(BTRIM(login))"
|
||||
);
|
||||
|
||||
statement.executeUpdate(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " +
|
||||
"ON solana_user_pda_current(slot)"
|
||||
);
|
||||
statement.executeUpdate(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login " +
|
||||
"ON solana_user_pda_current(normalized_login)"
|
||||
);
|
||||
statement.executeUpdate(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login " +
|
||||
"ON solana_user_pda_current(normalized_login)"
|
||||
);
|
||||
|
||||
statement.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS solana_user_pda_history (" +
|
||||
@@ -1057,12 +1083,16 @@ public final class PostgresStorageRepository
|
||||
s.updated_at_ms,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
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
|
||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||
AND s.is_server = TRUE
|
||||
@@ -1100,12 +1130,16 @@ public final class PostgresStorageRepository
|
||||
FOR affected_user IN
|
||||
SELECT u.login
|
||||
FROM solana_user_pda_current u
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
ELSE u.access_servers_json::jsonb
|
||||
END
|
||||
) AS access_server(login_value)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT login_value
|
||||
FROM jsonb_array_elements_text(
|
||||
CASE
|
||||
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||
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)
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
Тоесть сделать что бы нормально проверялись логины пользователей
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Статус: отложено.
|
||||
|
||||
## Зачем это нужно
|
||||
@@ -0,0 +1 @@
|
||||
Передачу билетов владельцами со счёта на счёт
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Баланс в салане
|
||||
|
||||
баланс в Арвив / и турбо
|
||||
|
||||
Балан лимит МБ / оно же сияния токены SHN
|
||||
@@ -0,0 +1,3 @@
|
||||
Сделать поддержку нескольких залогиненных аккаунтов враз тоесть что бы можно было менять акаунт под которым заходить
|
||||
- подумать о деталях, а так хорошая тема - и тестировать удобнее станет
|
||||
-
|
||||
-10
@@ -27,13 +27,3 @@
|
||||
|
||||
- `BlockchainTmpRecoveryOnStartup` и `BlockchainResyncRecoveryOnStartup` уже умеют добирать незавершённые хвосты после старта.
|
||||
- `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.3.1
|
||||
server.version=1.3.1
|
||||
client.version=1.7.0
|
||||
server.version=1.6.0
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
- `user_access_servers_current`
|
||||
- `solana_user_pda_history`
|
||||
- источник истины по пользовательским PDA: `solana_user_pda_current`.
|
||||
- в `solana_user_pda_current` хранятся оба варианта логина:
|
||||
- `login` — display-логин из PDA;
|
||||
- `normalized_login` — канонический lower-case для runtime lookup и части FK;
|
||||
- `user_access_servers_current` — это вторичная локальная проекция для быстрого роутинга DM по access servers;
|
||||
она автоматически пересобирается из `solana_user_pda_current`, включая backfill для уже существующих пользователей.
|
||||
|
||||
@@ -56,7 +59,7 @@ psql \
|
||||
Скрипт:
|
||||
|
||||
- создаёт таблицу версии схемы `db_schema_version`;
|
||||
- ставит `schema_version = 2`;
|
||||
- ставит актуальный `schema_version`;
|
||||
- создаёт таблицы sync-модуля Solana users;
|
||||
- создаёт server runtime tables;
|
||||
- создаёт триггеры и функции автоматической актуализации `user_access_servers_current`;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
backup.schema.version=1
|
||||
backup.full.version=2
|
||||
last.full.backup.date=2026-07-10
|
||||
backup.schema.version=2
|
||||
backup.full.version=3
|
||||
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
|
||||
tmpfs 392M 1.2M 391M 1% /run
|
||||
/dev/vda2 32G 14G 17G 46% /
|
||||
tmpfs 2.0G 0 2.0G 0% /dev/shm
|
||||
tmpfs 197M 1.2M 196M 1% /run
|
||||
/dev/sda1 40G 6.7G 31G 18% /
|
||||
tmpfs 982M 0 982M 0% /dev/shm
|
||||
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
|
||||
sr0 iso9660 Joliet Extension cidata 2026-06-02-12-22-36-00
|
||||
sr1
|
||||
vda
|
||||
├─vda1
|
||||
└─vda2 ext4 1.0 c422dce2-e6a3-4ce4-a9c1-17ab14e9193a 16.1G 44% /
|
||||
NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS
|
||||
sda
|
||||
└─sda1 ext4 1.0 42e8f44f-2dd9-4fc6-8485-6581d6528df5 30.6G 17% /
|
||||
sr0
|
||||
|
||||
@@ -1,13 +1,48 @@
|
||||
UNIT FILE STATE PRESET
|
||||
agent-memory.service enabled enabled
|
||||
caddy.service enabled enabled
|
||||
containerd.service enabled enabled
|
||||
coturn.service enabled enabled
|
||||
docker.service enabled enabled
|
||||
elaira-agent.service enabled enabled
|
||||
hermes-dashboard.service enabled enabled
|
||||
hermes-gateway.service enabled enabled
|
||||
shine-server.service enabled enabled
|
||||
ubuntu-fan.service enabled enabled
|
||||
UNIT FILE STATE VENDOR PRESET
|
||||
apparmor.service enabled enabled
|
||||
blk-availability.service enabled enabled
|
||||
caddy.service enabled enabled
|
||||
console-setup.service enabled enabled
|
||||
containerd.service enabled enabled
|
||||
coturn.service enabled enabled
|
||||
cron.service enabled enabled
|
||||
dmesg.service enabled enabled
|
||||
docker.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.
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
UNIT LOAD ACTIVE SUB DESCRIPTION
|
||||
agent-memory.service loaded active running agent-memory service
|
||||
caddy.service loaded active running Caddy
|
||||
containerd.service loaded active running containerd container runtime
|
||||
coturn.service loaded active running coTURN STUN/TURN Server
|
||||
cron.service loaded active running Regular background program processing daemon
|
||||
dbus.service loaded active running D-Bus System Message Bus
|
||||
docker.service loaded active running Docker Application Container Engine
|
||||
elaira-agent.service loaded active running Elaira self-hosted agent
|
||||
getty@tty1.service loaded active running Getty on tty1
|
||||
hermes-gateway.service loaded active running Hermes Agent Gateway - Messaging Platform Integration
|
||||
irqbalance.service loaded active running irqbalance daemon
|
||||
ModemManager.service loaded active running Modem Manager
|
||||
multipathd.service loaded active running Device-Mapper Multipath Device Controller
|
||||
networkd-dispatcher.service loaded active running Dispatcher daemon for systemd-networkd
|
||||
packagekit.service loaded active running PackageKit Daemon
|
||||
polkit.service loaded active running Authorization Manager
|
||||
qemu-guest-agent.service loaded active running QEMU Guest Agent
|
||||
rsyslog.service loaded active running System Logging Service
|
||||
shine-server.service loaded active running SHiNE Server
|
||||
shine-server.service loaded active running SHiNE Server (shineup.me)
|
||||
ssh.service loaded active running OpenBSD Secure Shell server
|
||||
systemd-hostnamed.service loaded active running Hostname Service
|
||||
systemd-journald.service loaded active running Journal Service
|
||||
@@ -26,10 +26,9 @@
|
||||
udisks2.service loaded active running Disk Manager
|
||||
unattended-upgrades.service loaded active running Unattended Upgrades Shutdown
|
||||
upower.service loaded active running Daemon for power management
|
||||
user@1002.service loaded active running User Manager for UID 1002
|
||||
|
||||
Legend: LOAD → Reflects whether the unit definition was properly loaded.
|
||||
ACTIVE → The high-level unit activation state, i.e. generalization of SUB.
|
||||
SUB → The low-level unit activation state, values depend on unit type.
|
||||
user@1000.service loaded active running User Manager for UID 1000
|
||||
|
||||
LOAD = Reflects whether the unit definition was properly loaded.
|
||||
ACTIVE = The high-level unit activation state, i.e. generalization of SUB.
|
||||
SUB = The low-level unit activation state, values depend on unit type.
|
||||
28 loaded units listed.
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
|
||||
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=323411,fd=15))
|
||||
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=37))
|
||||
LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=15))
|
||||
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=34))
|
||||
LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=13))
|
||||
LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=58))
|
||||
LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=21))
|
||||
LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=60))
|
||||
LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=23))
|
||||
LISTEN 0 2048 0.0.0.0:8000 0.0.0.0:* users:(("uvicorn",pid=2056697,fd=6))
|
||||
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=8096,fd=14))
|
||||
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=323411,fd=17))
|
||||
LISTEN 0 4096 0.0.0.0:2222 0.0.0.0:* users:(("docker-proxy",pid=8410,fd=7))
|
||||
LISTEN 0 4096 127.0.0.1:45999 0.0.0.0:* users:(("containerd",pid=2121968,fd=15))
|
||||
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("docker-proxy",pid=8433,fd=7))
|
||||
LISTEN 0 2048 127.0.0.1:9119 0.0.0.0:* users:(("hermes",pid=1913356,fd=7))
|
||||
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=323588,fd=3),("systemd",pid=1,fd=130))
|
||||
LISTEN 0 1024 185.229.109.118:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=40))
|
||||
LISTEN 0 1024 185.229.109.118:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=17))
|
||||
LISTEN 0 1024 185.229.109.118:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=56))
|
||||
LISTEN 0 1024 185.229.109.118:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=19))
|
||||
LISTEN 0 100 *:8018 *:* users:(("java",pid=9688,fd=13))
|
||||
LISTEN 0 50 *:7070 *:* users:(("java",pid=1732508,fd=16))
|
||||
LISTEN 0 4096 [::]:2222 [::]:* users:(("docker-proxy",pid=8417,fd=7))
|
||||
LISTEN 0 4096 [::]:22 [::]:* users:(("sshd",pid=323588,fd=4),("systemd",pid=1,fd=132))
|
||||
LISTEN 0 4096 *:80 *:* users:(("caddy",pid=8096,fd=16))
|
||||
LISTEN 0 4096 *:443 *:* users:(("caddy",pid=8096,fd=15))
|
||||
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=1689866,fd=25))
|
||||
LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=1689866,fd=62))
|
||||
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=1689866,fd=27))
|
||||
LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=1689866,fd=64))
|
||||
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
|
||||
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=839,fd=3))
|
||||
LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=34))
|
||||
LISTEN 0 1024 0.0.0.0:3478 0.0.0.0:* users:(("turnserver",pid=522,fd=33))
|
||||
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=804,fd=4))
|
||||
LISTEN 0 4096 127.0.0.1:42113 0.0.0.0:* users:(("containerd",pid=536,fd=16))
|
||||
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=466,fd=14))
|
||||
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=1240,fd=7))
|
||||
LISTEN 0 4096 *:80 *:* users:(("caddy",pid=804,fd=9))
|
||||
LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=839,fd=4))
|
||||
LISTEN 0 4096 *:443 *:* users:(("caddy",pid=804,fd=7))
|
||||
LISTEN 0 50 *:7070 *:* users:(("java",pid=1289,fd=19))
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
NAMES IMAGE STATUS PORTS
|
||||
gitea gitea/gitea:1.22.6 Up 5 weeks 127.0.0.1:3000->3000/tcp, 0.0.0.0:2222->22/tcp, [::]:2222->22/tcp
|
||||
NAMES IMAGE STATUS PORTS
|
||||
shine-postgres postgres:18 Up 19 hours 127.0.0.1:5432->5432/tcp
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
4.0K /home/player/hosts.codex.test
|
||||
8.0K /home/player/Work_AGENTS
|
||||
804K /home/player/sites
|
||||
33M /home/player/elaira-agent
|
||||
36M /home/player/agent-memory
|
||||
46M /home/player/gitea
|
||||
183M /home/player/SHiNE
|
||||
1.7G /home/player/hermes
|
||||
85M /home/player/SHiNE
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
4.0K /var/local
|
||||
4.0K /var/mail
|
||||
4.0K /var/opt
|
||||
4.0K /var/snap
|
||||
16K /var/spool
|
||||
88K /var/tmp
|
||||
3.2M /var/backups
|
||||
143M /var/cache
|
||||
1.2G /var/lib
|
||||
1.5G /var/log
|
||||
72K /var/tmp
|
||||
1.9M /var/backups
|
||||
319M /var/cache
|
||||
1.1G /var/log
|
||||
1.5G /var/lib
|
||||
2.8G /var
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026-07-10T08:27:36Z
|
||||
2026-08-08T20:05:20Z
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
openmindsoft.io, www.openmindsoft.io {
|
||||
encode zstd gzip
|
||||
root * /home/player/sites/OpenMindSoft.io
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
{
|
||||
auto_https disable_redirects
|
||||
}
|
||||
|
||||
shineup.me {
|
||||
@@ -46,33 +43,3 @@ shineup.me {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
git.shineup.me {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
test-solana-tickets.shineup.me, test-solana-tickets.shiningpeople.ru {
|
||||
encode zstd gzip
|
||||
root * /home/player/sites/test-solana-tickets.shineup.me
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
header -Etag
|
||||
header {
|
||||
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
||||
Pragma "no-cache"
|
||||
Expires "0"
|
||||
}
|
||||
}
|
||||
|
||||
hermes.shineup.me {
|
||||
encode zstd gzip
|
||||
basicauth {
|
||||
player $2a$14$a41XVsBhgxgKN2MVS5Vt3Otu4C6mmv2FRo1gYDjvPDEYwYkPGnj1e
|
||||
}
|
||||
reverse_proxy 127.0.0.1:9119 {
|
||||
header_up Host 127.0.0.1:9119
|
||||
header_up Origin http://127.0.0.1:9119
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
missing: /etc/systemd/system/agent-memory.service
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=SHiNE Server
|
||||
Description=SHiNE Server (shineup.me)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
@@ -7,7 +7,7 @@ Type=simple
|
||||
User=player
|
||||
Group=player
|
||||
WorkingDirectory=/home/player/SHiNE/shine-server
|
||||
ExecStart=/usr/bin/java -Dserver.1port=7070 -jar /home/player/SHiNE/shine-server/shine-server.jar
|
||||
ExecStart=/usr/bin/java -Dserver.port=7070 -jar /home/player/SHiNE/shine-server/shine-server.jar
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
|
||||
@@ -1,709 +1,17 @@
|
||||
# Coturn TURN SERVER configuration file
|
||||
#
|
||||
# Boolean values note: where boolean value is supposed to be used,
|
||||
# you can use '0', 'off', 'no', 'false', 'f' as 'false,
|
||||
# and you can use '1', 'on', 'yes', 'true', 't' as 'true'
|
||||
# If the value is missed, then it means 'true'.
|
||||
#
|
||||
|
||||
# Listener interface device (optional, Linux only).
|
||||
# NOT RECOMMENDED.
|
||||
#
|
||||
#listening-device=eth0
|
||||
|
||||
# TURN listener port for UDP and TCP (Default: 3478).
|
||||
# Note: actually, TLS & DTLS sessions can connect to the
|
||||
# "plain" TCP & UDP port(s), too - if allowed by configuration.
|
||||
#
|
||||
#listening-port=3478
|
||||
|
||||
# TURN listener port for TLS (Default: 5349).
|
||||
# Note: actually, "plain" TCP & UDP sessions can connect to the TLS & DTLS
|
||||
# port(s), too - if allowed by configuration. The TURN server
|
||||
# "automatically" recognizes the type of traffic. Actually, two listening
|
||||
# endpoints (the "plain" one and the "tls" one) are equivalent in terms of
|
||||
# functionality; but we keep both endpoints to satisfy the RFC 5766 specs.
|
||||
# For secure TCP connections, we currently support SSL version 3 and
|
||||
# TLS version 1.0, 1.1 and 1.2.
|
||||
# For secure UDP connections, we support DTLS version 1.
|
||||
#
|
||||
#tls-listening-port=5349
|
||||
|
||||
# Alternative listening port for UDP and TCP listeners;
|
||||
# default (or zero) value means "listening port plus one".
|
||||
# This is needed for RFC 5780 support
|
||||
# (STUN extension specs, NAT behavior discovery). The TURN Server
|
||||
# supports RFC 5780 only if it is started with more than one
|
||||
# listening IP address of the same family (IPv4 or IPv6).
|
||||
# RFC 5780 is supported only by UDP protocol, other protocols
|
||||
# are listening to that endpoint only for "symmetry".
|
||||
#
|
||||
#alt-listening-port=0
|
||||
|
||||
# Alternative listening port for TLS and DTLS protocols.
|
||||
# Default (or zero) value means "TLS listening port plus one".
|
||||
#
|
||||
#alt-tls-listening-port=0
|
||||
|
||||
# Listener IP address of relay server. Multiple listeners can be specified.
|
||||
# If no IP(s) specified in the config file or in the command line options,
|
||||
# then all IPv4 and IPv6 system IPs will be used for listening.
|
||||
#
|
||||
#listening-ip=172.17.19.101
|
||||
#listening-ip=10.207.21.238
|
||||
#listening-ip=2607:f0d0:1002:51::4
|
||||
|
||||
# Auxiliary STUN/TURN server listening endpoint.
|
||||
# Aux servers have almost full TURN and STUN functionality.
|
||||
# The (minor) limitations are:
|
||||
#
|
||||
# 1) Auxiliary servers do not have alternative ports and
|
||||
# they do not support STUN RFC 5780 functionality (CHANGE REQUEST).
|
||||
#
|
||||
# 2) Auxiliary servers also are never returning ALTERNATIVE-SERVER reply.
|
||||
#
|
||||
# Valid formats are 1.2.3.4:5555 for IPv4 and [1:2::3:4]:5555 for IPv6.
|
||||
#
|
||||
# There may be multiple aux-server options, each will be used for listening
|
||||
# to client requests.
|
||||
#
|
||||
#aux-server=172.17.19.110:33478
|
||||
#aux-server=[2607:f0d0:1002:51::4]:33478
|
||||
|
||||
# (recommended for older Linuxes only)
|
||||
# Automatically balance UDP traffic over auxiliary servers (if configured).
|
||||
# The load balancing is using the ALTERNATE-SERVER mechanism.
|
||||
# The TURN client must support 300 ALTERNATE-SERVER response for this
|
||||
# functionality.
|
||||
#
|
||||
#udp-self-balance
|
||||
|
||||
# Relay interface device for relay sockets (optional, Linux only).
|
||||
# NOT RECOMMENDED.
|
||||
#
|
||||
#relay-device=eth1
|
||||
|
||||
# Relay address (the local IP address that will be used to relay the
|
||||
# packets to the peer).
|
||||
# Multiple relay addresses may be used.
|
||||
# The same IP(s) can be used as both listening IP(s) and relay IP(s).
|
||||
#
|
||||
# If no relay IP(s) specified, then the turnserver will apply the default
|
||||
# policy: it will decide itself which relay addresses to be used, and it
|
||||
# will always be using the client socket IP address as the relay IP address
|
||||
# of the TURN session (if the requested relay address family is the same
|
||||
# as the family of the client socket).
|
||||
#
|
||||
#relay-ip=172.17.19.105
|
||||
#relay-ip=2607:f0d0:1002:51::5
|
||||
|
||||
# For Amazon EC2 users:
|
||||
#
|
||||
# TURN Server public/private address mapping, if the server is behind NAT.
|
||||
# In that situation, if a -X is used in form "-X <ip>" then that ip will be reported
|
||||
# as relay IP address of all allocations. This scenario works only in a simple case
|
||||
# when one single relay address is be used, and no RFC5780 functionality is required.
|
||||
# That single relay address must be mapped by NAT to the 'external' IP.
|
||||
# The "external-ip" value, if not empty, is returned in XOR-RELAYED-ADDRESS field.
|
||||
# For that 'external' IP, NAT must forward ports directly (relayed port 12345
|
||||
# must be always mapped to the same 'external' port 12345).
|
||||
#
|
||||
# In more complex case when more than one IP address is involved,
|
||||
# that option must be used several times, each entry must
|
||||
# have form "-X <public-ip/private-ip>", to map all involved addresses.
|
||||
# RFC5780 NAT discovery STUN functionality will work correctly,
|
||||
# if the addresses are mapped properly, even when the TURN server itself
|
||||
# is behind A NAT.
|
||||
#
|
||||
# By default, this value is empty, and no address mapping is used.
|
||||
#
|
||||
#external-ip=60.70.80.91
|
||||
#
|
||||
#OR:
|
||||
#
|
||||
#external-ip=60.70.80.91/172.17.19.101
|
||||
#external-ip=60.70.80.92/172.17.19.102
|
||||
|
||||
|
||||
# Number of the relay threads to handle the established connections
|
||||
# (in addition to authentication thread and the listener thread).
|
||||
# If explicitly set to 0 then application runs relay process in a
|
||||
# single thread, in the same thread with the listener process
|
||||
# (the authentication thread will still be a separate thread).
|
||||
#
|
||||
# If this parameter is not set, then the default OS-dependent
|
||||
# thread pattern algorithm will be employed. Usually the default
|
||||
# algorithm is the most optimal, so you have to change this option
|
||||
# only if you want to make some fine tweaks.
|
||||
#
|
||||
# In the older systems (Linux kernel before 3.9),
|
||||
# the number of UDP threads is always one thread per network listening
|
||||
# endpoint - including the auxiliary endpoints - unless 0 (zero) or
|
||||
# 1 (one) value is set.
|
||||
#
|
||||
#relay-threads=0
|
||||
|
||||
# Lower and upper bounds of the UDP relay endpoints:
|
||||
# (default values are 49152 and 65535)
|
||||
#
|
||||
#min-port=49152
|
||||
#max-port=65535
|
||||
|
||||
# Uncomment to run TURN server in 'normal' 'moderate' verbose mode.
|
||||
# By default the verbose mode is off.
|
||||
#verbose
|
||||
|
||||
# Uncomment to run TURN server in 'extra' verbose mode.
|
||||
# This mode is very annoying and produces lots of output.
|
||||
# Not recommended under any normal circumstances.
|
||||
#
|
||||
#Verbose
|
||||
|
||||
# Uncomment to use fingerprints in the TURN messages.
|
||||
# By default the fingerprints are off.
|
||||
#
|
||||
#fingerprint
|
||||
|
||||
# Uncomment to use long-term credential mechanism.
|
||||
# By default no credentials mechanism is used (any user allowed).
|
||||
#
|
||||
#lt-cred-mech
|
||||
|
||||
# This option is opposite to lt-cred-mech.
|
||||
# (TURN Server with no-auth option allows anonymous access).
|
||||
# If neither option is defined, and no users are defined,
|
||||
# then no-auth is default. If at least one user is defined,
|
||||
# in this file or in command line or in usersdb file, then
|
||||
# lt-cred-mech is default.
|
||||
#
|
||||
#no-auth
|
||||
|
||||
# TURN REST API flag.
|
||||
# (Time Limited Long Term Credential)
|
||||
# Flag that sets a special authorization option that is based upon authentication secret.
|
||||
#
|
||||
# This feature's purpose is to support "TURN Server REST API", see
|
||||
# "TURN REST API" link in the project's page
|
||||
# https://github.com/coturn/coturn/
|
||||
#
|
||||
# This option is used with timestamp:
|
||||
#
|
||||
# usercombo -> "timestamp:userid"
|
||||
# turn user -> usercombo
|
||||
# turn password -> base64(hmac(secret key, usercombo))
|
||||
#
|
||||
# This allows TURN credentials to be accounted for a specific user id.
|
||||
# If you don't have a suitable id, the timestamp alone can be used.
|
||||
# This option is just turning on secret-based authentication.
|
||||
# The actual value of the secret is defined either by option static-auth-secret,
|
||||
# or can be found in the turn_secret table in the database (see below).
|
||||
#
|
||||
# Read more about it:
|
||||
# - https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00
|
||||
# - https://www.ietf.org/proceedings/87/slides/slides-87-behave-10.pdf
|
||||
#
|
||||
# Be aware that use-auth-secret overrides some part of lt-cred-mech.
|
||||
# Notice that this feature depends internally on lt-cred-mech, so if you set
|
||||
# use-auth-secret then it enables internally automatically lt-cred-mech option
|
||||
# like if you enable both.
|
||||
#
|
||||
# You can use only one of the to auth mechanisms in the same time because,
|
||||
# both mechanism use the username and password validation in different way.
|
||||
#
|
||||
# This way be aware that you can't use both auth mechnaism in the same time!
|
||||
# Use in config either the lt-cred-mech or the use-auth-secret
|
||||
# to avoid any confusion.
|
||||
#
|
||||
listening-port=3478
|
||||
fingerprint
|
||||
lt-cred-mech
|
||||
use-auth-secret
|
||||
|
||||
# 'Static' authentication secret value (a string) for TURN REST API only.
|
||||
# If not set, then the turn server
|
||||
# will try to use the 'dynamic' value in turn_secret table
|
||||
# in user database (if present). The database-stored value can be changed on-the-fly
|
||||
# by a separate program, so this is why that other mode is 'dynamic'.
|
||||
#
|
||||
#static-auth-secret=north
|
||||
|
||||
# Server name used for
|
||||
# the oAuth authentication purposes.
|
||||
# The default value is the realm name.
|
||||
#
|
||||
#server-name=blackdow.carleon.gov
|
||||
|
||||
# Flag that allows oAuth authentication.
|
||||
#
|
||||
#oauth
|
||||
|
||||
# 'Static' user accounts for long term credentials mechanism, only.
|
||||
# This option cannot be used with TURN REST API.
|
||||
# 'Static' user accounts are NOT dynamically checked by the turnserver process,
|
||||
# so that they can NOT be changed while the turnserver is running.
|
||||
#
|
||||
#user=username1:key1
|
||||
#user=username2:key2
|
||||
# OR:
|
||||
#user=username1:password1
|
||||
#user=username2:password2
|
||||
#
|
||||
# Keys must be generated by turnadmin utility. The key value depends
|
||||
# on user name, realm, and password:
|
||||
#
|
||||
# Example:
|
||||
# $ turnadmin -k -u ninefingers -r north.gov -p youhavetoberealistic
|
||||
# Output: 0xbc807ee29df3c9ffa736523fb2c4e8ee
|
||||
# ('0x' in the beginning of the key is what differentiates the key from
|
||||
# password. If it has 0x then it is a key, otherwise it is a password).
|
||||
#
|
||||
# The corresponding user account entry in the config file will be:
|
||||
#
|
||||
#user=ninefingers:0xbc807ee29df3c9ffa736523fb2c4e8ee
|
||||
# Or, equivalently, with open clear password (less secure):
|
||||
#user=ninefingers:youhavetoberealistic
|
||||
#
|
||||
|
||||
# SQLite database file name.
|
||||
#
|
||||
# Default file name is /var/db/turndb or /usr/local/var/db/turndb or
|
||||
# /var/lib/turn/turndb.
|
||||
#
|
||||
#userdb=/var/db/turndb
|
||||
|
||||
# PostgreSQL database connection string in the case that we are using PostgreSQL
|
||||
# as the user database.
|
||||
# This database can be used for long-term credential mechanism
|
||||
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
|
||||
# See http://www.postgresql.org/docs/8.4/static/libpq-connect.html for 8.x PostgreSQL
|
||||
# versions connection string format, see
|
||||
# http://www.postgresql.org/docs/9.2/static/libpq-connect.html#LIBPQ-CONNSTRING
|
||||
# for 9.x and newer connection string formats.
|
||||
#
|
||||
#psql-userdb="host=<host> dbname=<database-name> user=<database-user> password=<database-user-password> connect_timeout=30"
|
||||
|
||||
# MySQL database connection string in the case that we are using MySQL
|
||||
# as the user database.
|
||||
# This database can be used for long-term credential mechanism
|
||||
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
|
||||
#
|
||||
# Optional connection string parameters for the secure communications (SSL):
|
||||
# ca, capath, cert, key, cipher
|
||||
# (see http://dev.mysql.com/doc/refman/5.1/en/ssl-options.html for the
|
||||
# command options description).
|
||||
#
|
||||
# Use string format as below (space separated parameters, all optional):
|
||||
#
|
||||
#mysql-userdb="host=<host> dbname=<database-name> user=<database-user> password=<database-user-password> port=<port> connect_timeout=<seconds> read_timeout=<seconds>"
|
||||
|
||||
# If you want to use in the MySQL connection string the password in encrypted format,
|
||||
# then set in this option the MySQL password encryption secret key file.
|
||||
#
|
||||
# Warning: If this option is set, then mysql password must be set in "mysql-userdb" in encrypted format!
|
||||
# If you want to use cleartext password then do not set this option!
|
||||
#
|
||||
# This is the file path which contain secret key of aes encryption while using password encryption.
|
||||
#
|
||||
#secret-key-file=/path/
|
||||
|
||||
# MongoDB database connection string in the case that we are using MongoDB
|
||||
# as the user database.
|
||||
# This database can be used for long-term credential mechanism
|
||||
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
|
||||
# Use string format is described at http://hergert.me/docs/mongo-c-driver/mongoc_uri.html
|
||||
#
|
||||
#mongo-userdb="mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]"
|
||||
|
||||
# Redis database connection string in the case that we are using Redis
|
||||
# as the user database.
|
||||
# This database can be used for long-term credential mechanism
|
||||
# and it can store the secret value for secret-based timed authentication in TURN RESP API.
|
||||
# Use string format as below (space separated parameters, all optional):
|
||||
#
|
||||
#redis-userdb="ip=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
|
||||
|
||||
# Redis status and statistics database connection string, if used (default - empty, no Redis stats DB used).
|
||||
# This database keeps allocations status information, and it can be also used for publishing
|
||||
# and delivering traffic and allocation event notifications.
|
||||
# The connection string has the same parameters as redis-userdb connection string.
|
||||
# Use string format as below (space separated parameters, all optional):
|
||||
#
|
||||
#redis-statsdb="ip=<ip-address> dbname=<database-number> password=<database-user-password> port=<port> connect_timeout=<seconds>"
|
||||
|
||||
# The default realm to be used for the users when no explicit
|
||||
# origin/realm relationship was found in the database, or if the TURN
|
||||
# server is not using any database (just the commands-line settings
|
||||
# and the userdb file). Must be used with long-term credentials
|
||||
# mechanism or with TURN REST API.
|
||||
#
|
||||
# Note: If default realm is not specified at all, then realm falls back to the host domain name.
|
||||
# If domain name is empty string, or '(None)', then it is initialized to am empty string.
|
||||
#
|
||||
#realm=mycompany.org
|
||||
|
||||
# The flag that sets the origin consistency
|
||||
# check: across the session, all requests must have the same
|
||||
# main ORIGIN attribute value (if the ORIGIN was
|
||||
# initially used by the session).
|
||||
#
|
||||
#check-origin-consistency
|
||||
|
||||
# Per-user allocation quota.
|
||||
# default value is 0 (no quota, unlimited number of sessions per user).
|
||||
# This option can also be set through the database, for a particular realm.
|
||||
#
|
||||
#user-quota=0
|
||||
|
||||
# Total allocation quota.
|
||||
# default value is 0 (no quota).
|
||||
# This option can also be set through the database, for a particular realm.
|
||||
#
|
||||
#total-quota=0
|
||||
|
||||
# Max bytes-per-second bandwidth a TURN session is allowed to handle
|
||||
# (input and output network streams are treated separately). Anything above
|
||||
# that limit will be dropped or temporary suppressed (within
|
||||
# the available buffer limits).
|
||||
# This option can also be set through the database, for a particular realm.
|
||||
#
|
||||
#max-bps=0
|
||||
|
||||
#
|
||||
# Maximum server capacity.
|
||||
# Total bytes-per-second bandwidth the TURN server is allowed to allocate
|
||||
# for the sessions, combined (input and output network streams are treated separately).
|
||||
#
|
||||
# bps-capacity=0
|
||||
|
||||
# Uncomment if no UDP client listener is desired.
|
||||
# By default UDP client listener is always started.
|
||||
#
|
||||
#no-udp
|
||||
|
||||
# Uncomment if no TCP client listener is desired.
|
||||
# By default TCP client listener is always started.
|
||||
#
|
||||
#no-tcp
|
||||
|
||||
# Uncomment if no TLS client listener is desired.
|
||||
# By default TLS client listener is always started.
|
||||
#
|
||||
#no-tls
|
||||
|
||||
# Uncomment if no DTLS client listener is desired.
|
||||
# By default DTLS client listener is always started.
|
||||
#
|
||||
#no-dtls
|
||||
|
||||
# Uncomment if no UDP relay endpoints are allowed.
|
||||
# By default UDP relay endpoints are enabled (like in RFC 5766).
|
||||
#
|
||||
#no-udp-relay
|
||||
|
||||
# Uncomment if no TCP relay endpoints are allowed.
|
||||
# By default TCP relay endpoints are enabled (like in RFC 6062).
|
||||
#
|
||||
#no-tcp-relay
|
||||
|
||||
# Uncomment if extra security is desired,
|
||||
# with nonce value having limited lifetime.
|
||||
# By default, the nonce value is unique for a session,
|
||||
# and has unlimited lifetime.
|
||||
# Set this option to limit the nonce lifetime.
|
||||
# It defaults to 600 secs (10 min) if no value is provided. After that delay,
|
||||
# the client will get 438 error and will have to re-authenticate itself.
|
||||
#
|
||||
#stale-nonce=600
|
||||
|
||||
# Uncomment if you want to set the maximum allocation
|
||||
# time before it has to be refreshed.
|
||||
# Default is 3600s.
|
||||
#
|
||||
#max-allocate-lifetime=3600
|
||||
|
||||
|
||||
# Uncomment to set the lifetime for the channel.
|
||||
# Default value is 600 secs (10 minutes).
|
||||
# This value MUST not be changed for production purposes.
|
||||
#
|
||||
#channel-lifetime=600
|
||||
|
||||
# Uncomment to set the permission lifetime.
|
||||
# Default to 300 secs (5 minutes).
|
||||
# In production this value MUST not be changed,
|
||||
# however it can be useful for test purposes.
|
||||
#
|
||||
#permission-lifetime=300
|
||||
|
||||
# Certificate file.
|
||||
# Use an absolute path or path relative to the
|
||||
# configuration file.
|
||||
#
|
||||
#cert=/usr/local/etc/turn_server_cert.pem
|
||||
|
||||
# Private key file.
|
||||
# Use an absolute path or path relative to the
|
||||
# configuration file.
|
||||
# Use PEM file format.
|
||||
#
|
||||
#pkey=/usr/local/etc/turn_server_pkey.pem
|
||||
|
||||
# Private key file password, if it is in encoded format.
|
||||
# This option has no default value.
|
||||
#
|
||||
#pkey-pwd=...
|
||||
|
||||
# Allowed OpenSSL cipher list for TLS/DTLS connections.
|
||||
# Default value is "DEFAULT".
|
||||
#
|
||||
#cipher-list="DEFAULT"
|
||||
|
||||
# CA file in OpenSSL format.
|
||||
# Forces TURN server to verify the client SSL certificates.
|
||||
# By default it is not set: there is no default value and the client
|
||||
# certificate is not checked.
|
||||
#
|
||||
# Example:
|
||||
#CA-file=/etc/ssh/id_rsa.cert
|
||||
|
||||
# Curve name for EC ciphers, if supported by OpenSSL
|
||||
# library (TLS and DTLS). The default value is prime256v1,
|
||||
# if pre-OpenSSL 1.0.2 is used. With OpenSSL 1.0.2+,
|
||||
# an optimal curve will be automatically calculated, if not defined
|
||||
# by this option.
|
||||
#
|
||||
#ec-curve-name=prime256v1
|
||||
|
||||
# Use 566 bits predefined DH TLS key. Default size of the key is 1066.
|
||||
#
|
||||
#dh566
|
||||
|
||||
# Use 2066 bits predefined DH TLS key. Default size of the key is 1066.
|
||||
#
|
||||
#dh2066
|
||||
|
||||
# Use custom DH TLS key, stored in PEM format in the file.
|
||||
# Flags --dh566 and --dh2066 are ignored when the DH key is taken from a file.
|
||||
#
|
||||
#dh-file=<DH-PEM-file-name>
|
||||
|
||||
# Flag to prevent stdout log messages.
|
||||
# By default, all log messages are going to both stdout and to
|
||||
# the configured log file. With this option everything will be
|
||||
# going to the configured log only (unless the log file itself is stdout).
|
||||
#
|
||||
#no-stdout-log
|
||||
|
||||
# Option to set the log file name.
|
||||
# By default, the turnserver tries to open a log file in
|
||||
# /var/log, /var/tmp, /tmp and current directories directories
|
||||
# (which open operation succeeds first that file will be used).
|
||||
# With this option you can set the definite log file name.
|
||||
# The special names are "stdout" and "-" - they will force everything
|
||||
# to the stdout. Also, the "syslog" name will force everything to
|
||||
# the system log (syslog).
|
||||
# In the runtime, the logfile can be reset with the SIGHUP signal
|
||||
# to the turnserver process.
|
||||
#
|
||||
#log-file=/var/tmp/turn.log
|
||||
|
||||
# Option to redirect all log output into system log (syslog).
|
||||
#
|
||||
syslog
|
||||
|
||||
# This flag means that no log file rollover will be used, and the log file
|
||||
# name will be constructed as-is, without PID and date appendage.
|
||||
# This option can be used, for example, together with the logrotate tool.
|
||||
#
|
||||
#simple-log
|
||||
|
||||
# Option to set the "redirection" mode. The value of this option
|
||||
# will be the address of the alternate server for UDP & TCP service in form of
|
||||
# <ip>[:<port>]. The server will send this value in the attribute
|
||||
# ALTERNATE-SERVER, with error 300, on ALLOCATE request, to the client.
|
||||
# Client will receive only values with the same address family
|
||||
# as the client network endpoint address family.
|
||||
# See RFC 5389 and RFC 5766 for ALTERNATE-SERVER functionality description.
|
||||
# The client must use the obtained value for subsequent TURN communications.
|
||||
# If more than one --alternate-server options are provided, then the functionality
|
||||
# can be more accurately described as "load-balancing" than a mere "redirection".
|
||||
# If the port number is omitted, then the default port
|
||||
# number 3478 for the UDP/TCP protocols will be used.
|
||||
# Colon (:) characters in IPv6 addresses may conflict with the syntax of
|
||||
# the option. To alleviate this conflict, literal IPv6 addresses are enclosed
|
||||
# in square brackets in such resource identifiers, for example:
|
||||
# [2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478 .
|
||||
# Multiple alternate servers can be set. They will be used in the
|
||||
# round-robin manner. All servers in the pool are considered of equal weight and
|
||||
# the load will be distributed equally. For example, if we have 4 alternate servers,
|
||||
# then each server will receive 25% of ALLOCATE requests. A alternate TURN server
|
||||
# address can be used more than one time with the alternate-server option, so this
|
||||
# can emulate "weighting" of the servers.
|
||||
#
|
||||
# Examples:
|
||||
#alternate-server=1.2.3.4:5678
|
||||
#alternate-server=11.22.33.44:56789
|
||||
#alternate-server=5.6.7.8
|
||||
#alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478
|
||||
|
||||
# Option to set alternative server for TLS & DTLS services in form of
|
||||
# <ip>:<port>. If the port number is omitted, then the default port
|
||||
# number 5349 for the TLS/DTLS protocols will be used. See the previous
|
||||
# option for the functionality description.
|
||||
#
|
||||
# Examples:
|
||||
#tls-alternate-server=1.2.3.4:5678
|
||||
#tls-alternate-server=11.22.33.44:56789
|
||||
#tls-alternate-server=[2001:db8:85a3:8d3:1319:8a2e:370:7348]:3478
|
||||
|
||||
# Option to suppress TURN functionality, only STUN requests will be processed.
|
||||
# Run as STUN server only, all TURN requests will be ignored.
|
||||
# By default, this option is NOT set.
|
||||
#
|
||||
#stun-only
|
||||
|
||||
# Option to suppress STUN functionality, only TURN requests will be processed.
|
||||
# Run as TURN server only, all STUN requests will be ignored.
|
||||
# By default, this option is NOT set.
|
||||
#
|
||||
#no-stun
|
||||
|
||||
# This is the timestamp/username separator symbol (character) in TURN REST API.
|
||||
# The default value is ':'.
|
||||
# rest-api-separator=:
|
||||
|
||||
# Flag that can be used to allow peers on the loopback addresses (127.x.x.x and ::1).
|
||||
# This is an extra security measure.
|
||||
#
|
||||
# (To avoid any security issue that allowing loopback access may raise,
|
||||
# the no-loopback-peers option is replaced by allow-loopback-peers.)
|
||||
#
|
||||
# Allow it only for testing in a development environment!
|
||||
# In production it adds a possible security vulnerability, so for security reasons
|
||||
# it is not allowed using it together with empty cli-password.
|
||||
#
|
||||
#allow-loopback-peers
|
||||
|
||||
# Flag that can be used to disallow peers on well-known broadcast addresses (224.0.0.0 and above, and FFXX:*).
|
||||
# This is an extra security measure.
|
||||
#
|
||||
#no-multicast-peers
|
||||
|
||||
# Option to set the max time, in seconds, allowed for full allocation establishment.
|
||||
# Default is 60 seconds.
|
||||
#
|
||||
#max-allocate-timeout=60
|
||||
|
||||
# Option to allow or ban specific ip addresses or ranges of ip addresses.
|
||||
# If an ip address is specified as both allowed and denied, then the ip address is
|
||||
# considered to be allowed. This is useful when you wish to ban a range of ip
|
||||
# addresses, except for a few specific ips within that range.
|
||||
#
|
||||
# This can be used when you do not want users of the turn server to be able to access
|
||||
# machines reachable by the turn server, but would otherwise be unreachable from the
|
||||
# internet (e.g. when the turn server is sitting behind a NAT)
|
||||
#
|
||||
# Examples:
|
||||
# denied-peer-ip=83.166.64.0-83.166.95.255
|
||||
# allowed-peer-ip=83.166.68.45
|
||||
|
||||
# File name to store the pid of the process.
|
||||
# Default is /var/run/turnserver.pid (if superuser account is used) or
|
||||
# /var/tmp/turnserver.pid .
|
||||
#
|
||||
#pidfile="/var/run/turnserver.pid"
|
||||
|
||||
# Require authentication of the STUN Binding request.
|
||||
# By default, the clients are allowed anonymous access to the STUN Binding functionality.
|
||||
#
|
||||
#secure-stun
|
||||
|
||||
# Mobility with ICE (MICE) specs support.
|
||||
#
|
||||
#mobility
|
||||
|
||||
# Allocate Address Family according
|
||||
# If enabled then TURN server allocates address family according the TURN
|
||||
# Client <=> Server communication address family.
|
||||
# (By default coTURN works according RFC 6156.)
|
||||
# !!Warning: Enabling this option breaks RFC6156 section-4.2 (violates use default IPv4)!!
|
||||
#
|
||||
#keep-address-family
|
||||
|
||||
|
||||
# User name to run the process. After the initialization, the turnserver process
|
||||
# will make an attempt to change the current user ID to that user.
|
||||
#
|
||||
#proc-user=<user-name>
|
||||
|
||||
# Group name to run the process. After the initialization, the turnserver process
|
||||
# will make an attempt to change the current group ID to that group.
|
||||
#
|
||||
#proc-group=<group-name>
|
||||
|
||||
# Turn OFF the CLI support.
|
||||
# By default it is always ON.
|
||||
# See also options cli-ip and cli-port.
|
||||
#
|
||||
#no-cli
|
||||
|
||||
#Local system IP address to be used for CLI server endpoint. Default value
|
||||
# is 127.0.0.1.
|
||||
#
|
||||
#cli-ip=127.0.0.1
|
||||
|
||||
# CLI server port. Default is 5766.
|
||||
#
|
||||
#cli-port=5766
|
||||
|
||||
# CLI access password. Default is empty (no password).
|
||||
# For the security reasons, it is recommended to use the encrypted
|
||||
# for of the password (see the -P command in the turnadmin utility).
|
||||
#
|
||||
# Secure form for password 'qwerty':
|
||||
#
|
||||
#cli-password=$5$79a316b350311570$81df9cfb9af7f5e5a76eada31e7097b663a0670f99a3c07ded3f1c8e59c5658a
|
||||
#
|
||||
# Or unsecure form for the same password:
|
||||
#
|
||||
#cli-password=qwerty
|
||||
|
||||
# Enable Web-admin support on https. By default it is Disabled.
|
||||
# If it is enabled it also enables a http a simple static banner page
|
||||
# with a small reminder that the admin page is available only on https.
|
||||
#
|
||||
#web-admin
|
||||
|
||||
# Local system IP address to be used for Web-admin server endpoint. Default value is 127.0.0.1.
|
||||
#
|
||||
#web-admin-ip=127.0.0.1
|
||||
|
||||
# Web-admin server port. Default is 8080.
|
||||
#
|
||||
#web-admin-port=8080
|
||||
|
||||
# Web-admin server listen on STUN/TURN worker threads
|
||||
# By default it is disabled for security resons! (Not recommended in any production environment!)
|
||||
#
|
||||
#web-admin-listen-on-workers
|
||||
|
||||
# Server relay. NON-STANDARD AND DANGEROUS OPTION.
|
||||
# Only for those applications when we want to run
|
||||
# server applications on the relay endpoints.
|
||||
# This option eliminates the IP permissions check on
|
||||
# the packets incoming to the relay endpoints.
|
||||
#
|
||||
#server-relay
|
||||
|
||||
# Maximum number of output sessions in ps CLI command.
|
||||
# This value can be changed on-the-fly in CLI. The default value is 256.
|
||||
#
|
||||
#cli-max-output-sessions
|
||||
|
||||
# Set network engine type for the process (for internal purposes).
|
||||
#
|
||||
#ne=[1|2|3]
|
||||
|
||||
# Do not allow an TLS/DTLS version of protocol
|
||||
#
|
||||
#no-tlsv1
|
||||
#no-tlsv1_1
|
||||
#no-tlsv1_2
|
||||
static-auth-secret=<secret-on-server-only>
|
||||
static-auth-secret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106
|
||||
realm=turn1.shineup.me
|
||||
total-quota=200
|
||||
stale-nonce=600
|
||||
no-multicast-peers
|
||||
no-loopback-peers
|
||||
no-cli
|
||||
simple-log
|
||||
external-ip=178.208.64.62
|
||||
listening-ip=0.0.0.0
|
||||
relay-ip=178.208.64.62
|
||||
min-port=49000
|
||||
max-port=53999
|
||||
|
||||
@@ -5,9 +5,21 @@ REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
|
||||
SCHEME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CAP_DIR="${SCHEME_DIR}/captures"
|
||||
CFG_DIR="${SCHEME_DIR}/configs"
|
||||
RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync")
|
||||
|
||||
mkdir -p "${CAP_DIR}" "${CFG_DIR}/systemd"
|
||||
|
||||
copy_optional_file() {
|
||||
local remote_path="$1"
|
||||
local local_path="$2"
|
||||
if ssh "${REMOTE_HOST}" "test -f '$remote_path'"; then
|
||||
rsync -a "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:${remote_path}" "${local_path}"
|
||||
else
|
||||
mkdir -p "$(dirname "${local_path}")"
|
||||
echo "missing: ${remote_path}" > "${local_path}.MISSING.txt"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[1/3] Обновляю инвентарь"
|
||||
ssh "${REMOTE_HOST}" 'hostnamectl --static; echo ---; uname -a; echo ---; df -h; echo ---; lsblk -f' > "${CAP_DIR}/01_host_disk.txt"
|
||||
ssh "${REMOTE_HOST}" 'systemctl list-unit-files --type=service --state=enabled --no-pager' > "${CAP_DIR}/02_enabled_services.txt"
|
||||
@@ -18,10 +30,14 @@ ssh "${REMOTE_HOST}" 'du -sh /home/player/* 2>/dev/null | sort -h' > "${CAP_DIR}
|
||||
ssh "${REMOTE_HOST}" 'sudo -n du -xhd1 /var 2>/dev/null | sort -h' > "${CAP_DIR}/07_var_sizes.txt"
|
||||
|
||||
echo "[2/3] Обновляю ключевые конфиги"
|
||||
rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
|
||||
rsync -a "${REMOTE_HOST}:/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf"
|
||||
rsync -a "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/"
|
||||
rsync -a "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/"
|
||||
if ssh "${REMOTE_HOST}" 'test -f /etc/caddy/Caddyfile'; then
|
||||
rsync -a "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
|
||||
else
|
||||
rsync -a "${REMOTE_HOST}:/home/player/SHiNE/caddy/Caddyfile" "${CFG_DIR}/Caddyfile"
|
||||
fi
|
||||
copy_optional_file "/etc/turnserver.conf" "${CFG_DIR}/turnserver.conf"
|
||||
copy_optional_file "/etc/systemd/system/shine-server.service" "${CFG_DIR}/systemd/shine-server.service"
|
||||
copy_optional_file "/etc/systemd/system/agent-memory.service" "${CFG_DIR}/systemd/agent-memory.service"
|
||||
|
||||
echo "[3/3] Метка обновления"
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "${CAP_DIR}/UPDATED_AT_UTC.txt"
|
||||
|
||||
@@ -9,6 +9,7 @@ DEPLOY_SERVER_LOGIN="server2shineupme" \
|
||||
DEPLOY_SERVER_ADDRESS="server2.shineup.me" \
|
||||
DEPLOY_SOLANA_CLUSTER="mainnet" \
|
||||
DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
|
||||
DEPLOY_WEBPUSH_VAPID_PUBLIC="BGI4ZG5ADm8MrUG0EQi2G0ZxoqTbZZyfUOzt3O-HrM6z4-zFPUpGGIGcA7Tx3N-O0r5Pie0ndGSdx6kRU2X2x4g" \
|
||||
TARGET_URL="https://server2.shineup.me" \
|
||||
REQUIRE_BACKUP="1" \
|
||||
bash "$(dirname "$0")/deploy_ui.sh"
|
||||
|
||||
@@ -9,6 +9,7 @@ DEPLOY_SERVER_LOGIN="shineupme" \
|
||||
DEPLOY_SERVER_ADDRESS="shineup.me" \
|
||||
DEPLOY_SOLANA_CLUSTER="mainnet" \
|
||||
DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
|
||||
DEPLOY_WEBPUSH_VAPID_PUBLIC="BEX12-68cSJVYvWSlwgLpd8TqITZrxxh8n20EulUK0OK4u5uWCsuu-iam86zVGYoZJTl1QKxlikGYhB-GLKSsxQ" \
|
||||
TARGET_URL="https://shineup.me" \
|
||||
REQUIRE_BACKUP="1" \
|
||||
bash "$(dirname "$0")/deploy_ui.sh"
|
||||
|
||||
@@ -88,6 +88,10 @@
|
||||
- `limit_exceeded`
|
||||
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
|
||||
- `repost_disabled` — репосты временно отключены до будущей реализации
|
||||
- `entrypoint_edit_forbidden` — `TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST`
|
||||
- `status_confirmed_target_must_be_status_action` — `STATUS_CONFIRMED` должен ссылаться на статусный блок
|
||||
- `status_action_target_not_allowed` — выбранный `STATUS_ACTION` нельзя ставить на этот тип материала
|
||||
- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
|
||||
- `internal_error`
|
||||
|
||||
## 5. Какие блоки реально можно добавлять через `AddBlock`
|
||||
@@ -103,7 +107,13 @@
|
||||
- `TEXT_EDIT_POST (11)`
|
||||
- `TEXT_REPLY (20)`
|
||||
- `TEXT_EDIT_REPLY (21)`
|
||||
- `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
|
||||
- `TEXT_RATING (30)` — target-based отзыв на конкретный блок
|
||||
- `TEXT_REPOST (50)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
|
||||
- `TEXT_CHANNEL_META (90)` — скрытый технический снимок профиля канала
|
||||
- `TEXT_ENTRYPOINT (100)` — входная страница канала
|
||||
- `TEXT_EXERCISE (110)` — line-based материал упражнения
|
||||
- `TEXT_SERVICE (120)` — line-based материал услуги / процедуры
|
||||
- `TEXT_COURSE (130)` — line-based материал курса
|
||||
|
||||
3. **REACTION (type=2)**
|
||||
- `REACTION_LIKE (1)`
|
||||
@@ -133,7 +143,68 @@
|
||||
5. **USER_PARAM (type=4)**
|
||||
- `USER_PARAM_TEXT_TEXT (1)`
|
||||
|
||||
## 6. Хватает ли функций сейчас
|
||||
6. **STATUS_ACTION (type=5)**
|
||||
- `STATUS_DONE_ONCE (10)`
|
||||
- `STATUS_LEARNED (20)`
|
||||
- `STATUS_SERVICE_PASSED (30)`
|
||||
- `STATUS_CONFIRMED (100)`
|
||||
- `STATUS_INTERESTED (110)`
|
||||
- `STATUS_STARTED (120)`
|
||||
- `STATUS_IN_STUDY (130)`
|
||||
- `STATUS_ABANDONED (140)`
|
||||
- `STATUS_COMPLETED (150)`
|
||||
|
||||
## 6. Практические payload-форматы для каналов и вложений
|
||||
|
||||
`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата.
|
||||
|
||||
### Вложения в сообщениях
|
||||
|
||||
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `S:att v=1`.
|
||||
|
||||
Пример текстового содержимого body:
|
||||
|
||||
```text
|
||||
<S:att;v=1;nm=photo.jpg;sz=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||
<S:att;v=1;nm=report.pdf;sz=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||
Текст сообщения
|
||||
```
|
||||
|
||||
Пример вложения с отдельным preview-файлом:
|
||||
|
||||
```text
|
||||
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
||||
```
|
||||
|
||||
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
|
||||
|
||||
### Создание публичного канала с профилем
|
||||
|
||||
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
|
||||
|
||||
```text
|
||||
<S:title;v=1;Человекочитаемое имя канала>
|
||||
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||
Описание канала
|
||||
```
|
||||
|
||||
Ограничение `channelDescription` — до `2048` UTF-8 байт. Новый клиент не пишет отдельный `TEXT_CHANNEL_META` сразу после создания канала: создание канала и начальный профиль должны попадать в один `TECH_CREATE_CHANNEL`.
|
||||
|
||||
### Изменение профиля канала
|
||||
|
||||
Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`.
|
||||
|
||||
Текстовое содержимое body использует тот же формат полного снимка профиля:
|
||||
|
||||
```text
|
||||
<S:title;v=1;Новое имя канала>
|
||||
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||
Новое описание канала
|
||||
```
|
||||
|
||||
Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `S:ava` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`.
|
||||
|
||||
## 7. Хватает ли функций сейчас
|
||||
|
||||
Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**.
|
||||
|
||||
@@ -147,7 +218,7 @@
|
||||
- нет API списка подписок с серверными счётчиками непрочитанного;
|
||||
- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC.
|
||||
|
||||
## 7. Рекомендации по клиенту при записи блоков
|
||||
## 8. Рекомендации по клиенту при записи блоков
|
||||
|
||||
1. Перед отправкой держать локальный `lastNumber/lastHash`.
|
||||
2. При `bad_prev_hash` или `bad_block_number`:
|
||||
@@ -157,7 +228,7 @@
|
||||
4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост.
|
||||
|
||||
|
||||
## 8. USER_PARAM для «личных данных»
|
||||
## 9. USER_PARAM для «личных данных»
|
||||
|
||||
Да, на текущем API это можно добавить **без изменения серверного кода**:
|
||||
|
||||
@@ -183,7 +254,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 9. `GetBlockchainBlock`
|
||||
## 10. `GetBlockchainBlock`
|
||||
|
||||
### Назначение
|
||||
|
||||
|
||||
@@ -297,6 +297,7 @@
|
||||
Первый целевой сценарий:
|
||||
|
||||
- `remote AddBlock via homeserver session`
|
||||
- межсессионная доставка звонковых `call_*` сигналов между устройствами пользователя
|
||||
|
||||
То есть телефон без локального `blockchain.key` может:
|
||||
|
||||
@@ -336,6 +337,12 @@
|
||||
- запрос должен быть подписан и `session key`, и `client key`;
|
||||
- в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой.
|
||||
|
||||
Для звонковых сигналов `signalType = call_*` правило строже:
|
||||
|
||||
- `clientSignatureB64` обязателен;
|
||||
- сервер отклоняет такой `SendSignal`, если `client key` подпись не передана;
|
||||
- это правило действует и для `single_session`, и для `all_sessions`.
|
||||
|
||||
### Запрос в одну сессию
|
||||
|
||||
```json
|
||||
@@ -366,11 +373,20 @@
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"deliveredCount": 1,
|
||||
"deliveredSessionIds": ["sess-hs-001"]
|
||||
"deliveredSessionIds": ["sess-hs-001"],
|
||||
"deliveredWsSessions": 1,
|
||||
"deliveredFcmSessions": 0,
|
||||
"deliveredWebPushSessions": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Для `call_invite` с `targetMode = "all_sessions"` сервер может дополнительно отправить offline web-push в те сессии адресата, которые сейчас не подключены по WebSocket. В таком случае:
|
||||
|
||||
- `deliveredWsSessions` — сколько сессий получили `IncomingSignal` по WebSocket;
|
||||
- `deliveredWebPushSessions` — сколько offline-сессий получили web-push;
|
||||
- `deliveredFcmSessions` пока дублирует тот же счётчик push-доставки и зарезервирован под отдельные push-каналы.
|
||||
|
||||
### Событие на принимающей стороне
|
||||
|
||||
```json
|
||||
@@ -428,6 +444,7 @@
|
||||
- `404 / USER_NOT_FOUND` — логин адресата не найден.
|
||||
- `400 / BAD_DATA` — сервер не смог обработать `data`.
|
||||
- `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`.
|
||||
- `400 / CLIENT_SIGNATURE_REQUIRED` — для `call_*` сигнала не передана обязательная подпись `client key`.
|
||||
- `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`.
|
||||
- `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн.
|
||||
- `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user