SHA256
НЕ ПРОВЕРЕНО: DM-вложения, upload файлов и ревизии личных сообщений
This commit is contained in:
@@ -618,6 +618,7 @@ public final class DatabaseInitializer {
|
||||
time_ms INTEGER NOT NULL,
|
||||
nonce INTEGER NOT NULL,
|
||||
message_type INTEGER NOT NULL,
|
||||
revision_time_ms INTEGER NOT NULL DEFAULT 0,
|
||||
raw_block BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
source_api TEXT NOT NULL,
|
||||
@@ -639,6 +640,36 @@ public final class DatabaseInitializer {
|
||||
ON signed_messages_v2 (base_key, message_type);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS dm_files (
|
||||
file_hash_sha256 BLOB NOT NULL PRIMARY KEY,
|
||||
file_size INTEGER NOT NULL,
|
||||
ref_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS dm_message_file_links (
|
||||
message_key TEXT NOT NULL,
|
||||
login TEXT NOT NULL,
|
||||
file_hash_sha256 BLOB NOT NULL,
|
||||
PRIMARY KEY (message_key, login, file_hash_sha256),
|
||||
FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key),
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (file_hash_sha256) REFERENCES dm_files(file_hash_sha256)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_login
|
||||
ON dm_message_file_links (login, file_hash_sha256);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_message
|
||||
ON dm_message_file_links (message_key);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming
|
||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||
|
||||
@@ -14,7 +14,7 @@ import java.sql.Statement;
|
||||
public final class SqliteDbController {
|
||||
|
||||
private static volatile SqliteDbController instance;
|
||||
private static final int LATEST_SCHEMA_VERSION = 5;
|
||||
private static final int LATEST_SCHEMA_VERSION = 6;
|
||||
|
||||
private final String jdbcUrl;
|
||||
|
||||
@@ -88,6 +88,7 @@ public final class SqliteDbController {
|
||||
case 3 -> migrateToV3();
|
||||
case 4 -> migrateToV4();
|
||||
case 5 -> migrateToV5();
|
||||
case 6 -> migrateToV6();
|
||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||
}
|
||||
}
|
||||
@@ -209,6 +210,26 @@ public final class SqliteDbController {
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV6() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureSignedMessagesRevisionColumn(c, st);
|
||||
ensureDmFileTables(st);
|
||||
setSchemaVersion(c, 6);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v6 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v6 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||
@@ -329,6 +350,45 @@ public final class SqliteDbController {
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureSignedMessagesRevisionColumn(Connection c, Statement st) throws SQLException {
|
||||
if (!tableExists(c, "signed_messages_v2")) return;
|
||||
if (!columnExists(c, "signed_messages_v2", "revision_time_ms")) {
|
||||
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN revision_time_ms INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureDmFileTables(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS dm_files (
|
||||
file_hash_sha256 BLOB NOT NULL PRIMARY KEY,
|
||||
file_size INTEGER NOT NULL,
|
||||
ref_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS dm_message_file_links (
|
||||
message_key TEXT NOT NULL,
|
||||
login TEXT NOT NULL,
|
||||
file_hash_sha256 BLOB NOT NULL,
|
||||
PRIMARY KEY (message_key, login, file_hash_sha256),
|
||||
FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key),
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (file_hash_sha256) REFERENCES dm_files(file_hash_sha256)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_login
|
||||
ON dm_message_file_links (login, file_hash_sha256);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_message
|
||||
ON dm_message_file_links (message_key);
|
||||
""");
|
||||
}
|
||||
|
||||
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
|
||||
try (Statement probe = c.createStatement();
|
||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.DmFileRef;
|
||||
import shine.db.entities.SignedMessageV2Entry;
|
||||
|
||||
import java.sql.Connection;
|
||||
@@ -8,7 +9,10 @@ import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class SignedMessagesV2DAO {
|
||||
private static volatile SignedMessagesV2DAO instance;
|
||||
@@ -30,35 +34,19 @@ public final class SignedMessagesV2DAO {
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, raw_block, created_at_ms,
|
||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getMessageKey());
|
||||
ps.setString(2, e.getBaseKey());
|
||||
ps.setString(3, e.getTargetLogin());
|
||||
ps.setString(4, e.getFromLogin());
|
||||
ps.setString(5, e.getToLogin());
|
||||
ps.setLong(6, e.getTimeMs());
|
||||
ps.setLong(7, e.getNonce());
|
||||
ps.setInt(8, e.getMessageType());
|
||||
ps.setBytes(9, e.getRawBlock());
|
||||
ps.setLong(10, e.getCreatedAtMs());
|
||||
ps.setString(11, e.getSourceApi());
|
||||
ps.setString(12, e.getOriginSessionId());
|
||||
ps.setString(13, e.getReceiptRefBaseKey());
|
||||
if (e.getReceiptRefType() == null) ps.setObject(14, null);
|
||||
else ps.setInt(14, e.getReceiptRefType());
|
||||
bindSignedMessage(ps, e);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Атомарная вставка пары блоков: либо вставляются оба, либо не вставляется ни один.
|
||||
* Возвращает true только если обе записи добавлены в БД.
|
||||
* Если хотя бы одна запись уже существует (или конфликтует по уникальности), возвращает false.
|
||||
* Атомарная вставка пары блоков legacy/read-receipt: либо вставляются оба, либо не вставляется ни один.
|
||||
*/
|
||||
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
@@ -85,37 +73,97 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
}
|
||||
|
||||
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getMessageKey());
|
||||
ps.setString(2, e.getBaseKey());
|
||||
ps.setString(3, e.getTargetLogin());
|
||||
ps.setString(4, e.getFromLogin());
|
||||
ps.setString(5, e.getToLogin());
|
||||
ps.setLong(6, e.getTimeMs());
|
||||
ps.setLong(7, e.getNonce());
|
||||
ps.setInt(8, e.getMessageType());
|
||||
ps.setBytes(9, e.getRawBlock());
|
||||
ps.setLong(10, e.getCreatedAtMs());
|
||||
ps.setString(11, e.getSourceApi());
|
||||
ps.setString(12, e.getOriginSessionId());
|
||||
ps.setString(13, e.getReceiptRefBaseKey());
|
||||
if (e.getReceiptRefType() == null) ps.setObject(14, null);
|
||||
else ps.setInt(14, e.getReceiptRefType());
|
||||
return ps.executeUpdate();
|
||||
/**
|
||||
* Атомарный upsert пары контентных DM с полной заменой файловых связей.
|
||||
* Возвращает true, только если ревизия применена. Более старая или идентичная ревизия игнорируется.
|
||||
*/
|
||||
public boolean upsertContentPairReplaceFiles(
|
||||
SignedMessageV2Entry incoming,
|
||||
List<DmFileRef> incomingFiles,
|
||||
SignedMessageV2Entry outgoing,
|
||||
List<DmFileRef> outgoingFiles
|
||||
) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (!allFilesExist(c, incomingFiles) || !allFilesExist(c, outgoingFiles)) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey());
|
||||
Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey());
|
||||
long currentRevision = Math.max(
|
||||
currentIncomingRevision != null ? currentIncomingRevision : Long.MIN_VALUE,
|
||||
currentOutgoingRevision != null ? currentOutgoingRevision : Long.MIN_VALUE
|
||||
);
|
||||
long nextRevision = incoming.getRevisionTimeMs();
|
||||
|
||||
if (currentRevision != Long.MIN_VALUE && nextRevision < currentRevision) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
if (currentRevision != Long.MIN_VALUE
|
||||
&& nextRevision == currentRevision
|
||||
&& hasSameRawBlock(c, incoming)
|
||||
&& hasSameRawBlock(c, outgoing)) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
replaceFileLinks(c, incoming.getMessageKey(), incoming.getTargetLogin(), incomingFiles);
|
||||
replaceFileLinks(c, outgoing.getMessageKey(), outgoing.getTargetLogin(), outgoingFiles);
|
||||
|
||||
upsertMessage(c, incoming);
|
||||
upsertMessage(c, outgoing);
|
||||
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isConstraintViolation(SQLException ex) {
|
||||
String msg = String.valueOf(ex.getMessage()).toLowerCase();
|
||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||
public boolean fileExists(byte[] fileHash, long fileSize) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return fileExists(c, fileHash, fileSize);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean fileExistsByHash(byte[] fileHash) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = "SELECT 1 FROM dm_files WHERE file_hash_sha256 = ? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setBytes(1, fileHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void registerFileIfAbsent(byte[] fileHash, long fileSize) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO dm_files (file_hash_sha256, file_size, ref_count)
|
||||
VALUES (?, ?, 0)
|
||||
ON CONFLICT(file_hash_sha256) DO UPDATE SET
|
||||
file_size = excluded.file_size
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setBytes(1, fileHash);
|
||||
ps.setLong(2, fileSize);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
||||
@@ -123,7 +171,7 @@ public final class SignedMessagesV2DAO {
|
||||
String sql = """
|
||||
SELECT
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, raw_block, created_at_ms,
|
||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
FROM signed_messages_v2
|
||||
WHERE message_key = ?
|
||||
@@ -203,13 +251,13 @@ public final class SignedMessagesV2DAO {
|
||||
String sql = """
|
||||
SELECT
|
||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
||||
m.time_ms, m.nonce, m.message_type, m.raw_block, m.created_at_ms,
|
||||
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.raw_block, m.created_at_ms,
|
||||
m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type
|
||||
FROM signed_messages_v2 m
|
||||
JOIN signed_message_session_delivery d
|
||||
ON d.message_key = m.message_key
|
||||
WHERE d.session_id = ? AND d.delivered = 0
|
||||
ORDER BY m.time_ms ASC, m.created_at_ms ASC
|
||||
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.created_at_ms ASC
|
||||
""";
|
||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
@@ -222,6 +270,206 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
}
|
||||
|
||||
private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(message_key) DO UPDATE SET
|
||||
base_key = excluded.base_key,
|
||||
target_login = excluded.target_login,
|
||||
from_login = excluded.from_login,
|
||||
to_login = excluded.to_login,
|
||||
time_ms = excluded.time_ms,
|
||||
nonce = excluded.nonce,
|
||||
message_type = excluded.message_type,
|
||||
revision_time_ms = excluded.revision_time_ms,
|
||||
raw_block = excluded.raw_block,
|
||||
created_at_ms = excluded.created_at_ms,
|
||||
source_api = excluded.source_api,
|
||||
origin_session_id = excluded.origin_session_id,
|
||||
receipt_ref_base_key = excluded.receipt_ref_base_key,
|
||||
receipt_ref_type = excluded.receipt_ref_type
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void replaceFileLinks(Connection c, String messageKey, String login, List<DmFileRef> nextFiles) throws SQLException {
|
||||
List<byte[]> oldHashes = listLinkedFileHashes(c, messageKey, login);
|
||||
for (byte[] oldHash : oldHashes) {
|
||||
adjustRefCount(c, oldHash, -1);
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
DELETE FROM dm_message_file_links
|
||||
WHERE message_key = ? AND login = ? COLLATE NOCASE
|
||||
""")) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, login);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
if (nextFiles == null || nextFiles.isEmpty()) return;
|
||||
|
||||
Set<String> dedup = new HashSet<>();
|
||||
for (DmFileRef ref : nextFiles) {
|
||||
if (ref == null || ref.getFileHash() == null) continue;
|
||||
String dedupKey = Arrays.toString(ref.getFileHash());
|
||||
if (!dedup.add(dedupKey)) continue;
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT OR IGNORE INTO dm_message_file_links (
|
||||
message_key, login, file_hash_sha256
|
||||
) VALUES (?, ?, ?)
|
||||
""")) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, login);
|
||||
ps.setBytes(3, ref.getFileHash());
|
||||
int inserted = ps.executeUpdate();
|
||||
if (inserted > 0) {
|
||||
adjustRefCount(c, ref.getFileHash(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<byte[]> listLinkedFileHashes(Connection c, String messageKey, String login) throws SQLException {
|
||||
String sql = """
|
||||
SELECT file_hash_sha256
|
||||
FROM dm_message_file_links
|
||||
WHERE message_key = ? AND login = ? COLLATE NOCASE
|
||||
""";
|
||||
List<byte[]> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
out.add(rs.getBytes(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void adjustRefCount(Connection c, byte[] fileHash, int delta) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE dm_files
|
||||
SET ref_count = CASE
|
||||
WHEN ref_count + ? < 0 THEN 0
|
||||
ELSE ref_count + ?
|
||||
END
|
||||
WHERE file_hash_sha256 = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setInt(1, delta);
|
||||
ps.setInt(2, delta);
|
||||
ps.setBytes(3, fileHash);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allFilesExist(Connection c, List<DmFileRef> refs) throws SQLException {
|
||||
if (refs == null) return true;
|
||||
for (DmFileRef ref : refs) {
|
||||
if (ref == null || ref.getFileHash() == null) return false;
|
||||
if (!fileExists(c, ref.getFileHash(), ref.getFileSize())) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean fileExists(Connection c, byte[] fileHash, long fileSize) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM dm_files
|
||||
WHERE file_hash_sha256 = ? AND file_size = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setBytes(1, fileHash);
|
||||
ps.setLong(2, fileSize);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Long getRevisionTimeMs(Connection c, String messageKey) throws SQLException {
|
||||
String sql = "SELECT revision_time_ms FROM signed_messages_v2 WHERE message_key = ? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasSameRawBlock(Connection c, SignedMessageV2Entry entry) throws SQLException {
|
||||
String sql = "SELECT raw_block FROM signed_messages_v2 WHERE message_key = ? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getMessageKey());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return false;
|
||||
return Arrays.equals(rs.getBytes(1), entry.getRawBlock());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void resetDeliveryRows(Connection c, String messageKey) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE signed_message_session_delivery
|
||||
SET delivered = 0, delivered_at_ms = NULL
|
||||
WHERE message_key = ?
|
||||
""")) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void bindSignedMessage(PreparedStatement ps, SignedMessageV2Entry e) throws SQLException {
|
||||
ps.setString(1, e.getMessageKey());
|
||||
ps.setString(2, e.getBaseKey());
|
||||
ps.setString(3, e.getTargetLogin());
|
||||
ps.setString(4, e.getFromLogin());
|
||||
ps.setString(5, e.getToLogin());
|
||||
ps.setLong(6, e.getTimeMs());
|
||||
ps.setLong(7, e.getNonce());
|
||||
ps.setInt(8, e.getMessageType());
|
||||
ps.setLong(9, e.getRevisionTimeMs());
|
||||
ps.setBytes(10, e.getRawBlock());
|
||||
ps.setLong(11, e.getCreatedAtMs());
|
||||
ps.setString(12, e.getSourceApi());
|
||||
ps.setString(13, e.getOriginSessionId());
|
||||
ps.setString(14, e.getReceiptRefBaseKey());
|
||||
if (e.getReceiptRefType() == null) ps.setObject(15, null);
|
||||
else ps.setInt(15, e.getReceiptRefType());
|
||||
}
|
||||
|
||||
private boolean isConstraintViolation(SQLException ex) {
|
||||
String msg = String.valueOf(ex.getMessage()).toLowerCase();
|
||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||
}
|
||||
|
||||
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
|
||||
SignedMessageV2Entry e = new SignedMessageV2Entry();
|
||||
e.setMessageKey(rs.getString("message_key"));
|
||||
@@ -232,6 +480,7 @@ public final class SignedMessagesV2DAO {
|
||||
e.setTimeMs(rs.getLong("time_ms"));
|
||||
e.setNonce(rs.getLong("nonce"));
|
||||
e.setMessageType(rs.getInt("message_type"));
|
||||
e.setRevisionTimeMs(rs.getLong("revision_time_ms"));
|
||||
e.setRawBlock(rs.getBytes("raw_block"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setSourceApi(rs.getString("source_api"));
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DmFileRef {
|
||||
private byte[] fileHash;
|
||||
private long fileSize;
|
||||
|
||||
public DmFileRef() {
|
||||
}
|
||||
|
||||
public DmFileRef(byte[] fileHash, long fileSize) {
|
||||
this.fileHash = fileHash;
|
||||
this.fileSize = fileSize;
|
||||
}
|
||||
|
||||
public byte[] getFileHash() { return fileHash; }
|
||||
public void setFileHash(byte[] fileHash) { this.fileHash = fileHash; }
|
||||
public long getFileSize() { return fileSize; }
|
||||
public void setFileSize(long fileSize) { this.fileSize = fileSize; }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ public class SignedMessageV2Entry {
|
||||
private long timeMs;
|
||||
private long nonce;
|
||||
private int messageType;
|
||||
private long revisionTimeMs;
|
||||
private byte[] rawBlock;
|
||||
private long createdAtMs;
|
||||
private String sourceApi;
|
||||
@@ -32,6 +33,8 @@ public class SignedMessageV2Entry {
|
||||
public void setNonce(long nonce) { this.nonce = nonce; }
|
||||
public int getMessageType() { return messageType; }
|
||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||
public long getRevisionTimeMs() { return revisionTimeMs; }
|
||||
public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; }
|
||||
public byte[] getRawBlock() { return rawBlock; }
|
||||
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
|
||||
public final class DmFileStorage {
|
||||
private static final String CFG_FILES_DIR = "dm.files.dir";
|
||||
private static final String CFG_MAX_UPLOAD_BYTES = "dm.upload.maxBytes";
|
||||
private static final long DEFAULT_MAX_UPLOAD_BYTES = 100L * 1024L * 1024L;
|
||||
|
||||
private DmFileStorage() {
|
||||
}
|
||||
|
||||
public static Path rootDir() {
|
||||
String configured = AppConfig.getInstance().getStringOrEmpty(CFG_FILES_DIR).trim();
|
||||
if (configured.isEmpty()) configured = "f";
|
||||
return Path.of(configured).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
public static void ensureRootDir() throws IOException {
|
||||
Files.createDirectories(rootDir());
|
||||
}
|
||||
|
||||
public static long maxUploadBytes() {
|
||||
String raw = AppConfig.getInstance().getStringOrEmpty(CFG_MAX_UPLOAD_BYTES).trim();
|
||||
if (raw.isEmpty()) return DEFAULT_MAX_UPLOAD_BYTES;
|
||||
try {
|
||||
long parsed = Long.parseLong(raw);
|
||||
return parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_BYTES;
|
||||
} catch (Exception ignored) {
|
||||
return DEFAULT_MAX_UPLOAD_BYTES;
|
||||
}
|
||||
}
|
||||
|
||||
public static Path resolvePathByHashB64Url(String hashB64Url) {
|
||||
return rootDir().resolve(hashB64Url).normalize();
|
||||
}
|
||||
|
||||
public static String hashToBase64Url(byte[] hashBytes) {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes);
|
||||
}
|
||||
|
||||
public static byte[] base64UrlToHash(String value) {
|
||||
try {
|
||||
byte[] decoded = Base64.getUrlDecoder().decode(value);
|
||||
if (decoded.length != 32) {
|
||||
throw new IllegalArgumentException("BAD_HASH_LEN");
|
||||
}
|
||||
return decoded;
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new IllegalArgumentException("BAD_HASH");
|
||||
}
|
||||
}
|
||||
|
||||
public static StoreResult storeCiphertext(InputStream in, String expectedHashB64Url, long expectedSize) throws Exception {
|
||||
if (expectedSize < 0) throw new IllegalArgumentException("BAD_SIZE");
|
||||
ensureRootDir();
|
||||
|
||||
byte[] expectedHash = base64UrlToHash(expectedHashB64Url);
|
||||
Path target = resolvePathByHashB64Url(expectedHashB64Url);
|
||||
if (Files.exists(target)) {
|
||||
long existingSize = Files.size(target);
|
||||
return new StoreResult(expectedHashB64Url, existingSize, true);
|
||||
}
|
||||
|
||||
long maxBytes = maxUploadBytes();
|
||||
Path tmp = Files.createTempFile(rootDir(), "upload-", ".tmp");
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
long written = 0;
|
||||
try (OutputStream out = Files.newOutputStream(tmp)) {
|
||||
byte[] buf = new byte[8192];
|
||||
while (true) {
|
||||
int read = in.read(buf);
|
||||
if (read < 0) break;
|
||||
if (read == 0) continue;
|
||||
written += read;
|
||||
if (written > maxBytes) {
|
||||
throw new IllegalArgumentException("UPLOAD_TOO_LARGE");
|
||||
}
|
||||
digest.update(buf, 0, read);
|
||||
out.write(buf, 0, read);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
try { Files.deleteIfExists(tmp); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
}
|
||||
|
||||
if (written != expectedSize) {
|
||||
Files.deleteIfExists(tmp);
|
||||
throw new IllegalArgumentException("SIZE_MISMATCH");
|
||||
}
|
||||
|
||||
byte[] actualHash = digest.digest();
|
||||
String actualHashB64Url = hashToBase64Url(actualHash);
|
||||
if (!MessageDigest.isEqual(expectedHash, actualHash)) {
|
||||
Files.deleteIfExists(tmp);
|
||||
throw new IllegalArgumentException("HASH_MISMATCH");
|
||||
}
|
||||
|
||||
try {
|
||||
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
return new StoreResult(actualHashB64Url, written, false);
|
||||
} catch (IOException moveError) {
|
||||
Files.deleteIfExists(tmp);
|
||||
if (Files.exists(target)) {
|
||||
return new StoreResult(actualHashB64Url, Files.size(target), true);
|
||||
}
|
||||
throw moveError;
|
||||
}
|
||||
}
|
||||
|
||||
public record StoreResult(String hashB64Url, long sizeBytes, boolean alreadyExists) {
|
||||
}
|
||||
}
|
||||
+20
-4
@@ -8,9 +8,12 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Reque
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.entities.DmFileRef;
|
||||
import shine.db.dao.SignedMessagesV2DAO;
|
||||
import shine.db.entities.SignedMessageV2Entry;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
@@ -32,9 +35,13 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
try {
|
||||
SignedMessagesCore.verifyUsersAndSignature(incoming);
|
||||
SignedMessagesCore.verifyUsersAndSignature(outgoing);
|
||||
SignedMessagesCore.ensureAllFilesExist(incoming);
|
||||
SignedMessagesCore.ensureAllFilesExist(outgoing);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
String code = ex.getMessage();
|
||||
int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED;
|
||||
int status = "USER_NOT_FOUND".equals(code)
|
||||
? 404
|
||||
: ("ATTACHMENT_NOT_FOUND".equals(code) ? WireCodes.Status.BAD_REQUEST : WireCodes.Status.UNVERIFIED);
|
||||
return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку");
|
||||
}
|
||||
|
||||
@@ -49,11 +56,20 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
||||
}
|
||||
|
||||
boolean pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
|
||||
boolean pairInserted;
|
||||
if (incoming.isContentType()) {
|
||||
List<DmFileRef> incomingFiles = SignedMessagesCore.attachmentRefs(incoming);
|
||||
List<DmFileRef> outgoingFiles = SignedMessagesCore.attachmentRefs(outgoing);
|
||||
pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPairReplaceFiles(
|
||||
incomingEntry, incomingFiles, outgoingEntry, outgoingFiles
|
||||
);
|
||||
} else {
|
||||
pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
|
||||
}
|
||||
|
||||
SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (pairInserted) {
|
||||
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null);
|
||||
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming);
|
||||
}
|
||||
|
||||
String excludeSessionId = null;
|
||||
@@ -62,7 +78,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
}
|
||||
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (pairInserted) {
|
||||
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId);
|
||||
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, outgoing, excludeSessionId);
|
||||
}
|
||||
|
||||
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
||||
|
||||
+159
-16
@@ -1,26 +1,39 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import shine.db.entities.DmFileRef;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
final class SignedMessageBlock {
|
||||
static final byte[] PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
|
||||
static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
|
||||
static final byte[] V1_PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII);
|
||||
static final int TYPE_INCOMING_TEXT = 1;
|
||||
static final int TYPE_OUTGOING_COPY = 2;
|
||||
static final int TYPE_READ_INCOMING = 3;
|
||||
static final int TYPE_READ_OUTGOING_COPY = 4;
|
||||
static final int MAX_ATTACHMENTS = 12;
|
||||
|
||||
final String toLogin;
|
||||
final String fromLogin;
|
||||
final long timeMs;
|
||||
final long nonce;
|
||||
final int messageType;
|
||||
final long revisionTimeMs;
|
||||
final int formatVersionMajor;
|
||||
final int formatVersionMinor;
|
||||
final byte[] payloadBytes;
|
||||
final byte[] encryptedBodyBytes;
|
||||
final List<DmFileRef> attachments;
|
||||
final byte[] signedBody;
|
||||
final byte[] signature64;
|
||||
final byte[] rawPacket;
|
||||
final boolean legacyFormat;
|
||||
|
||||
private SignedMessageBlock(
|
||||
String toLogin,
|
||||
@@ -28,36 +41,54 @@ final class SignedMessageBlock {
|
||||
long timeMs,
|
||||
long nonce,
|
||||
int messageType,
|
||||
long revisionTimeMs,
|
||||
int formatVersionMajor,
|
||||
int formatVersionMinor,
|
||||
byte[] payloadBytes,
|
||||
byte[] encryptedBodyBytes,
|
||||
List<DmFileRef> attachments,
|
||||
byte[] signedBody,
|
||||
byte[] signature64,
|
||||
byte[] rawPacket
|
||||
byte[] rawPacket,
|
||||
boolean legacyFormat
|
||||
) {
|
||||
this.toLogin = toLogin;
|
||||
this.fromLogin = fromLogin;
|
||||
this.timeMs = timeMs;
|
||||
this.nonce = nonce;
|
||||
this.messageType = messageType;
|
||||
this.revisionTimeMs = revisionTimeMs;
|
||||
this.formatVersionMajor = formatVersionMajor;
|
||||
this.formatVersionMinor = formatVersionMinor;
|
||||
this.payloadBytes = payloadBytes;
|
||||
this.encryptedBodyBytes = encryptedBodyBytes;
|
||||
this.attachments = attachments;
|
||||
this.signedBody = signedBody;
|
||||
this.signature64 = signature64;
|
||||
this.rawPacket = rawPacket;
|
||||
this.legacyFormat = legacyFormat;
|
||||
}
|
||||
|
||||
static SignedMessageBlock parse(byte[] raw, int maxPayloadBytes) {
|
||||
if (raw == null || raw.length < PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) {
|
||||
static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) {
|
||||
if (raw == null || raw.length < 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
if (raw.length > 8192) {
|
||||
throw new IllegalArgumentException("PAYLOAD_TOO_LARGE");
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||
byte[] prefix = new byte[PREFIX.length];
|
||||
bb.get(prefix);
|
||||
if (!Arrays.equals(prefix, PREFIX)) {
|
||||
throw new IllegalArgumentException("BAD_PREFIX");
|
||||
if (startsWith(raw, LEGACY_PREFIX)) {
|
||||
return parseLegacy(raw, maxEncryptedBodyBytes);
|
||||
}
|
||||
if (startsWith(raw, V1_PREFIX)) {
|
||||
return parseV1(raw, maxEncryptedBodyBytes);
|
||||
}
|
||||
throw new IllegalArgumentException("BAD_PREFIX");
|
||||
}
|
||||
|
||||
private static SignedMessageBlock parseLegacy(byte[] raw, int maxPayloadBytes) {
|
||||
if (raw.length < LEGACY_PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.position(LEGACY_PREFIX.length);
|
||||
|
||||
String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
|
||||
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
|
||||
@@ -67,9 +98,7 @@ final class SignedMessageBlock {
|
||||
|
||||
long nonce = Integer.toUnsignedLong(bb.getInt());
|
||||
int messageType = Short.toUnsignedInt(bb.getShort());
|
||||
if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) {
|
||||
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
|
||||
}
|
||||
ensureMessageType(messageType);
|
||||
|
||||
int payloadLen = Short.toUnsignedInt(bb.getShort());
|
||||
if (payloadLen < 1 || payloadLen > maxPayloadBytes) {
|
||||
@@ -86,7 +115,95 @@ final class SignedMessageBlock {
|
||||
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
||||
|
||||
return new SignedMessageBlock(
|
||||
toLogin, fromLogin, timeMs, nonce, messageType, payload, signedBody, signature64, raw
|
||||
toLogin,
|
||||
fromLogin,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
0L,
|
||||
2,
|
||||
0,
|
||||
payload,
|
||||
payload,
|
||||
List.of(),
|
||||
signedBody,
|
||||
signature64,
|
||||
raw,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private static SignedMessageBlock parseV1(byte[] raw, int maxEncryptedBodyBytes) {
|
||||
if (raw.length < V1_PREFIX.length + 2 + 1 + 1 + 8 + 4 + 2 + 8 + 1 + 4 + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.position(V1_PREFIX.length);
|
||||
|
||||
int major = Byte.toUnsignedInt(bb.get());
|
||||
int minor = Byte.toUnsignedInt(bb.get());
|
||||
if (major != 1 || minor != 0) {
|
||||
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||
}
|
||||
|
||||
String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
|
||||
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
|
||||
long timeMs = bb.getLong();
|
||||
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
|
||||
long nonce = Integer.toUnsignedLong(bb.getInt());
|
||||
int messageType = Short.toUnsignedInt(bb.getShort());
|
||||
ensureMessageType(messageType);
|
||||
long revisionTimeMs = bb.getLong();
|
||||
if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||
|
||||
int attachmentsCount = Byte.toUnsignedInt(bb.get());
|
||||
if (attachmentsCount > MAX_ATTACHMENTS) {
|
||||
throw new IllegalArgumentException("TOO_MANY_ATTACHMENTS");
|
||||
}
|
||||
List<DmFileRef> attachments = new ArrayList<>(attachmentsCount);
|
||||
for (int i = 0; i < attachmentsCount; i++) {
|
||||
if (bb.remaining() < 32 + 8 + 4 + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
byte[] hash = new byte[32];
|
||||
bb.get(hash);
|
||||
long size = bb.getLong();
|
||||
if (size < 0) throw new IllegalArgumentException("BAD_ATTACHMENT_SIZE");
|
||||
attachments.add(new DmFileRef(hash, size));
|
||||
}
|
||||
|
||||
if (bb.remaining() < 4 + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
long encryptedBodyLen = Integer.toUnsignedLong(bb.getInt());
|
||||
if (encryptedBodyLen > maxEncryptedBodyBytes) {
|
||||
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||
}
|
||||
if (bb.remaining() != encryptedBodyLen + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
byte[] encryptedBody = new byte[(int) encryptedBodyLen];
|
||||
bb.get(encryptedBody);
|
||||
byte[] signature64 = new byte[64];
|
||||
bb.get(signature64);
|
||||
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
||||
|
||||
return new SignedMessageBlock(
|
||||
toLogin,
|
||||
fromLogin,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
revisionTimeMs,
|
||||
major,
|
||||
minor,
|
||||
encryptedBody,
|
||||
encryptedBody,
|
||||
Collections.unmodifiableList(attachments),
|
||||
signedBody,
|
||||
signature64,
|
||||
raw,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,10 +215,36 @@ final class SignedMessageBlock {
|
||||
return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY;
|
||||
}
|
||||
|
||||
boolean isContentType() {
|
||||
return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_OUTGOING_COPY;
|
||||
}
|
||||
|
||||
boolean isReadReceiptType() {
|
||||
return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY;
|
||||
}
|
||||
|
||||
boolean isDeletedContent() {
|
||||
return isContentType() && !legacyFormat && attachments.isEmpty() && encryptedBodyBytes.length == 0;
|
||||
}
|
||||
|
||||
String targetLogin() {
|
||||
return isIncomingType() ? toLogin : fromLogin;
|
||||
}
|
||||
|
||||
private static void ensureMessageType(int messageType) {
|
||||
if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) {
|
||||
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean startsWith(byte[] raw, byte[] prefix) {
|
||||
if (raw.length < prefix.length) return false;
|
||||
for (int i = 0; i < prefix.length; i++) {
|
||||
if (raw[i] != prefix[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String readAscii(ByteBuffer bb, int minLen, int maxLen, String code) {
|
||||
if (!bb.hasRemaining()) throw new IllegalArgumentException(code);
|
||||
int len = Byte.toUnsignedInt(bb.get());
|
||||
|
||||
+78
-3
@@ -2,21 +2,27 @@ package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import shine.db.dao.SignedMessagesV2DAO;
|
||||
import shine.db.dao.SolanaUsersDAO;
|
||||
import shine.db.entities.DmFileRef;
|
||||
import shine.db.entities.SignedMessageV2Entry;
|
||||
import shine.db.entities.SolanaUserEntry;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
final class SignedMessagesCore {
|
||||
private static final int MAX_PAYLOAD_BYTES = 4096;
|
||||
private static final int MAX_ENCRYPTED_BODY_BYTES = 16384;
|
||||
|
||||
private SignedMessagesCore() {}
|
||||
|
||||
static SignedMessageBlock parseFromB64(String blobB64) {
|
||||
try {
|
||||
byte[] raw = Base64.getDecoder().decode(blobB64.trim());
|
||||
return SignedMessageBlock.parse(raw, MAX_PAYLOAD_BYTES);
|
||||
return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
||||
}
|
||||
@@ -42,7 +48,7 @@ final class SignedMessagesCore {
|
||||
if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||
if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||
|
||||
if (incoming.messageType == SignedMessageBlock.TYPE_READ_INCOMING) {
|
||||
if (incoming.isReadReceiptType()) {
|
||||
ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes);
|
||||
ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes);
|
||||
if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin)
|
||||
@@ -52,6 +58,63 @@ final class SignedMessagesCore {
|
||||
|| inRef.refType != outRef.refType) {
|
||||
throw new IllegalArgumentException("BAD_RECEIPT_REF");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (incoming.legacyFormat || outgoing.legacyFormat) {
|
||||
throw new IllegalArgumentException("BAD_CONTENT_FORMAT");
|
||||
}
|
||||
if (incoming.revisionTimeMs != outgoing.revisionTimeMs) {
|
||||
throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||
}
|
||||
if (incoming.formatVersionMajor != outgoing.formatVersionMajor
|
||||
|| incoming.formatVersionMinor != outgoing.formatVersionMinor) {
|
||||
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||
}
|
||||
if (incoming.attachments.size() != outgoing.attachments.size()) {
|
||||
throw new IllegalArgumentException("BAD_ATTACHMENTS");
|
||||
}
|
||||
|
||||
Set<String> seenIncoming = new HashSet<>();
|
||||
Set<String> seenOutgoing = new HashSet<>();
|
||||
for (int i = 0; i < incoming.attachments.size(); i++) {
|
||||
DmFileRef left = incoming.attachments.get(i);
|
||||
DmFileRef right = outgoing.attachments.get(i);
|
||||
if (left.getFileSize() != right.getFileSize()) {
|
||||
throw new IllegalArgumentException("BAD_ATTACHMENTS");
|
||||
}
|
||||
if (left.getFileHash() == null || right.getFileHash() == null) {
|
||||
throw new IllegalArgumentException("BAD_ATTACHMENTS");
|
||||
}
|
||||
if (left.getFileHash().length != 32 || right.getFileHash().length != 32) {
|
||||
throw new IllegalArgumentException("BAD_ATTACHMENTS");
|
||||
}
|
||||
String inDedup = Base64.getEncoder().encodeToString(left.getFileHash());
|
||||
String outDedup = Base64.getEncoder().encodeToString(right.getFileHash());
|
||||
if (!seenIncoming.add(inDedup) || !seenOutgoing.add(outDedup)) {
|
||||
throw new IllegalArgumentException("DUPLICATE_ATTACHMENTS");
|
||||
}
|
||||
if (!inDedup.equals(outDedup)) {
|
||||
throw new IllegalArgumentException("BAD_ATTACHMENTS");
|
||||
}
|
||||
}
|
||||
|
||||
if (incoming.encryptedBodyBytes.length != outgoing.encryptedBodyBytes.length) {
|
||||
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||
}
|
||||
for (int i = 0; i < incoming.encryptedBodyBytes.length; i++) {
|
||||
if (incoming.encryptedBodyBytes[i] != outgoing.encryptedBodyBytes[i]) {
|
||||
throw new IllegalArgumentException("BAD_ENCRYPTED_BODY");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void ensureAllFilesExist(SignedMessageBlock block) throws Exception {
|
||||
if (!block.isContentType()) return;
|
||||
for (DmFileRef ref : block.attachments) {
|
||||
if (!SignedMessagesV2DAO.getInstance().fileExists(ref.getFileHash(), ref.getFileSize())) {
|
||||
throw new IllegalArgumentException("ATTACHMENT_NOT_FOUND");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +131,7 @@ final class SignedMessagesCore {
|
||||
entry.setTimeMs(block.timeMs);
|
||||
entry.setNonce(block.nonce);
|
||||
entry.setMessageType(block.messageType);
|
||||
entry.setRevisionTimeMs(block.revisionTimeMs);
|
||||
entry.setRawBlock(block.rawPacket);
|
||||
entry.setCreatedAtMs(System.currentTimeMillis());
|
||||
entry.setSourceApi(sourceApi);
|
||||
@@ -83,6 +147,17 @@ final class SignedMessagesCore {
|
||||
return entry;
|
||||
}
|
||||
|
||||
static List<DmFileRef> attachmentRefs(SignedMessageBlock block) {
|
||||
return new ArrayList<>(block.attachments);
|
||||
}
|
||||
|
||||
static String previewTextForPush(SignedMessageBlock block) {
|
||||
if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) {
|
||||
return "";
|
||||
}
|
||||
return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static boolean saveIfAbsent(SignedMessageV2Entry entry) throws Exception {
|
||||
return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
|
||||
}
|
||||
|
||||
+20
-4
@@ -21,8 +21,13 @@ public final class SignedMessagesRealtime {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private SignedMessagesRealtime() {}
|
||||
|
||||
static DeliveryCounters deliverToTargetSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception {
|
||||
return deliverToTargetSessions(message, block, null);
|
||||
}
|
||||
|
||||
static DeliveryCounters deliverToTargetSessions(
|
||||
SignedMessageV2Entry message,
|
||||
SignedMessageBlock block,
|
||||
String excludeSessionId
|
||||
) throws Exception {
|
||||
DeliveryCounters counters = new DeliveryCounters();
|
||||
@@ -39,8 +44,11 @@ public final class SignedMessagesRealtime {
|
||||
counters.wsDelivered++;
|
||||
continue;
|
||||
}
|
||||
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT) {
|
||||
boolean pushed = pushNewMessageNotification(s, message);
|
||||
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT
|
||||
&& block != null
|
||||
&& block.revisionTimeMs == 0
|
||||
&& !block.isDeletedContent()) {
|
||||
boolean pushed = pushNewMessageNotification(s, message, block);
|
||||
if (pushed) counters.pushDelivered++;
|
||||
}
|
||||
}
|
||||
@@ -89,13 +97,21 @@ public final class SignedMessagesRealtime {
|
||||
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
||||
}
|
||||
|
||||
private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) {
|
||||
private static boolean pushNewMessageNotification(
|
||||
ActiveSessionEntry session,
|
||||
SignedMessageV2Entry message,
|
||||
SignedMessageBlock block
|
||||
) {
|
||||
try {
|
||||
if (session == null) return false;
|
||||
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||
return false;
|
||||
}
|
||||
String text = "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения.";
|
||||
String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim();
|
||||
if (preview.length() > 80) preview = preview.substring(0, 80) + "...";
|
||||
String text = preview.isBlank()
|
||||
? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения."
|
||||
: preview;
|
||||
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
||||
return WebPushSender.sendBase64Payload(
|
||||
session.getPushEndpoint(),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package server.files;
|
||||
|
||||
import jakarta.servlet.http.HttpServlet;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import server.logic.ws_protocol.JSON.messages.DmFileStorage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class DmFilesServlet extends HttpServlet {
|
||||
@Override
|
||||
protected void doOptions(HttpServletRequest req, HttpServletResponse resp) {
|
||||
applyCors(resp);
|
||||
resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws IOException {
|
||||
handleRead(req, resp, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
|
||||
handleRead(req, resp, false);
|
||||
}
|
||||
|
||||
private void handleRead(HttpServletRequest req, HttpServletResponse resp, boolean headOnly) throws IOException {
|
||||
applyCors(resp);
|
||||
String pathInfo = String.valueOf(req.getPathInfo() == null ? "" : req.getPathInfo()).trim();
|
||||
if (pathInfo.startsWith("/")) pathInfo = pathInfo.substring(1);
|
||||
if (pathInfo.isBlank()) {
|
||||
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "hash is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
DmFileStorage.base64UrlToHash(pathInfo);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "bad hash");
|
||||
return;
|
||||
}
|
||||
|
||||
Path target = DmFileStorage.resolvePathByHashB64Url(pathInfo);
|
||||
if (!Files.exists(target) || !Files.isRegularFile(target)) {
|
||||
resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
|
||||
resp.setStatus(HttpServletResponse.SC_OK);
|
||||
resp.setContentType("application/octet-stream");
|
||||
resp.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
resp.setHeader("Content-Disposition", "inline; filename=\"" + pathInfo + "\"");
|
||||
long size = Files.size(target);
|
||||
resp.setContentLengthLong(size);
|
||||
if (headOnly) return;
|
||||
Files.copy(target, resp.getOutputStream());
|
||||
}
|
||||
|
||||
private void applyCors(HttpServletResponse resp) {
|
||||
resp.setHeader("Access-Control-Allow-Origin", "*");
|
||||
resp.setHeader("Access-Control-Allow-Methods", "GET,HEAD,OPTIONS");
|
||||
resp.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package server.files;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import jakarta.servlet.http.HttpServlet;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import server.logic.ws_protocol.JSON.messages.DmFileStorage;
|
||||
import shine.db.dao.SignedMessagesV2DAO;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class DmUploadServlet extends HttpServlet {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
protected void doOptions(HttpServletRequest req, HttpServletResponse resp) {
|
||||
applyCors(resp);
|
||||
resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
|
||||
applyCors(resp);
|
||||
String hash = String.valueOf(req.getParameter("hash")).trim();
|
||||
String sizeRaw = String.valueOf(req.getParameter("size")).trim();
|
||||
if (hash.isEmpty() || sizeRaw.isEmpty()) {
|
||||
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "hash and size are required");
|
||||
return;
|
||||
}
|
||||
|
||||
long expectedSize;
|
||||
try {
|
||||
expectedSize = Long.parseLong(sizeRaw);
|
||||
if (expectedSize < 0) throw new NumberFormatException("negative");
|
||||
} catch (Exception ex) {
|
||||
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "bad size");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
DmFileStorage.StoreResult result = DmFileStorage.storeCiphertext(req.getInputStream(), hash, expectedSize);
|
||||
SignedMessagesV2DAO.getInstance().registerFileIfAbsent(
|
||||
DmFileStorage.base64UrlToHash(result.hashB64Url()),
|
||||
result.sizeBytes()
|
||||
);
|
||||
|
||||
ObjectNode payload = MAPPER.createObjectNode();
|
||||
payload.put("ok", true);
|
||||
payload.put("hash", result.hashB64Url());
|
||||
payload.put("size", result.sizeBytes());
|
||||
payload.put("alreadyExists", result.alreadyExists());
|
||||
|
||||
resp.setStatus(HttpServletResponse.SC_OK);
|
||||
resp.setContentType("application/json; charset=UTF-8");
|
||||
MAPPER.writeValue(resp.getOutputStream(), payload);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
|
||||
} catch (Exception ex) {
|
||||
resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "upload_failed");
|
||||
}
|
||||
}
|
||||
|
||||
private void applyCors(HttpServletResponse resp) {
|
||||
resp.setHeader("Access-Control-Allow-Origin", "*");
|
||||
resp.setHeader("Access-Control-Allow-Methods", "POST,OPTIONS");
|
||||
resp.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package server.ws;
|
||||
|
||||
import org.eclipse.jetty.server.Server;
|
||||
import org.eclipse.jetty.servlet.ServletHolder;
|
||||
import org.eclipse.jetty.servlet.ServletContextHandler;
|
||||
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.debug.DebugApiConfigurator;
|
||||
import server.files.DmFilesServlet;
|
||||
import server.files.DmUploadServlet;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.time.Duration;
|
||||
@@ -61,6 +64,8 @@ public final class WsServer {
|
||||
|
||||
// HTTP debug API
|
||||
DebugApiConfigurator.register(context);
|
||||
context.addServlet(new ServletHolder(new DmFilesServlet()), "/f/*");
|
||||
context.addServlet(new ServletHolder(new DmUploadServlet()), "/upload");
|
||||
|
||||
// Инициализация контейнера WebSocket
|
||||
JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> {
|
||||
@@ -75,4 +80,4 @@ public final class WsServer {
|
||||
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
||||
server.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
server.1port=7070
|
||||
db.path=data/shine.sqlite
|
||||
dm.files.dir=f
|
||||
dm.upload.maxBytes=104857600
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Server public info
|
||||
|
||||
Reference in New Issue
Block a user