НЕ ПРОВЕРЕНО: откат DM-вложений, оставлены ревизии и удаление

This commit is contained in:
AidarKC
2026-06-18 12:24:14 +04:00
parent 92fd315505
commit a95bd245cf
23 changed files with 309 additions and 1267 deletions
@@ -640,36 +640,6 @@ 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 = 6;
private static final int LATEST_SCHEMA_VERSION = 7;
private final String jdbcUrl;
@@ -89,6 +89,7 @@ public final class SqliteDbController {
case 4 -> migrateToV4();
case 5 -> migrateToV5();
case 6 -> migrateToV6();
case 7 -> migrateToV7();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
}
}
@@ -216,7 +217,6 @@ public final class SqliteDbController {
c.setAutoCommit(false);
try {
ensureSignedMessagesRevisionColumn(c, st);
ensureDmFileTables(st);
setSchemaVersion(c, 6);
c.commit();
} catch (Exception e) {
@@ -230,6 +230,25 @@ public final class SqliteDbController {
}
}
private void migrateToV7() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
dropDmFileTables(st);
setSchemaVersion(c, 7);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v7 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v7 failed", e);
}
}
private static void ensureChat200StateTables(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_state (
@@ -357,36 +376,11 @@ public final class SqliteDbController {
}
}
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 void dropDmFileTables(Statement st) throws SQLException {
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login");
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message");
st.executeUpdate("DROP TABLE IF EXISTS dm_message_file_links");
st.executeUpdate("DROP TABLE IF EXISTS dm_files");
}
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
@@ -1,7 +1,6 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.DmFileRef;
import shine.db.entities.SignedMessageV2Entry;
import java.sql.Connection;
@@ -10,9 +9,7 @@ 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;
@@ -45,9 +42,6 @@ public final class SignedMessagesV2DAO {
}
}
/**
* Атомарная вставка пары блоков legacy/read-receipt: либо вставляются оба, либо не вставляется ни один.
*/
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
@@ -73,25 +67,11 @@ public final class SignedMessagesV2DAO {
}
}
/**
* Атомарный upsert пары контентных DM с полной заменой файловых связей.
* Возвращает true, только если ревизия применена. Более старая или идентичная ревизия игнорируется.
*/
public boolean upsertContentPairReplaceFiles(
SignedMessageV2Entry incoming,
List<DmFileRef> incomingFiles,
SignedMessageV2Entry outgoing,
List<DmFileRef> outgoingFiles
) throws Exception {
public boolean upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) 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(
@@ -112,12 +92,8 @@ public final class SignedMessagesV2DAO {
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());
@@ -132,40 +108,6 @@ public final class SignedMessagesV2DAO {
}
}
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 {
try (Connection c = db.getConnection()) {
String sql = """
@@ -299,106 +241,6 @@ public final class SignedMessagesV2DAO {
}
}
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)) {
@@ -1,19 +0,0 @@
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; }
}
@@ -1,122 +0,0 @@
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) {
}
}
@@ -8,6 +8,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessag
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SignedMessagesV2DAO;
import shine.db.entities.SignedMessageV2Entry;
public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
@@ -43,7 +44,7 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
}
boolean inserted = SignedMessagesCore.saveIfAbsent(entry);
boolean inserted = SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (inserted) {
counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null);
@@ -8,12 +8,9 @@ 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 {
@@ -35,13 +32,9 @@ 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
: ("ATTACHMENT_NOT_FOUND".equals(code) ? WireCodes.Status.BAD_REQUEST : WireCodes.Status.UNVERIFIED);
int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED;
return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку");
}
@@ -58,10 +51,8 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
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
pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPair(
incomingEntry, outgoingEntry
);
} else {
pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
@@ -1,14 +1,9 @@
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[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
@@ -17,7 +12,6 @@ final class SignedMessageBlock {
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;
@@ -29,7 +23,6 @@ final class SignedMessageBlock {
final int formatVersionMinor;
final byte[] payloadBytes;
final byte[] encryptedBodyBytes;
final List<DmFileRef> attachments;
final byte[] signedBody;
final byte[] signature64;
final byte[] rawPacket;
@@ -46,7 +39,6 @@ final class SignedMessageBlock {
int formatVersionMinor,
byte[] payloadBytes,
byte[] encryptedBodyBytes,
List<DmFileRef> attachments,
byte[] signedBody,
byte[] signature64,
byte[] rawPacket,
@@ -62,7 +54,6 @@ final class SignedMessageBlock {
this.formatVersionMinor = formatVersionMinor;
this.payloadBytes = payloadBytes;
this.encryptedBodyBytes = encryptedBodyBytes;
this.attachments = attachments;
this.signedBody = signedBody;
this.signature64 = signature64;
this.rawPacket = rawPacket;
@@ -125,7 +116,6 @@ final class SignedMessageBlock {
0,
payload,
payload,
List.of(),
signedBody,
signature64,
raw,
@@ -157,19 +147,8 @@ final class SignedMessageBlock {
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 (attachmentsCount != 0) {
throw new IllegalArgumentException("ATTACHMENTS_DISABLED");
}
if (bb.remaining() < 4 + 64) {
@@ -199,7 +178,6 @@ final class SignedMessageBlock {
minor,
encryptedBody,
encryptedBody,
Collections.unmodifiableList(attachments),
signedBody,
signature64,
raw,
@@ -224,7 +202,7 @@ final class SignedMessageBlock {
}
boolean isDeletedContent() {
return isContentType() && !legacyFormat && attachments.isEmpty() && encryptedBodyBytes.length == 0;
return isContentType() && !legacyFormat && encryptedBodyBytes.length == 0;
}
String targetLogin() {
@@ -1,18 +1,12 @@
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_ENCRYPTED_BODY_BYTES = 16384;
@@ -24,7 +18,23 @@ final class SignedMessagesCore {
byte[] raw = Base64.getDecoder().decode(blobB64.trim());
return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
String code = e.getMessage();
if (code == null || code.isBlank()) {
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
}
switch (code) {
case "ATTACHMENTS_DISABLED",
"BAD_PREFIX",
"BAD_LEN",
"BAD_TO_LOGIN",
"BAD_FROM_LOGIN",
"BAD_TIME",
"BAD_MESSAGE_TYPE",
"BAD_MESSAGE_LEN",
"BAD_FORMAT_VERSION",
"BAD_REVISION_TIME" -> throw e;
default -> throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
}
}
}
@@ -71,33 +81,6 @@ final class SignedMessagesCore {
|| 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");
@@ -109,15 +92,6 @@ final class SignedMessagesCore {
}
}
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");
}
}
}
static SignedMessageV2Entry toEntry(SignedMessageBlock block, String sourceApi, String originSessionId) {
String baseKey = SignedMessageKeys.baseKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce);
String messageKey = SignedMessageKeys.messageKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce, block.messageType);
@@ -147,18 +121,10 @@ 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);
}
}
@@ -1,66 +0,0 @@
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");
}
}
@@ -1,69 +0,0 @@
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,14 +1,11 @@
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;
@@ -64,8 +61,6 @@ 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) -> {
@@ -1,7 +1,5 @@
server.1port=7070
db.path=data/shine.sqlite
dm.files.dir=f
dm.upload.maxBytes=104857600
# ------------------------------------------------------------
# Server public info